使用Go进行io_uring的动手实践

码农天地 -
使用Go进行io_uring的动手实践

在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操作方式的 ,让我们研究实现它的实际系统调用。 只有两个。

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

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

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复用技术》详解(上)

特别申明:本文内容来源网络,版权归原作者所有,如有侵权请立即与我们联系(cy198701067573@163.com),我们将及时处理。

Tags 标签

加个好友,技术交流

1628738909466805.jpg