
最近,关于LWN的一篇有关用于轮询的新内核接口的文章吸引了作者的注意力。 它讨论了Linux AIO API(用于异步文件处理的接口)中的新轮询机制,该机制已添加到内核版本4.18中。 这个想法很有趣:该补丁的作者建议使用Linux AIO API与网络配合使用。
但请稍等! 毕竟,Linux AIO的创建是为了处理磁盘之间的异步I / O! 磁盘上的文件与网络连接不同。 甚至可以使用Linux AIO API进行联网吗?
事实证明,是的,有可能! 本文介绍了如何利用Linux AIO API的优势来创建更快更好的网络服务器。
但是,让我们从解释什么是Linux AIO开始。
Linux AIO简介
Linux AIO为用户软件提供异步磁盘到磁盘I / O。
历史上,在Linux上,所有磁盘操作都被阻止。 如果您调用
open()
,
read()
,
write()
或
fsync()
,则流将停止直到元数据出现在磁盘缓存中。 通常这不是问题。 如果没有很多I / O操作和足够的内存,则系统调用将逐渐填充高速缓存,并且一切将足够快地工作。
当I / O操作的数量足够大时(例如,在具有数据库和代理的情况下),其性能会降低。 对于此类应用程序,为了等待一个
read()
系统调用而停止整个过程是不可接受的。
要解决此问题,应用程序可以使用三种方法:
- 在单独的线程上使用线程池和调用阻止功能。 这就是POSIX AIO在glibc中的工作方式(不要将其与Linux AIO混淆)。 有关更多信息,请参阅IBM文档 。 这就是我们解决Cloudflare中问题的方法: 我们使用线程池调用
read()
和open()
。
- 使用
posix_fadvise(2)
预热磁盘缓存,并希望达到最佳效果。
- 将Linux AIO与XFS文件系统结合使用, 使用O_DIRECT标志打开文件并避免未记录的问题 。
但是,这些方法都不是理想的。 即使
io_submit()
使用Linux AIO,也可以在
io_submit()
调用中阻止它。 最近在
有关LWN的另
一篇文章中提到了这一点:
“ Linux异步I / O接口有很多批评家,而支持者却很少,但是大多数人期望它至少具有异步性。 实际上,在调用线程负担不起的情况下,由于多种原因,可以在内核中阻止AIO操作。”
现在我们知道了Linux AIO API的弱点,下面让我们看一下它的优点。
使用Linux AIO的简单程序
为了使用Linux AIO,您首先必须自己
确定所有五个必需的系统调用 -glibc不提供它们。
- 首先,您需要调用
io_setup()
来初始化aio_context
结构。 内核将返回一个不透明的指向结构的指针。
- 之后,您可以调用
io_submit()
以结构iocb结构的形式将“ I / O控制块”的向量添加到处理队列中。
- 现在,最后,我们可以调用
io_getevents()
并以struct io_event
结构的向量(每个iocb块的结果)的形式等待它的响应。
您可以在iocb中使用八个命令。 两个用于读取的命令,两个用于写入的命令,两个fsync选项,以及POLL命令,该命令是在内核版本4.18中添加的(第八个命令为NOOP):
IOCB_CMD_PREAD = 0, IOCB_CMD_PWRITE = 1, IOCB_CMD_FSYNC = 2, IOCB_CMD_FDSYNC = 3, IOCB_CMD_POLL = 5, /* from 4.18 */ IOCB_CMD_NOOP = 6, IOCB_CMD_PREADV = 7, IOCB_CMD_PWRITEV = 8,
传递给
io_submit
函数
iocb
相当大,可以与磁盘一起使用。 这是其简化版本:
struct iocb { __u64 data; /* user data */ ... __u16 aio_lio_opcode; /* see IOCB_CMD_ above */ ... __u32 aio_fildes; /* file descriptor */ __u64 aio_buf; /* pointer to buffer */ __u64 aio_nbytes; /* buffer size */ ... }
io_getevents
返回的完整
io_event
结构:
struct io_event { __u64 data; /* user data */ __u64 obj; /* pointer to request iocb */ __s64 res; /* result code for this event */ __s64 res2; /* secondary result */ };
一个例子。 一个简单的程序,使用Linux AIO API读取/ etc / passwd文件:
fd = open("/etc/passwd", O_RDONLY); aio_context_t ctx = 0; r = io_setup(128, &ctx); char buf[4096]; struct iocb cb = {.aio_fildes = fd, .aio_lio_opcode = IOCB_CMD_PREAD, .aio_buf = (uint64_t)buf, .aio_nbytes = sizeof(buf)}; struct iocb *list_of_iocb[1] = {&cb}; r = io_submit(ctx, 1, list_of_iocb); struct io_event events[1] = {{0}}; r = io_getevents(ctx, 1, 1, events, NULL); bytes_read = events[0].res; printf("read %lld bytes from /etc/passwd\n", bytes_read);
当然,完整的资源
可以在GitHub上找到 。 这是该程序的strace输出:
openat(AT_FDCWD, "/etc/passwd", O_RDONLY) io_setup(128, [0x7f4fd60ea000]) io_submit(0x7f4fd60ea000, 1, [{aio_lio_opcode=IOCB_CMD_PREAD, aio_fildes=3, aio_buf=0x7ffc5ff703d0, aio_nbytes=4096, aio_offset=0}]) io_getevents(0x7f4fd60ea000, 1, 1, [{data=0, obj=0x7ffc5ff70390, res=2494, res2=0}], NULL)
一切正常,但是从磁盘读取不是异步的:io_submit调用被阻止并且完成了所有工作,
io_getevents
函数立即执行。 我们可以尝试异步读取,但这需要O_DIRECT标志,磁盘操作使用该标志绕过缓存。
让我们更好地说明
io_submit
如何锁定常规文件。 这是一个类似的示例,显示了从
/dev/zero
读取1 GB块后strace的输出:
io_submit(0x7fe1e800a000, 1, [{aio_lio_opcode=IOCB_CMD_PREAD, aio_fildes=3, aio_buf=0x7fe1a79f4000, aio_nbytes=1073741824, aio_offset=0}]) \ = 1 <0.738380> io_getevents(0x7fe1e800a000, 1, 1, [{data=0, obj=0x7fffb9588910, res=1073741824, res2=0}], NULL) \ = 1 <0.000015>
内核在
io_submit
调用上花费了738毫秒
io_submit
而在
io_getevents
上仅花费了15 ns。 它与网络连接的行为类似-所有工作都由
io_submit
完成。
照相 Helix84 CC / BY-SA / 3.0
Linux AIO和网络
io_submit
实现非常保守:如果未使用O_DIRECT标志打开传递的文件描述符,则该函数将简单地阻塞并执行指定的操作。 对于网络连接,这意味着:
- 为了阻止连接,IOCV_CMD_PREAD将等待响应数据包;
- 对于非阻塞连接,IOCB_CMD_PREAD将返回代码-11(EAGAIN)。
在常规的
read()
系统调用中也使用了相同的语义,因此可以说,使用网络连接时io_submit并不比旧的
read() / write()
调用更聪明。
重要的是要注意,
iocb
请求
iocb
由内核顺序执行的。
尽管Linux AIO不能帮助我们进行异步操作,但是它可以用于将系统调用组合为批处理。
如果Web服务器需要从数百个网络连接发送和接收数据,那么使用
io_submit
可能是一个好主意,因为它避免了数百个发送和接收调用。 这将提高性能-从用户空间切换到内核,反之亦然,这并不是免费的,尤其是在
采取了应对Spectre和Meltdown的
措施之后。
| 一个缓冲区
| 多个缓冲区
|
一个文件描述符
| 阅读()
| readv()
|
多个文件描述符
| io_submit + IOCB_CMD_PREAD
| io_submit + IOCB_CMD_PREADV
|
为了说明使用
io_submit
将系统调用分组为数据包
io_submit
让我们编写一个小程序,将数据从一个TCP连接发送到另一个。 以最简单的形式(没有Linux AIO),它看起来像这样:
while True: d = sd1.read(4096) sd2.write(d)
我们可以通过Linux AIO表达相同的功能。 这种情况下的代码将如下所示:
struct iocb cb[2] = {{.aio_fildes = sd2, .aio_lio_opcode = IOCB_CMD_PWRITE, .aio_buf = (uint64_t)&buf[0], .aio_nbytes = 0}, {.aio_fildes = sd1, .aio_lio_opcode = IOCB_CMD_PREAD, .aio_buf = (uint64_t)&buf[0], .aio_nbytes = BUF_SZ}}; struct iocb *list_of_iocb[2] = {&cb[0], &cb[1]}; while(1) { r = io_submit(ctx, 2, list_of_iocb); struct io_event events[2] = {}; r = io_getevents(ctx, 2, 2, events, NULL); cb[0].aio_nbytes = events[1].res; }
此代码向
io_submit
添加了两个作业:首先是对
io_submit
的写请求,然后是来自sd1的读请求。 读取后,代码将更正写缓冲区的大小,并从头开始重复循环。 有一个技巧:第一次使用大小为0的缓冲区进行写操作。这是必需的,因为我们能够在一个
io_submit
调用中组合写+读(但不能读+写)。
这段代码比常规的
read()
/
write()
快吗? 还没 这两个版本都使用两个系统调用:读+写和io_submit + io_getevents。 但是,幸运的是,代码可以得到改进。
摆脱io_getevents
在运行时
io_setup()
内核为进程分配几页内存。 这是此内存块在/ proc //映射中的外观:
marek:~$ cat /proc/`pidof -s aio_passwd`/maps ... 7f7db8f60000-7f7db8f63000 rw-s 00000000 00:12 2314562 /[aio] (deleted) ...
分配了内存块aio(在这种情况下为12 Kb)
io_setup
。 它用于存储事件的循环缓冲区。 在大多数情况下,没有理由调用
io_getevents
可以从环形缓冲区获取事件完成数据,而无需切换到内核模式。 这是代码的更正版本:
int io_getevents(aio_context_t ctx, long min_nr, long max_nr, struct io_event *events, struct timespec *timeout) { int i = 0; struct aio_ring *ring = (struct aio_ring*)ctx; if (ring == NULL || ring->magic != AIO_RING_MAGIC) { goto do_syscall; } while (i < max_nr) { unsigned head = ring->head; if (head == ring->tail) { /* There are no more completions */ break; } else { /* There is another completion to reap */ events[i] = ring->events[head]; read_barrier(); ring->head = (head + 1) % ring->nr; i++; } } if (i == 0 && timeout != NULL && timeout->tv_sec == 0 && timeout->tv_nsec == 0) { /* Requested non blocking operation. */ return 0; } if (i && i >= min_nr) { return i; } do_syscall: return syscall(__NR_io_getevents, ctx, min_nr-i, max_nr-i, &events[i], timeout); }
完整版本的代码可
在GitHub上找到 。 该环形缓冲区的接口文献很少;作者改编了
axboe / fio项目中的代码。
进行此更改后,我们使用Linux AIO的代码版本仅需要在一个循环中进行一次系统调用,这使其比使用读写+原始代码要快一些。
照片 火车照片 CC / BY-SA / 2.0
Epoll替代
通过在内核版本4.18中添加IOCB_CMD_POLL,可以使用
io_submit
替代select / poll / epoll。 例如,此代码将期望来自网络连接的数据:
struct iocb cb = {.aio_fildes = sd, .aio_lio_opcode = IOCB_CMD_POLL, .aio_buf = POLLIN}; struct iocb *list_of_iocb[1] = {&cb}; r = io_submit(ctx, 1, list_of_iocb); r = io_getevents(ctx, 1, 1, events, NULL);
完整代码 。 这是他的strace输出:
io_submit(0x7fe44bddd000, 1, [{aio_lio_opcode=IOCB_CMD_POLL, aio_fildes=3}]) \ = 1 <0.000015> io_getevents(0x7fe44bddd000, 1, 1, [{data=0, obj=0x7ffef65c11a8, res=1, res2=0}], NULL) \ = 1 <1.000377>
如您所见,这次异步工作了:io_submit立即执行,
io_getevents
阻塞一秒钟,等待数据。 可以使用它代替
epoll_wait()
系统调用。
此外,使用
epoll
通常需要使用epoll_ctl系统调用。 应用程序开发人员尝试避免频繁调用此功能-要了解原因,只需
阅读手册中的 EPOLLONESHOT和EPOLLET标志。 使用io_submit查询连接,可以避免这些困难和其他系统调用。 只需将连接添加到iocb向量,一次调用io_submit并等待执行。 一切都非常简单。
总结
在本文中,我们介绍了Linux AIO API。 该API最初旨在与磁盘一起使用,但也与网络连接一起使用。 但是,与常规的read()+ write()调用不同,使用io_submit可以对系统调用进行分组,从而提高性能。
从内核版本4.18开始,对于网络连接,可以将
io_submit io_getevents
用于形式为POLLIN和POLLOUT的事件。 这是
epoll()
的替代方法。
我可以想象一个网络服务仅使用
io_submit io_getevents
而不使用标准的读取,写入,epoll_ctl和epoll_wait组。 在这种情况下,对
io_submit
系统调用进行
io_submit
可以提供很大的优势,这样的服务器将更快。
不幸的是,即使在最近对Linux AIO API进行了改进之后,有关其有用性的讨论仍在继续。 众所周知,
莱纳斯讨厌他 :
“ AIO是膝盖高的设计的一个可怕例子,其主要借口是:“其他人才较少的人提出了这一要求,因此我们必须遵守兼容性,以便数据库开发人员(很少有品味的人)可以使用它。” 但是AIO一直非常非常弯曲。”
为了创建更好的接口来分组呼叫和异步,已经进行了几次尝试,但是它们缺乏共同的愿景。 例如,最近
添加的sendto(MSG_ZEROCOPY)允许真正异步的数据传输,但不提供分组。
io_submit
提供分组功能,但不提供异步功能。 更糟糕的是,目前有三种在Linux上传递异步事件的方式:信号,
io_getevents
和MSG_ERRQUEUE。
无论如何,很高兴有新方法来加快网络服务的工作。