在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)。 这是一个非常简单的结构,其中包含:
- 结果 : 的返回值
readv
syscall 。 如果成功,它将读取字节数。 否则,它将具有错误代码。 - 用户数据 :我们在SQE中传递的标识符。
这里只需要注意一个重要的细节:SQ和CQ在用户和内核之间共享。 但是,尽管CQ实际上包含CQE,但对于SQ而言却有所不同。 它本质上是一个间接层,其中SQ数组中的索引值实际上包含保存SQE项的实际数组的索引。 这对于某些在内部结构中具有提交请求的应用程序很有用,因此允许它们在一个操作中提交多个请求,从本质上简化了 的采用 io_uring
API 。
这意味着我们实际上在内存中映射了三件事:提交队列,完成队列和提交队列数组。 下图应使情况更清楚:
现在,让我们重新访问 的 flags
之前跳过 字段。 正如我们所讨论的,CQE条目可能完全不同于队列中提交的条目。 这带来了一个有趣的问题。 如果我们要一个接一个地执行一系列I / O操作怎么办? 例如,文件副本。 我们想从文件描述符中读取并写入另一个文件。 在当前状态下,我们甚至无法开始提交写入操作,直到看到CQ中出现读取事件为止。 那就是 的地方 flags
进来 。
我们可以 设置 IOSQE_IO_LINK
在 flags
现场 以实现这一目标。 如果设置了此选项,则下一个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_submit
和 min_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_request
和 C.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_callback
与 write_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_uring
和 libaio
。 旋钮太多,无法更改。 但是,我们将使用比率为75/25的随机读/写工作量,使用1GiB文件以及16KiB,32KiB和1MiB的不同块大小来执行一个非常简单的实验。 然后,我们以8、16和32的队列大小重复整个实验。
请注意,这是 io_uring
不轮询的基本模式,在这种情况下,结果可能更高。
结论
这是一篇相当长的文章,非常感谢您阅读本文!
io_uring
仍处于起步阶段,但很快就吸引了很多人。 许多知名人士(例如libuv和RocksDB)已经支持它。 甚至有一个补丁可以 nginx
增加 io_uring
支持。
对io复用技术感兴趣的小伙伴还可以看看这个视频
高性能服务器《IO复用技术》详解(上)
有疑问加站长微信联系(非本文作者)