LWN:epoll也要用ring buffer来跟用户程序交互了

A ring buffer for epoll

By Jonathan Corbet

May 30, 2019
epoll这一组system call系统调用设计目的就是希望能大大增强对大量I/O events的polling能力。它能减少针对每次system call的准备工作,直接返回多个events,这样系统调用的使用次数也会减少。不过有些用户仍然不满意,觉得不能支撑自己更大规模场景下的使用。相应的,Roman Penyaev提出了一组patch,给kernel增加了另一个ring-buffer接口。
poll()和select()这两个系统调用会等待,直到一组file descriptor中的某一个可以开始进行I/O操作。每次调用的时候,都需要kernel准备一个内部数据结构,这样当某个file descriptor状态改变的时候能得到通知。epoll就是对这里的一个改进,它把准备和等待这两个阶段分离开,内部的数据结构就可以一直保留着。
应用程序先用epoll_create1()来创建file descriptor(后续简称fd),后续步骤需要用到。这个API基本上取代了epoll_create(),把那个用不到的参数换成了一个flag参数。接下来调用epoll_ctl()来把各个需要监控的fd加到epoll的监控集合里面。最后调用epoll_wait(),会一直阻塞住,直到至少有一个fd有状态变化才返回。这个流程比poll()多了几个步骤,但当应用程序在监控大量的fd的时候,就能有很大的性能改善。
其实,还能够有性能改善的空间。尽管epoll比此前的方案都更加高效,不过application还是需要通过调用system call才能让下一组fd准备好进行I/O。在繁忙系统里,如果能够不用调用系统调用就能拿到最新的event,肯定会提高效率。Penyaev的patch set就是这样实现的。他创建了一个ring buffer,让application和kernel公用,可以用来传递events。

epoll_create() — the third time is the charm
想要利用这个机制的application,第一步需要告诉kernel后续会开始用polling机制了,以及需要多大的ring buffer。epoll_create1()没有这些参数,因此他创建了一个新的system call,epoll_create2():
int epoll_create2(int flags, size_t size);

也增加了一个新的flag,EPOLL_USERPOLL,目的是告诉kernel要用ring buffer来传递events。size参数就是指明ring buffer需要容纳多少项。size会被增长到按2的幂次,然后用于设置这个epoll命令能监控的 fd
最大数量。目前的patch set里面限制了不能超过65536个项目。
接下来仍旧使用epoll_ctl()来把所关心的多个fd加入polling集合。这里还是会有一些限制,因为某些操作跟user-space polling不兼容,例如每个fd都需要用EPOLLET flag来指定成边沿触发的条件。当某个fd报告说已经准备就绪的时候,只有一个event会被放入ring buffer,这里肯定不能用电平触发的方式来持续不断的放event进入ring buffer。EPOLLWAKEUP flag(用于防止系统在某些event被处理的过程中进入suspend)在这个模式下也无法工作。EPOLLEXCLUSIVE也不支持。
后面还需要两三次mmap()调用,来将ring buffer映射到user space。第一次调用需要指定0作为offset、length是一个page的大小,这样就能获得一个包含如下信息的结构:

struct epoll_uheader {
    u32 magic;          /* epoll user header magic */
    u32 header_length;  /* length of the header + items */
    u32 index_length;   /* length of the index ring, always pow2 */
    u32 max_items_nr;   /* max number of items */
    u32 head;           /* updated by userland */
    u32 tail;           /* updated by kernel */

    struct epoll_uitem items[];
    };

这里的header_length成员变量其实包含了epoll_uheader结构和它的数据数组的长度。可以参照这个例子程序(https://github.com/rouming/test-tools/blob/master/userpolled-epoll.c  ),后续application会把header结构map出来,得到实际的长度,把这个page unmap掉,然后重新用header_length的长度来再次map出来,得到全部数据的数组。
大家可能以为item就是这个ring buffer,其实这里有点绕。还需要再调用mmap()一次才能得到真正的ring buffer,传入的参数里offset是header_length,length是index_length。mmap获取的结果就是一组数据,里面每一项都存放着指向items数组的一个整形索引数字,这才是真正的ring buffer。
最终表示每个event的结构如下:

struct epoll_uitem {
    __poll_t ready_events;
    __poll_t events;
    __u64 data;
    };

其中,events是epoll_ctl()调用时提供的那组events集合。ready_events就是真正已经触发的event集合。这里data成员直接来自epoll_ctl()调用时加入的fd。
如果head和tail两个值不等,说明ring buffer里至少有一个event存在了。application需要拿到这个event,它就直接从head指向的位置读出内容,一直等到读出内容非0为止。这里实际上就是在等kernel把数据写入ring buffer。最后读出的数值就是item数组里面的一个索引值(其实是索引值+1)。把这一项的data复制出来之后,ready_events设置为0,最后head指针就自己加一指向后一项。
简单来说,代码类似下面这样:

while (header->tail == header->head)
        ;  /* Wait for an event to appear */
    while (index[header->head] == 0)
        ;  /* Wait for event to really appear */
    item = header->items + index[header->head] - 1;
    data = item->data;
    item->ready_events = 0;  /* Mark event consumed */
    header->head++;

这只是个示例,实际操作中,这里的代码应该用C语言的原子操作,而不是简单的read和write。这里head增加到位之后需要从0开始。不过总体来说概念应该展示得很清楚了。
这里会在看到ring buffer是空的时候,会忙等在这里,这肯定不是最优的方案。如果application觉得没有什么能做了的,就可以调用epoll_wait()来阻塞住,直到有event出现。这个调用只有在传入events数组为NULL并且maxevents是0的时候才能成功,其他情况下,epoll_wait()会阻塞住,但不会返回任何event给调用者,而很有可能会返回ESTALE,提醒说ring buffer里面有events在等待处理。这组patch set已经是第三版了,目前看来没有更多反对意见了。目前还没能进入linux-next,不过很有可能能够赶上Linux 5.3的合入窗口。

一些感触
理解上述流程,是通过仔细研读代码才得到的。这是一个挺复杂的新API,不过几乎没有任何文档。这里导致了使用困难,不过在这之前,首先导致了API的review也很困难。很怀疑直到目前未知,除了作者,没有其他人真正试用过这组API。到底社区是否真正理解了它的实现,也是个问好。
很可惜的是,可能它会成为kernel的众多ring-buffer接口中的一个最新例子。其他的还包括perf events,ftrace,io_uring,AF_XDP,等等等等。这些接口,每一个都是从头创建出来,需要相对应的领域里的user-space开发者来仔细理解以及实现。这里是不是应该让kernel来定义一组ring buffer管理标准,专门针对user space交互场景的,这样不用每次都创建一个完全不同的一套新的机制?我们不能责备当前的patch set有这个问题,毕竟在作者开始实现的时候还没有其他参考。不过这里确实展示了Linux kernel API设计中的一个缺点,经常缺乏一个一致性很强的整体设计。