开发高性能网络程序时,Windows 开发者们言必称 locp,Linux 开发者们则言必称 Epoll。大家都明白 Epoll 是一种 IO 多路复用技术,可以非常高效的处理数以百万计的 Socket 句柄,比起以前的 Select 和 Poll 效率提高了很多。

先简单了解下如何使用 C 库封装的 3个 Epoll 系统调用:

int epoll_create(int size);
int epoll_ctl(int epfd, int op. int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

使用起来很清晰,首先要调用 epoll_create 建立一个 epoll 对象。参数 size 是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。

epoll_ctl 可以操作上面建立的 epoll,例如,将刚建立的 socket 加入到 epoll 中让其监控,或者把 epoll 正在监控的某个 socket 句柄移出 epoll,不再监控它等等。

epoll_wait 在调用时,在给定的 timeout 时间内,当在监控的所有句柄中有事件发生时,就返回用户态的进程。

从调用方式就可以看到 epoll 相比 select/poll 的优越之处是,因为后者每次调用时都要传递你所要监控的所有 socket 给 select/poll 系统调用,这意味着需要将用户态的 socket 列表 copy 到内核态,如果以万计的句柄会导致每次都要 copy 几十或者几百KB的内存到内核态,非常低效。而我们调用 epoll_wait 时就相当于以往调用 select/poll,但在这时却不用传递 socket句柄给内核,因为内核已经在 epoll_ctl 中拿到了要监控的句柄列表。

所以,实际上在你调用 epoll_create 后,内核就已经在内核态开始准备帮你存储要监控的句柄了,每次调用 epoll_ctl 只是在往内核的数据结构里塞入新的 socket 句柄。

在内核里,一切皆文件。所以,epoll 向内核注册了一个文件系统,用于存储上述的被监控 socket。当你调用 epoll_create 时,就会在这个虚拟的 epoll 文件系统里创建一个 file 节点。当然这个 file 不是普通文件,它只服务与 epoll。

epoll 在被内核初始化时(操作系统启动),同时会开辟出 epoll 自己的内核高速 cache 区,用于安置每一个我们想监控的 socket,这些 socket 会以红黑树的形式保存在内核 cache 里,以支持快速的查找、插入、删除。这个内核高速 cache 区,就是建立连续的物理内存页,然后在之上建立 slab 层,通常来讲,就是物理上分配好你想要的 size 内存对象,每次使用时都是使用空闲的已分配好的对象。

static int __init eventpoll_init(void)  {  
... ...  

/* Allocates slab cache used to allocate "struct epitem" items */ epi_cache = kmem_cache_create("eventpoll_epi", sizeof(struct epitem), 0, SLAB_HWCACHE_ALIGN|EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL); /* Allocates slab cache used to allocate "struct eppoll_entry" */ pwq_cache = kmem_cache_create("eventpoll_pwq", sizeof(struct eppoll_entry), 0, EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL); ... ... }

epoll 的高效就在于,当我们调用 epoll_ctl 往里塞入百万个句柄时,epoll_wait 仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。这是由于我们在调用 epoll_create 时,内核除了帮我们在 epoll 文件系统里建了个 file 节点,在内核 cache 里建了个红黑树用于存储以后 epoll_ctl 传来的 socket 外,还会再建立一个 list 链表,用于存储准备就绪的事件,当 epoll_wait 调用时,仅仅观察这个 list 链表里有没有数据即可。有数据就返回,没数据就 sleep,等到 timeout 时间到后即使链表没数据也返回。所以,epoll_wait 非常高效。

而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait 仅需要从内核态 copy 少量的句柄到用户态而已,因此就会非常的高效!

然而,这个准备就绪的 list 链表是怎么维护的呢?
当我们执行 epoll_ctl 时,除了把 socket 放在 epoll 文件系统里 file 对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪的 list 链表里。所以,当一个 socket 上有数据到了,内核再把网卡上的数据 copy 到内核中后,就来把 socket 插入到准备就绪的链表里了。

如此,一个红黑树,一张准备就绪句柄链表,少量的内核 cache,就帮我们解决了大并发下的 socket 处理问题。执行 epoll_create 时,创建了红黑树和就绪链表,执行 epoll_ctl 时,如果增加 socket 句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时,向准备就绪链表中插入数据。执行 epoll_wait 时立刻返回准备就绪链表里的数据即可。

最后看看 epoll 独有的两种模式 LT 和 ET。无论是 LT 和 ET模式,都适用于以上所说的流程。区别是,LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用 epoll_wait 时每次返回这个句柄,而 ET 模式仅在第一次返回。

当一个 socket 句柄上有事件时,内核会把该句柄插入上面所说的准备就绪 list 链表,这时我们调用 epoll_wait,会把准备就绪的 socket 拷贝到用户态内存,然后清空准备就绪 list 链表,最后,epoll_wait 需要做的事情,就是检查这些 socket,如果不是 ET 模式(就是 LT 模式的句柄了),并且这些 socket 上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。所以,非 ET 的句柄,只要它上面还有事件,epoll_wait 每次都会返回。而 ET 模式的句柄,除非有新中断到,即使 socket 上的事件没有处理完,也是不会每次从 epoll_wait 返回的。

因此 epoll 比 select 的提高实质上是一个用空间换时间思想的具体应用,对比阻塞 IO 的处理模型,可以看到采用了多路复用 IO 之后,程序可以自由的进行自己除了 IO 操作之外的工作,只有到 IO 状态发生变化的时候由多路复用 IO 进行通知,然后再采取相应的操作,而不用一直阻塞等待 IO 状态发生变化,提高效率。

最后修改:2021 年 03 月 05 日 03 : 27 PM
如果觉得我的文章对你有用,请随意赞赏