关于golang:使用Go进行iouring的动手实践

9次阅读

共计 8940 个字符,预计需要花费 23 分钟才能阅读完成。

在 Linux 中,零碎调用(syscalls)是所有的外围。它们是应用程序与内核交互的次要接口。因而,至关重要的是它们要快。尤其在后 Spectre / Meltdown 后世界中,这一点尤为重要。


如果感觉看完文章有所播种的话,能够关注我一下哦
知乎:秃顶之路
b 站:linux 亦有归途
每天都会更新咱们的公开课录播以及编程干货和大厂面经
或者间接点击链接 c /c++ linux 服务器开发高级架构师
来课堂上跟咱们讲师面对面交换
须要大厂面经跟学习纲要的小伙伴能够加群 973961276 获取


大部分零碎调用都解决 I / O,因为大多数应用程序都是这样做的。对于网络 I / O,咱们领有 epoll一系列 syscall,它们为咱们提供了相当快的性能。然而在文件系统 I / O 部门中,有点不足。咱们曾经有 async_io一段时间了,然而除了大量的利基应用程序之外,它并不是十分无益。次要起因是它仅在应用 关上文件时才起作用 O_DIRECT标记。这将使内核绕过所有操作系统缓存,并尝试间接在设施之间进行读写。当咱们试图使事件停顿很快时,这不是执行 I / O 的好办法。在缓冲模式下,它将同步运行。

All that is changing slowly because now we have a brand new interface to perform I/O with the kernel: io_uring

四周有很多嗡嗡声。没错,因为它为咱们提供了一个与内核进行交互的全新模型。让咱们深入研究它,并尝试理解它是什么以及它如何解决问题。而后,咱们将应用 Go 来构建一个小型演示应用程序来应用它。

背景

让咱们退后一步,想一想通常的零碎调用是如何工作的。咱们进行零碎调用,咱们在用户层中的应用程序调用内核,并在内核空间中复制数据。实现内核执行后,它将后果复制回用户空间缓冲区。而后返回。所有这些都在 syscall 依然被阻止的状况下产生。

马上,咱们能够看到很多瓶颈。有很多复制,并且有阻塞。Go 通过在应用程序和内核之间引入另一层来解决此问题:运行时。它应用一个虚构实体(通常称为 P),其中蕴含要运行的 goroutine 队列,而后将其映射到 OS 线程。

这种间接级别使它能够进行一些乏味的优化。每当咱们进行阻塞的 syscall 时,运行时就晓得了,它会将线程与 的 拆散 P 执行 goroutine,并取得一个新线程来执行其余 goroutine。这称为越区切换。而当零碎调用返回时,运行时尝试将其重新安装到 P。如果无奈取得收费的 P,它将把 goroutine 推入队列以待稍后执行,并将线程存储在池中。当您的代码进入零碎调用时,这就是 Go 出现“非阻塞”状态的形式。

很好,然而依然不能解决次要问题,即依然产生复制并且理论的 syscall 依然阻塞。

让咱们考虑一下手头的第一个问题:复制。咱们如何避免从用户空间复制到内核空间?好吧,显然咱们须要某种共享内存。好的,能够应用 来实现,该 mmap零碎调用 零碎调用能够映射用户与内核之间共享的内存块。

那须要复制。然而同步呢?即便咱们不复制,咱们也须要某种形式来同步咱们和内核之间的数据拜访。否则,咱们将遇到雷同的问题,因为应用程序将须要再次进行 syscall 能力执行锁定。

如果咱们将问题视为用户和内核是两个互相独立的组件,那么这实质上就是生产者-消费者问题。用户创立零碎调用申请,内核承受它们。实现后,它会向用户发出信号,表明已准备就绪,并且用户会承受它们。

侥幸的是,这个问题有一个古老的解决方案:环形缓冲区。环形缓冲区容许生产者和使用者之间实现高效同步,而基本没有锁定。正如您可能曾经晓得的那样,咱们须要两个环形缓冲区:一个提交队列(SQ),其中用户充当生产者并推送 syscall 申请,内核应用它们;还有一个实现队列(CQ),其中内核是生产者推动实现后果,而用户应用它们。

应用这种模型,咱们齐全打消了所有内存正本和锁定。从用户到内核的所有通信都能够十分高效地进行。这本质上是 的核心思想 io_uring施行。让咱们简要介绍一下它的外部,看看它是如何实现的。

io_uring 简介

要将申请推送到 SQ,咱们须要创立一个提交队列条目(SQE)。假如咱们要读取文件。略过许多细节,SQE 基本上将蕴含:

  • 操作码 :形容要进行的零碎调用的操作码。因为咱们对读取文件感兴趣,因而咱们将应用 的 readv 映射到操作码 零碎调用 IORING_OP_READV
  • 标记:这些是能够随任何申请传递的修饰符。咱们稍后会解决。
  • Fd:咱们要读取的文件的文件描述符。
  • 地址 :对于咱们的 readv 调用,它将创立一个缓冲区(或向量)数组以将数据读入其中。因而,地址字段蕴含该数组的地址。
  • Length:向量数组的长度。
  • 用户数据:一个标识符,用于将咱们的申请从实现队列中移出。请记住,不能保障实现后果的程序与 SQE 雷同。那会毁坏应用异步 API 的全副目标。因而,咱们须要一些货色来辨认咱们提出的申请。这达到了目标。通常,这是指向一些保留有申请元数据的构造的指针。

在实现方面,咱们从 CQ 取得实现队列事件(CQE)。这是一个非常简单的构造,其中蕴含:

  • 后果:的返回值 readvsyscall。如果胜利,它将读取字节数。否则,它将具备错误代码。
  • 用户数据:咱们在 SQE 中传递的标识符。

这里只须要留神一个重要的细节:SQ 和 CQ 在用户和内核之间共享。然而,只管 CQ 实际上蕴含 CQE,但对于 SQ 而言却有所不同。它实质上是一个间接层,其中 SQ 数组中的索引值实际上蕴含保留 SQE 项的理论数组的索引。这对于某些在内部结构中具备提交申请的应用程序很有用,因而容许它们在一个操作中提交多个申请,从实质上简化了 的采纳 io_uringAPI。

这意味着咱们实际上在内存中映射了三件事:提交队列,实现队列和提交队列数组。下图应使状况更分明:

当初,让咱们从新拜访 的 flags之前跳过 字段。正如咱们所探讨的,CQE 条目可能齐全不同于队列中提交的条目。这带来了一个乏味的问题。如果咱们要一个接一个地执行一系列 I / O 操作怎么办?例如,文件正本。咱们想从文件描述符中读取并写入另一个文件。在以后状态下,咱们甚至无奈开始提交写入操作,直到看到 CQ 中呈现读取事件为止。那就是 的中央 flags进来。

咱们能够 设置 IOSQE_IO_LINKflags现场 以实现这一指标。如果设置了此选项,则下一个 SQE 将主动链接到该 SQE,直到以后 SQE 实现后它才开始。这使咱们可能按所需形式对 I / O 事件执行排序。文件复制只是一个示例。从实践上讲,咱们能够 链接 任何 彼此 零碎调用,直到在未设置该字段的状况下推送 SQE,此时该链被视为已损坏。

零碎调用

通过对 简要概述 io_uring操作形式的,让咱们钻研实现它的理论零碎调用。只有两个。

  1. int io_uring_setup(unsigned entries, struct io_uring_params *params);

entries示意 SQEs 的数量为该环。params是一个构造,其中蕴含无关应用程序要应用的 CQ 和 SQ 的各种详细信息。它向该 返回文件描述符 io_uring实例。

  1. int io_uring_enter(unsigned int fd, unsigned int to_submit, unsigned int min_complete, unsigned int flags, sigset_t sig);

该调用用于向内核提交申请。让咱们疾速浏览以下重要内容:

  • fd是上一次调用返回的环的文件描述符。
  • to_submit通知内核要从环中耗费多少条目。请记住,这些环位于共享内存中。因而,在要求内核解决它们之前,咱们能够随便推送任意数量的条目。
  • min_complete批示在返回之前,呼叫应期待多少条目能力实现。

精明的读者会留神到,中具备 to_submitmin_complete在雷同的调用 意味着咱们能够应用它来仅提交,或仅实现,甚至两者!这将关上 API,以依据应用程序工作负载以各种乏味的形式应用。

轮询模式

对于提早敏感的应用程序或具备极高 IOPS 的应用程序,每次有可用数据读取时让设施驱动程序中断内核是不够高效的。如果咱们要读取大量数据,那么高中断率实际上会减慢用于处理事件的内核吞吐量。在这些状况下,咱们实际上会退回轮询设施驱动程序。要将轮询与一起应用 io_uring,咱们能够 设置 IORING_SETUP_IOPOLL在 标记 io_uring_setup呼叫中,并将轮询事件与 的 保持一致 IORING_ENTER_GETEVENTS设置 io_uring_enter呼叫中。

但这依然须要咱们(用户)拨打电话。为了进步性能,,io_uring它还具备称为“内核侧轮询”的性能 通过该性能,如果将 设置为 IORING_SETUP_SQPOLL标记 io_uring_params,内核将主动轮询 SQ 以查看是否有新条目并应用它们。这基本上意味着咱们能够持续做所有的 I / 咱们想Ø不执行甚至一个 繁多的 零碎 打电话。这扭转了所有。

然而,所有这些灵活性和原始功率都是有代价的。间接应用此 API 并非易事且容易出错。因为咱们的数据结构是在用户和内核之间共享的,因而咱们须要设置内存屏障(神奇的编译器命令以强制执行内存操作的程序)和其余技巧,以正确实现工作。

侥幸的是,的创建者 Jens Axboe io_uring创立了一个包装器库,liburing以帮忙简化所有操作。应用 liburing,咱们大抵必须执行以下步骤:

  • io_uring_queue_(init|exit)设置并拆下戒指。
  • io_uring_get_sqe取得 SQE。
  • io_uring_prep_(readv|writev|other)标记要应用的零碎调用。
  • io_uring_sqe_set_data标记用户数据字段。
  • io_uring_(wait|peek)_cqe期待 CQE 或不期待而窥视它。
  • io_uring_cqe_get_data取回用户数据字段。
  • io_uring_cqe_seen将 CQE 标记为实现。

在 Go 中包装 io_uring

有很多实践须要消化。为了简洁起见,我特意跳过了更多内容。当初,让咱们回到用 Go 语言编写一些代码,并尝试一下。

为了简略和平安起见,咱们将应用该 liburing库,这意味着咱们将须要应用 CGo。很好,因为这只是一个玩具,正确的办法是 取得 本机反对 在 Go 运行时中。后果,可怜的是,咱们将不得不应用回调。在本机 Go 中,正在运行的 goroutine 将在运行时进入睡眠状态,而后在实现队列中的数据可用时被唤醒。

让咱们给程序包命名 frodo(就像这样,我淘汰了计算机科学中两个最艰难的问题之一)。咱们将只有一个非常简单的 API 来读写文件。还有另外两个性能可在实现后设置和清理环。

咱们的次要力量将是单个 goroutine,它将承受提交申请并将其推送到 SQ。而后从 C 中应用 CQE 条目对 Go 进行回调。咱们将应用 fd一旦取得数据,文件的来晓得要执行哪个回调。然而,咱们还须要确定何时将队列理论提交给内核。咱们维持一个队列阈值,如果超过了未决申请的阈值,咱们将提交。而且,咱们向用户提供了另一个性能,容许他们本人进行提交,以使他们能够更好地控制应用程序的行为。

再次留神,这是一种低效的解决形式。因为 CQ 和 SQ 齐全离开,因而它们基本不须要任何锁定,因而提交和实现能够从不同的线程中自在进行。现实状况下,咱们只需将一个条目推送到 SQ 并让一个独自的 goroutine 监听期待实现的工夫,每当看到一个条目时,咱们都会进行回调并回到期待状态。还记得咱们能够用来 io_uring_enter实现工作吗?这是一个这样的例子!这依然使每个 CQE 条目只有一个零碎调用,咱们甚至能够通过指定要期待的 CQE 条目数来进一步优化它。

回到咱们的简化模型,这是它的样子的伪代码:

// ReadFile reads a file from the given path and returns the result as a byte slice
// in the passed callback function.
func ReadFile(path string, cb func(buf []byte)) error {f, err := os.Open(path)
    // handle error

    fi, err := f.Stat()
    // handle error

    submitChan <- &request{
        code:   opCodeRead, // a constant to identify which syscall we are going to make
        f:      f,          // the file descriptor
        size:   fi.Size(),  // size of the file
        readCb: cb,        // the callback to call when the read is done
    }
    return nil
}
// WriteFile writes data to a file at the given path. After the file is written,
// it then calls the callback with the number of bytes written.
func WriteFile(path string, data []byte, perm os.FileMode, cb func(written int)) error {f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
    // handle error
    submitChan <- &request{
        code:    opCodeWrite, // same as above. This is for the writev syscall
        buf:     data,        // the byte slice of data to be written
        f:       f,           // the file descriptor
        writeCb: cb,          // the callback to call when the write is done
    }
    return nil
}

submitChan将申请发送给咱们的次要工作人员,由他们负责提交。这是伪代码:

 

queueSize := 0
for {
    select {
    case sqe := <-submitChan:
        switch sqe.code {
        case opCodeRead:
            // We store the fd in our cbMap to be called later from the callback from C.
            cbMap[sqe.f.Fd()] = cbInfo{
                readCb: sqe.readCb,
                close:  sqe.f.Close,
            }

            C.push_read_request(C.int(sqe.f.Fd()), C.long(sqe.size))
        case opCodeWrite:
            cbMap[sqe.f.Fd()] = cbInfo{
                writeCb: sqe.writeCb,
                close:   sqe.f.Close,
            }

            C.push_write_request(C.int(sqe.f.Fd()), ptr, C.long(len(sqe.buf)))
        }

        queueSize++
        if queueSize > queueThreshold { // if queue_size > threshold, then pop all.
            submitAndPop(queueSize)
            queueSize = 0
        }
    case <-pollChan:
        if queueSize > 0 {submitAndPop(queueSize)
            queueSize = 0
        }
    case <-quitChan:
        // possibly drain channel.
        // pop_request till everything is done.
        return
    }
}

cbMap将文件描述符映射到要调用的理论回调函数。当 CGo 代码调用 Go 代码来示意事件实现 代码 submitAndPop调用时,io_uring_submit_and_wait应用此 queueSize,而后从 CQ 弹出条目。

让咱们来看看成什么 C.push_read_requestC.push_write_request做。他们实际上所做的只是向 SQ 推送读 / 写申请。

他们看起来像这样:
 

int push_read_request(int file_fd, off_t file_sz) {
    // Create a file_info struct
    struct file_info *fi;

    // Populate the struct with the vectors and some metadata
    // like the file size, fd and the opcode IORING_OP_READV.

    // Get an SQE.
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    // Mark the operation to be readv.
    io_uring_prep_readv(sqe, file_fd, fi->iovecs, total_blocks, 0);
    // Set the user data section.
    io_uring_sqe_set_data(sqe, fi);
    return 0;
}

int push_write_request(int file_fd, void *data, off_t file_sz) {
    // Create a file_info struct
    struct file_info *fi;

    // Populate the struct with the vectors and some metadata
    // like the file size, fd and the opcode IORING_OP_WRITEV.

    // Get an SQE.
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    // Mark the operation to be writev.
    io_uring_prep_writev(sqe, file_fd, fi->iovecs, 1, 0);
    // Set the user data section.
    io_uring_sqe_set_data(sqe, fi);
    return 0;
}

submitAndPop尝试从 CQ 弹出条目时,将执行以下命令:

int pop_request() {
    struct io_uring_cqe *cqe;
    // Get an element from CQ without waiting.
    int ret = io_uring_peek_cqe(&ring, &cqe);
    // some error handling

    // Get the user data set in the set_data call.
    struct file_info *fi = io_uring_cqe_get_data(cqe);
    if (fi->opcode == IORING_OP_READV) {
        // Calculate the number of blocks read.

        // Call read_callback to Go.
        read_callback(fi->iovecs, total_blocks, fi->file_fd);
    } else if (fi->opcode == IORING_OP_WRITEV) {
        // Call write_callback to Go.
        write_callback(cqe->res, fi->file_fd);
    }

    // Mark the queue item as seen.
    io_uring_cqe_seen(&ring, cqe);
    return 0;
}

read_callbackwrite_callback从刚刚失去的条目 cbMap与传递 fd和调用所需的回调函数最后收回 ReadFile/ WriteFile电话。

//export read_callback
func read_callback(iovecs *C.struct_iovec, length C.int, fd C.int) {
    var buf bytes.Buffer
    // Populate the buffer with the data passed.

    cbMut.Lock()
    cbMap[uintptr(fd)].close()
    cbMap[uintptr(fd)].readCb(buf.Bytes())
    cbMut.Unlock()}

//export write_callback
func write_callback(written C.int, fd C.int) {cbMut.Lock()
    cbMap[uintptr(fd)].close()
    cbMap[uintptr(fd)].writeCb(int(written))
    cbMut.Unlock()}

基本上就是这样!如何应用该库的示例如下:

err := frodo.ReadFile("shire.html", func(buf []byte) {// handle buf})
if err != nil {// handle err}

随时查看 源代码,以深刻理解实现的细节。

性能

没有一些性能数字,没有任何博客文章是残缺的。然而,对 I / O 引擎进行适当的基准测试比拟可能会须要另外一篇博客文章。为了残缺起见,我将简短而迷信的测试后果公布到笔记本电脑上。不要过多地浏览它,因为任何基准测试都高度依赖于工作负载,队列参数,硬件,一天中的工夫以及衬衫的色彩。

咱们将应用 fio 由 Jens 本人编写的丑陋工具 来对具备不同工作负载的多个 I / O 引擎进行基准测试,同时反对 io_uringlibaio。旋钮太多,无奈更改。然而,咱们将应用比率为 75/25 的随机读 / 写工作量,应用 1GiB 文件以及 16KiB,32KiB 和 1MiB 的不同块大小来执行一个非常简单的试验。而后,咱们以 8、16 和 32 的队列大小反复整个试验。

请留神,这是 io_uring不轮询的基本模式,在这种状况下,后果可能更高。

论断

这是一篇相当长的文章,十分感谢您浏览本文!

io_uring仍处于起步阶段,但很快就吸引了很多人。许多知名人士(例如 libuv 和 RocksDB)曾经反对它。甚至有一个补丁能够 nginx减少 io_uring反对。


对 io 复用技术感兴趣的小伙伴还能够看看这个视频
高性能服务器《IO 复用技术》详解(上)

正文完
 0