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