在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 := 0for {    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_callbackfunc 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_callbackfunc 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复用技术》详解(上)