简单说一句话,你需要分层看这个事:
-
epoll 这个系统调用,是同步的,也就是必须等待操作系统返回值。
-
而底层用了 epoll 的封装后的框架,可以是异步的,只要你暴露给外部的接口,无需等待你的返回值即可。
-
再多说些,epoll 这个系统调用的底层内核设计里,每个 IO 事件的通知等待,是异步的。但这不影响,epoll 这个系统调用对外部来说,是一个同步的接口。
所以你说,有的地方说同步,有的地方说异步,其实是不同分层的视角看。
而之所以产生这个问题,一方面是计算机分层思维没有养成,另一方面是对 IO 模型的了解还不够透彻,我这里从头梳理下,希望可以帮到你。
我们先介绍下 UNIX/Linux 中几个基本概念
-
用户空间与内核空间
-
进程切换
-
进程的阻塞
-
文件描述符
-
缓存 I/O
用户空间与内核空间
现在操作系统都是采用虚拟存储器,那么对于 32 位系统而言,它的寻址空间 (虚拟存储空间) 为 4G (2 的 32 次幂)。操作系统核心就是内核。可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
针对 Unix/Linux 操作系统而言,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间,而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间。
进程切换
为了控制进程的执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的,并且进程切换是非常耗费资源的。
进程运行切换,进行下面几个变化:
-
保存处理机上下文,包括程序计数器和其他寄存器;
-
更新 PCB 信息;
-
把进程的 PCB 移入相应的队列,如就绪、在某事件阻塞等队列;
-
选择另一个进程执行,并更新其 PCB;
-
更新内存管理的数据结构;
-
恢复处理机上下文
进程的阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新任务做等,则由系统自动执行阻塞 (Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得了 CPU 资源),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用 CPU 资源的。
文件描述符
文件描述符是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于 Unix、Linux 这样的操作系统。
缓存 I/O
缓存 I/O 又称为标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存中,即数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
IO 多路复用
概念
IO 多路复用是一种 IO 同步模型,实现一个线程可以监控多个文件句柄;如果文件句柄就绪,就能通知应用程序进行相应读写操作;没有文件句柄就绪,就会阻塞应用程序,让出 CPU。
多路是指的是网络连接,复用是指的是同一线程。
阻塞 IO
服务端为了处理客户端的连接和请求的数据,写了如下代码。
listenfd = socket(); // 打开一个网络通信端口
bind(listenfd); // 绑定
listen(listenfd); // 监听
while(1) {
connfd = accept(listenfd); // 阻塞建立连接
int n = read(connfd, buf); // 阻塞读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
这段代码会执行得磕磕绊绊,就像这样。
可以看到,服务端的线程阻塞在了两个地方,一个是 accept 函数,一个是 read 函数。
如果再把 read 函数的细节展开,我们会发现其阻塞在了两个阶段。
这就是传统的阻塞 IO。
整体流程如下图。
所以,如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接。
这肯定是不行的。
需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享
非阻塞 IO
为了解决上面的问题,其关键在于改造这个 read 函数。
有一种聪明的办法是,每次都创建一个新的进程或线程,去调用 read 函数,并做业务处理。
while(1) {
connfd = accept(listenfd); // 阻塞建立连接
pthread_create(doWork); // 创建一个新的线程
}
void doWork() {
int n = read(connfd, buf); // 阻塞读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
这样,当给一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的 read 请求上。
不过,这不叫非阻塞 IO,只不过用了多线程的手段使得主线程没有卡在 read 函数上不往下走罢了。操作系统为我们提供的 read 函数仍然是阻塞的。
所以真正的非阻塞 IO,不能是通过我们用户层的小把戏,而是要恳请操作系统为我们提供一个非阻塞的 read 函数。
这个 read 函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。
操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可。
fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);
这样,就需要用户线程循环调用 read,直到返回值不为 -1,再开始处理业务。
这里我们注意到一个细节。
非阻塞的 read,指的是在数据到达前,即数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的。
当数据已到达内核缓冲区,此时调用 read 函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回。
整体流程如下图
IO 多路复用
为每个客户端创建一个线程,服务器端的线程资源很容易被耗光。
当然还有个聪明的办法,我们可以每 accept 一个客户端连接后,将这个文件描述符(connfd)放到一个数组里。
fdlist.add(connfd);
然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read 方法。
while(1) {
for(fd <-- fdlist) {
if(read(fd) != -1) {
doSomeThing();
}
}
}
这样,我们就成功用一个线程处理了多个客户端连接。
你是不是觉得这有些多路复用的意思?
但这和我们用多线程去将阻塞 IO 改造成看起来是非阻塞 IO 一样,这种遍历方式也只是我们用户自己想出的小把戏,每次遍历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用。
在 while 循环里做系统调用,就好比你做分布式项目时在 while 里做 rpc 请求一样,是不划算的。
所以,还是得恳请操作系统老大,提供给我们一个有这样效果的函数,我们将一批文件描述符通过一次系统调用传给内核,由内核层去遍历,才能真正解决这个问题。
IO 多路复用的实现
-
select
-
poll
-
epoll
select
这种实现只仅仅知道有 I/O 事件发生,但是不清楚哪个那几个流(可能有一个,多个,甚至全部), 因此我们只能无差别进行轮询所有流,获取读取数据的流以及写数据的流,来对其相应操作。因此 select 具有 o (n) 的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
select 调用过程
-
copy_from_user 从用户空间拷贝 fd_set 到内核空间
-
注册回调函数__pollwait
-
遍历所有 fd,调用其对应的 poll 方法(对于 socket,这个 poll 方法是 sock_poll,sock_poll 根据情况会调用到 tcp_poll,udp_poll 或者 datagram_poll)
-
以 tcp_poll 为例,其核心实现就是__pollwait,也就是上面注册的回调函数。
-
__pollwait 的主要工作就是把 current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于 tcp_poll 来说,其等待队列是 sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时 current 便被唤醒了。
-
poll 方法返回时会返回一个描述读写操作是否就绪的 mask 掩码,根据这个 mask 掩码给 fd_set 赋值。
-
如果遍历完所有的 fd,还没有返回一个可读写的 mask 掩码,则会调用 schedule_timeout 是调用 select 的进程(也就是 current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout 指定),还是没人唤醒,则调用 select 的进程会重新被唤醒获得 CPU,进而重新遍历 fd,判断有没有就绪的 fd。
-
把 fd_set 从内核空间拷贝到用户空间。
select 函数接口
#include <sys/select.h>
#include <sys/time.h>
#define FD_SETSIZE 1024
#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)
// 数据结构 (bitmap)
typedef struct {
unsigned long fds_bits[__FDSET_LONGS];
} fd_set;
// API
int select(
int max_fd,
fd_set *readset,
fd_set *writeset,
fd_set *exceptset,
struct timeval *timeout
) // 返回值就绪描述符的数目
FD_ZERO(int fd, fd_set* fds) // 清空集合
FD_SET(int fd, fd_set* fds) // 将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds) // 判断指定描述符是否在集合中
FD_CLR(int fd, fd_set* fds) // 将给定的描述符从文件中删除
select 使用示例
int main() {
/*
* 这里进行一些初始化的设置,
* 包括socket建立,地址的设置等,
*/
fd_set read_fs, write_fs;
struct timeval timeout;
int max = 0; // 用于记录最大的fd,在轮询中时刻更新即可
// 初始化比特位
FD_ZERO(&read_fs);
FD_ZERO(&write_fs);
int nfds = 0; // 记录就绪的事件,可以减少遍历的次数
while (1) {
// 阻塞获取
// 每次需要把fd从用户态拷贝到内核态
nfds = select(max + 1, &read_fd, &write_fd, NULL, &timeout);
// 每次需要遍历所有fd,判断有无读写事件发生
for (int i = 0; i <= max && nfds; ++i) {
if (i == listenfd) {
--nfds;
// 这里处理accept事件
FD_SET(i, &read_fd);//将客户端socket加入到集合中
}
if (FD_ISSET(i, &read_fd)) {
--nfds;
// 这里处理read事件
}
if (FD_ISSET(i, &write_fd)) {
--nfds;
// 这里处理write事件
}
}
}
select 局限性
select 本质上是通过设置或者检查存放 fd 标志位的数据结构来进行下一步处理,因此存在如下局限性:
-
单个进程所打开的 FD (文件句柄) 是有限制的,通过 FD_SETSIZE 设置,默认 1024 ;
-
每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个资源开销在 fd 很多时会很大; (即需要维护一个用来存放大量 fd 的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大)
-
对 socket 扫描时是线性扫描,采用轮询的方法,效率较低(高并发);
当套接字比较多的时候,每次 select () 都要通过遍历 FD_SETSIZE 个 Socket 来完成调度,不管哪个 Socket 是活跃的,都遍历一遍。这会浪费很多 CPU 时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是 epoll 与 kqueue 做的。
poll
poll 本质上和 select 没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个 fd 对应的设备状态,但最本质区别就是它没有最大连接数的限制,因为它基于链表存储的。
poll 函数接口
#include <poll.h>
// 数据结构
struct pollfd {
int fd; // 需要监视的文件描述符
short events; // 需要内核监视的事件
short revents; // 实际发生的事件
};
// API
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
poll 使用示例
// 先宏定义长度
#define MAX_POLLFD_LEN 4096
int main() {
/*
* 在这里进行一些初始化的操作,
* 比如初始化数据和socket等。
*/
int nfds = 0;
pollfd fds[MAX_POLLFD_LEN];
memset(fds, 0, sizeof(fds));
fds[0].fd = listenfd;
fds[0].events = POLLRDNORM;
int max = 0; // 队列的实际长度,是一个随时更新的,也可以自定义其他的
int timeout = 0;
int current_size = max;
while (1) {
// 阻塞获取
// 每次需要把fd从用户态拷贝到内核态
nfds = poll(fds, max+1, timeout);
if (fds[0].revents & POLLRDNORM) {
// 这里处理accept事件
connfd = accept(listenfd);
//将新的描述符添加到读描述符集合中
}
// 每次需要遍历所有fd,判断有无读写事件发生
for (int i = 1; i < max; ++i) {
if (fds[i].revents & POLLRDNORM) {
sockfd = fds[i].fd
if ((n = read(sockfd, buf, MAXLINE)) <= 0) {
// 这里处理read事件
if (n == 0) {
close(sockfd);
fds[i].fd = -1;
}
} else {
// 这里处理write事件
}
if (--nfds <= 0) {
break;
}
}
}
}
poll 局限性
它没有最大连接数的限制,原因是它是基于链表来存储的,同样存在局限性:
-
每次调用 poll ,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
-
对 socket 扫描是线性扫描,采用轮询的方法,效率较低(高并发时)
epoll
epoll 即 event poll,相比之前 select 和 poll,epoll 会把哪个流发生了怎样的 I/O 事件通知用户。所以 epoll 实际上是 ** 事件驱动(每个事件关联上 fd)** 的,此时用户对这些流的操作都是有意义的。(复杂度降低到了 O (1))。
epoll 函数接口
当某一进程调用 epoll_create 方法时,Unix/Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个成员与 epoll 的使用方式密切相关。具体结构如下:
#include <sys/epoll.h>
// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
};
// API
int epoll_create(int size); // 内核中间加一个 ep 对象,把所有需要监听的 socket 都放到 ep 对象中
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 负责把 socket 增加、删除到内核红黑树
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 负责检测可读队列,没有可读 socket 则阻塞进程
每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl 方法向 epoll 对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来 (红黑树的插入时间效率是 lgn,其中 n 为红黑树元素个数)。
而所有添加到 epoll 中的事件都会与设备 (网卡) 驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫 ep_poll_callback, 它会将发生的事件添加到 rdlist 双链表中
在 epoll 中,对于每一个事件,都会建立一个 epitem 结构体,如下:
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdlist 双链表中是否有 epitem 元素即可。如果 rdlist 不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。如下图
从上图可以归纳出:通过红黑树和双链表数据结构,并结合回调机制,成就了 epoll 高效。从上述原理中,我们可以掌握 epoll 了用法,三部曲:
-
第一步:epoll_create () 系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。
-
第二步:epoll_ctl () 系统调用。通过此调用向 epoll 对象中添加、删除、修改感兴趣的事件,返回 0 标识成功,返回 - 1 表示失败。
-
第三部:epoll_wait () 系统调用。通过此调用收集收集在 epoll 监控中已经发生的事件。
epoll 使用示例
int main(int argc, char* argv[]){
/*
* 在这里进行一些初始化的操作,
* 比如初始化数据和socket等。
*/
// 内核中创建ep对象
epfd=epoll_create(256);
// 需要监听的socket放到ep中
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
while(1) {
// 阻塞获取
nfds = epoll_wait(epfd,events,20,0);
for(i=0;i<nfds;++i) {
if(events[i].data.fd==listenfd) {
// 这里处理accept事件
connfd = accept(listenfd);
// 接收新连接写到内核对象中
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
} else if (events[i].events&EPOLLIN) {
// 这里处理read事件
read(sockfd, BUF, MAXLINE);
//读完后准备写
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
} else if(events[i].events&EPOLLOUT) {
// 这里处理write事件
write(sockfd, BUF, n);
//写完后准备读
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
}
}
}
return 0;
}
epoll 优点
-
没有最大并发连接的限制,能打开的 FD 的上限远大于 1024(1G 的内存上能监听约 10 万个端口);
-
效率提升,不是轮询的方式,不会随着 FD 数目的增加效率下降。只有活跃可用的 FD 才会调用 callback 函数;即 Epoll 最大的优点就在于它只管你 “活跃” 的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll 的效率就会远远高于 select 和 poll;
-
内存拷贝,利用 mmap () 文件映射内存加速与内核空间的消息传递;即 epoll 使用 mmap 减少复制开销。
epoll 局限性
只能工作在 Linux 平台上。
epoll 的 LT 与 ET 模式区别
epoll 有 EPOLLLT 和 EPOLLET 两种触发模式,LT 是默认的模式,ET 是 “高速” 模式。
-
LT 模式下,只要这个 fd 还有数据可读,每次 epoll_wait 都会返回它的事件,提醒用户程序去操作;
-
ET 模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论 fd 中是否还有数据可读。所以在 ET 模式下,read 一个 fd 的时候一定要把它的 buffer 读完,或者遇到 EAGIN 错误。
epoll 使用 “事件” 的就绪通知方式,通过 epoll_ctl 注册 fd,一旦该 fd 就绪,内核就会采用类似 callback 的回调机制来激活该 fd,epoll_wait 便可以收到通知。
区别与联系
select,poll,epoll 都是 IO 多路复用的机制。I/O 多路复用就通过一种机制,可以监视多个文件描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但 select,poll,epoll 本质上都是同步 I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步 I/O 则无需自己负责进行读写,异步 I/O 的实现会负责把数据从内核拷贝到用户空间。
epoll 跟 select 都能提供多路 I/O 复用的解决方案。在现在的 Linux 内核里有都能够支持,其中 epoll 是 Linux 所特有,而 select 则应该是 POSIX 所规定,一般操作系统均有实现。
类目 | select | poll | epoll |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
数据结构 | bitmap | 数组 | 红黑树 |
最大连接数 | 1024(x86);2048(x64) | 无上限 | 无上限 |
最大支持文件描述符数 | 一般有最大限制 | 65535 | 65535 |
fd 拷贝 | 每次调用 select,都需要将 fd 集合从用户态拷贝内核态 | 每次调用 poll,都需要将 fd 集合从用户态拷贝内核态 | 首次调用需要 epoll_ctl 拷贝,每次调用 epoll_wait |
工作模式 | LT | LT | 支持 ET 高效模式 |
工作效率 | 每次调用都进行线性遍历,时间复杂度为 O (n) | 每次调用都进行线性遍历,时间复杂度为 O (n) | 事件通知方式,每当 fd 就绪,系统注册的回调函数就会被调用,将就绪 fd 放入 readList 存放,时间复杂为 O (1) |
epoll 是 Linux 目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超 select 和 poll。目前流行的高性能 web 服务器 Nginx 正式依赖于 epoll 提供的高效网络套接字轮询服务。但是,在并发连接不高的情况下,多线程 + 阻塞 I/O 方式可能性能更好。
单个进程支持打开最大连接数
-
select:单个进程所能打开的最大连接数有 FD_SETSIZE 宏定义,其大小是 32 个整数的大小(在 32 位的机器上,大小就是 32_32,同理 64 位机器上 FD_SETSIZE 为 32_64),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。
-
poll:poll 本质上和 select 没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的。
-
epoll:虽然连接数有上限,但是很大,1G 内存的机器上可以打开 10 万左右的连接,2G 内存的机器可以打开 20 万左右的连接。
FD 剧增后带来的 IO 效率问题
-
select:因为每次调用时都会对连接进行线性遍历,所以随着 FD 的增加会造成遍历速度慢的 “线性下降性能问题”。
-
poll:同上
-
epoll:因为 epoll 内核中实现是根据每个 fd 上的 callback 函数来实现的,只有活跃的 socket 才会主动调用 callback,所以在活跃 socket 较少的情况下,使用 epoll 没有前面两者的线性下降的性能问题,但是所有 socket 都很活跃的情况下,可能会有性能问题。
消息传递方式
-
select:内核需要将消息传递到用户空间,都需要内核拷贝动作
-
poll:同上
-
epoll:epoll 通过内核和用户空间共享一块内存来实现的。
总结
select,poll 实现需要自己不断轮询所有 fd 集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而 epoll 其实也需要调用 epoll_wait 不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪 fd 放入就绪链表中,并唤醒在 epoll_wait 中进入睡眠的进程。虽然都要睡眠和交替,但是 select 和 poll 在 “醒着” 的时候要遍历整个 fd 集合,而 epoll 在 “醒着” 的时候只要判断一下就绪链表是否为空就行了,这节省了大量的 CPU 时间。这就是回调机制带来的性能提升。
select,poll 每次调用都要把 fd 集合从用户态往内核态拷贝一次,并且要把 current 往设备等待队列中挂一次,而 epoll 只要一次拷贝,而且把 current 往等待队列上挂也只挂一次(在 epoll_wait 的开始,注意这里的等待队列并不是设备等待队列,只是一个 epoll 内部定义的等待队列)。这也能节省不少的开销。