io_submit:您从未听说过的epoll的替代方法



最近,关于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()系统调用而停止整个过程是不可接受的。

要解决此问题,应用程序可以使用三种方法:

  1. 在单独的线程上使用线程池和调用阻止功能。 这就是POSIX AIO在glibc中的工作方式(不要将其与Linux AIO混淆)。 有关更多信息,请参阅IBM文档 。 这就是我们解决Cloudflare中问题的方法: 我们使用线程池调用read()open()
  2. 使用posix_fadvise(2)预热磁盘缓存,并希望达到最佳效果。
  3. 将Linux AIO与XFS文件系统结合使用, 使用O_DIRECT标志打开文件避免未记录的问题

但是,这些方法都不是理想的。 即使io_submit()使用Linux AIO,也可以在io_submit()调用中阻止它。 最近在有关LWN的一篇文章中提到了这一点:
“ Linux异步I / O接口有很多批评家,而支持者却很少,但是大多数人期望它至少具有异步性。 实际上,在调用线程负担不起的情况下,由于多种原因,可以在内核中阻止AIO操作。”
现在我们知道了Linux AIO API的弱点,下面让我们看一下它的优点。

使用Linux AIO的简单程序


为了使用Linux AIO,您首先必须自己确定所有五个必需的系统调用 -glibc不提供它们。

  1. 首先,您需要调用io_setup()来初始化aio_context结构。 内核将返回一个不透明的指向结构的指针。
  2. 之后,您可以调用io_submit()以结构iocb结构的形式将“ I / O控制块”的向量添加到处理队列中。
  3. 现在,最后,我们可以调用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。

无论如何,很高兴有新方法来加快网络服务的工作。

Source: https://habr.com/ru/post/zh-CN439972/


All Articles