前言:
在linux系统中,实际上所有的 I/O 设备都被抽象为了文件这个概念,一切皆文件,磁盘、网络数据、终端,甚至进程间通信工具管道 pipe 等都被当做文件对待。
在了解多路复用 select、poll、epoll 实现之前,我们先简单回忆复习以下两个概念:
一、什么是多路复用:
- 多路:多个 socket 网络连接。
- 复用:复用一个线程,使用一个线程来检查多个文件描述符(socket)的就绪状态。
- 多路复用主要有三种技术:select、poll、epoll。epoll 是最新的,也是目前最好的多路复用技术。
二、五种 I/O 模型
+ blocking I/O - 阻塞I/O
+ non-blocking I/O - 非阻塞I/O
+ signal-driven I/O - 信号驱动I/O
+ asynchronous I/O - 异步I/O
+ I/O multiplexing - I/O多路复用
1. 阻塞 I/O 模型
进程/线程在从调用 recvfrom()
开始到它返回的整段时间内是被阻塞的,recvfrom()
成功返回后,应用进程/线程开始处理数据报。
recvfrom()
是一个系统调用函数,用于从一个已连接或未连接的套接字(socket)接收数据。函数原型如下:
ssize_t recvfrom(int sockfd, //套接字文件描述符
void *buf, //指向接收数据的缓冲区
size_t len, //缓冲区的大小
int flags, //可选的标志参数,用于控制接收操作的行为
struct sockaddr *src_addr, //用于存储发送方的地址信息
socklen_t *addrlen //src_addr 的长度
);
recvfrom()
在调用时会阻塞,直到有数据到达或发生错误。当有数据到达时,它将数据读取到指定的缓冲区中,并填充发送方的地址信息到 src_addr
参数中。如果套接字是已连接的,src_addr
和 addrlen
参数可以设置为 NULL
。
- 主要特点
- 进程阻塞挂起不消耗CPU资源,能及时响应每个操作。
- 实现难度低。
- 适用并发量小的网络应用开发,不适用并发量大的应用。因为一个请求IO会阻塞进程,所以每请求分配一个处理进程(线程)去响应,系统开销大。
2. 非阻塞 I/O 模型
进程发起 I/O
系统调用后,如果内核缓冲区没有数据,需要到 I/O
设备中读取,进程返回一个错误而不会被阻塞;如果内核缓冲区有数据,内核就会把数据返回进程。
- 主要特点
- 进程轮询(重复)调用,消耗CPU的资源。
- 实现难度低、开发应用相对阻塞IO模式较难。
- 适用并发量较小、且不需要及时响应的网络应用开发。
3. 信号驱动 I/O 模型
当进程发起一个 I/O
操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用 I/O
读取数据。与阻塞式 I/O
或非阻塞式 I/O
模型不同,信号驱动 I/O
模型允许应用程序在进行 I/O
操作时继续执行其他任务,而不需要显式地轮询或阻塞等待 I/O
操作的完成。
-
工作流程
- 应用程序通过调用
sigaction()
来注册一个信号处理函数(Signal Handler)
,用于处理特定的I/O
相关信号,如SIGIO
。 - 将
I/O 文件描述符
设置为信号驱动模式,通常使用fcntl()
并设置F_SETOWN
标志,将文件描述符的拥有者设置为当前进程。这样,当I/O
事件发生时,内核将向该进程发送相应的信号。 - 当
I/O
事件(如数据到达)发生时,操作系统将为相应的文件描述符生成一个信号(通常是SIGIO
),并将其发送给拥有者进程。 - 拥有者进程接收到信号后,会调用事先注册的信号处理函数进行相应的处理。在信号处理函数中,可以执行读取数据、写入数据等操作。
- 信号处理函数执行完毕后,应用程序可以继续执行其他任务,而不需要显式地等待
I/O
操作的完成。
- 应用程序通过调用
-
主要特点
- 实现、开发应用难度大。需要合理处理信号处理函数和信号同步的问题。
- 适用于需要同时处理多个
I/O
事件的情况,如网络编程中的异步处理。
4. 异步 I/O 模型
当进程发起一个 I/O
操作,进程返回(不阻塞),但也不能返回结果;内核把整个 I/O
处理完后,会通知进程结果。如果 I/O
操作成功则进程直接获取到数据。
-
工作原理
- 应用程序调用系统调用函数(如
aio_read、aio_write
等)发起异步I/O
操作。这些函数通常是系统提供的异步I/O
接口函数。 - 在发起异步
I/O
操作时,应用程序还需要提供一个回调函数
,该函数将在I/O
操作完成时被调用。 - 异步
I/O
操作被提交给操作系统或I/O
子系统进行处理。操作系统将负责执行实际的I/O
操作,并在操作完成后触发相应的事件。 - 当
I/O
操作完成时,操作系统将调用之前注册的回调函数,并将操作的结果传递给回调函数。 - 在回调函数中,应用程序可以处理操作的结果,例如读取已接收的数据、处理错误情况等。
- 应用程序可以继续执行其它任务,而无需等待
I/O
操作的完成。
- 应用程序调用系统调用函数(如
-
主要特点
- 不阻塞,数据一步到位。
- 需要操作系统的底层支持,LINUX 2.5 版本内核首现,2.6 版本产品的内核标准特性。
- 实现、开发应用难度大。
- 非常适合高性能高并发应用。
5. I/O 复用模型
大多数文件系统的默认 I/O
操作都是缓存 I/O
。在 Linux
的缓存 I/O
机制中,操作系统会将 I/O
的数据缓存在文件系统的页缓存(page cache)。也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓存区拷贝到应用程序的地址空间中。这种做法的缺点就是,需要在应用程序地址空间和内核进行多次拷贝,这些拷贝动作所带来的CPU以及内存开销是非常大的。
至于为什么不能直接让磁盘控制器把数据送到应用程序的地址空间中呢?最简单的一个原因就是应用程序不能直接操作底层硬件。
总的来说,IO分两阶段:
1)数据准备阶段
2)内核空间复制回用户进程缓冲区阶段。如下图:
-
工作原理
- 应用程序通过将多个
I/O
流(如套接字)注册到I/O
复用机制中,以便对这些流的状态进行监视。 - 当应用程序调用
I/O
复用机制的函数(如select
、poll
或epoll
)时,它会被阻塞,直到至少一个注册的I/O
满足指定的条件(如可读、可写等)。 - 当有流满足指定条件时,
I/O
复用机制并返回,并通知应用程序哪些流满足条件。 - 应用程序可以遍历返回的流集合,检查每个流的状态,并进行相应的处理。通常,应用程序会使用条件判断语句来确定流是可读还是可写,并执行相应的读取或写入操作。
- 应用程序可以继续执行其他任务或再次调用
I/O
复用机制的函数,以便继续监视I/O
流的状态变化。
- 应用程序通过将多个
-
主要特点
- 使用单个系统调用来监视多个
I/O
流的状态,而不是针对每个流进行阻塞式的等待。 - 有效减少系统调用次数和上下文切换开销。
- 提高应用程序的并发性和响应性。
- 使用单个系统调用来监视多个
三、I/O 多路复用之select、poll、epoll详解
目前支持 I/O
多路复用的系统调用有 select,pselect,poll,epoll
。与多进程和多线程技术相比,I/O
多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
I/O
多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但 select,poll,epoll
本质上都是 同步I/O
,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而 异步I/O
则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
select
select函数
监视的文件描述符分3类,分别是 writefds
、readfds
和 exceptfds
。
- 当用户线程调用
select
的时候,select
将需要监控的readfds集合
拷贝到内核空间(假设监控的仅仅是socket可读
)。 - 然后遍历自己监控的
skb
(SocketBuffer),挨个调用skb
的poll
逻辑以便检查该socket
是否有可读事件,遍历完所有的skb
后。 - 如果没有任何一个
socket
可读,那么select
会调用schedule_timeout
进入schedule
循环,使得线程进入睡眠。如果在timeout
时间内某个socket
上有数据可读了,或者等待timeout
了,则调用select
的线程会被唤醒,接下来select
就是遍历监控的集合,挨个收集可读事件并返回给用户了。
相应的伪码如下:
int select(
int nfds, //监控的文件描述符集里最大文件描述符+1
fd_set *readfds, //监控读数据到达文件描述符集合
fd_set *writefds, //监控写数据到达文件描述符集合
fd_set *exceptfds, //监控异常发生到达文件描述符集合
struct timeval *timeout //定时阻塞监控时间,3种情况:1.NULL,永远等下去。
// 2.设置timeval,等待固定时间
// 3.设置timeval里时间均为0,检查描述字后立即返回,轮询
);
//----------------select服务端伪码---------------------
//首先一个线程不断接受客户端连接,并把socket文件描述符放到一个list里
while(1){
connfd = accept(listenfd);
fcntl(connfd, F_SETFL, O_NONBLOCK);
fdlist.add(connfd);
}
/*
select函数还是返回刚刚提交的list,应用程序依然列出所有的fd,只不过操作系统会将准备就绪的文件描述符做上标识,
用户层将不会再有无意义的系统调用开销。
*/
struct timeval timeout;
int max = 0; //用于记录最大的fd,在轮询中时刻更新即可
//初始化比特位
FD_ZERO(&read_fd);
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){
//这里处理accept事件
FD_SET(i, &read_fd); //将客户端socket加入到集合中
}
if(FD_ISSET(i, &read_fd)){
//这里处理read事件
}
}
}
下面是 select
工作原理的动图:
select工作图
通过上面的select逻辑过程分析,相信大家都意识到,select存在三个问题:
- 每次调用
select
,都需要把被监控的fds
集合从用户态空间拷贝到内核态空间,高并发场景下这样的拷贝会使得消耗的资源是很大的。 - 能监听端口的数量有限,单个进程所能打开的最大连接数由
FD_SETSIZE
宏定义,监听上限就等于fds_bits
位数组中所有元素的二进制位总数,其大小是32个整数的大小(在32位的机器上,大小就是32,同理64位机器上为64),当然我们可以对宏FD_SETSIZE
进行修改,然后重新编译内核,但是性能可能会受到影响,一般该数和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max
察看。32位机默认1024个,64位默认2048。 - 被监控的
fds集合
中,只要有一个有数据可读,整个socket集合
就会被遍历一次调用sk
的poll
函数收集可读事件:由于仅关心是否有数据可读这样一个事件,数据的到来是异步的,于是,只能挨个遍历每个socket来收集可读事件了。
poll
poll
的实现和 select
非常相似,只是描述 fd
集合的方式不同。针对 select
遗留的三个问题中(问题(2)是fd限制问题,问题(1)和(3)则是性能问题),poll
使用 pollfd结构
而不是 select
的 fd_set结构
,这就解决了 select
的问题(2)fds集合大小限制问题。
但 poll
和 select
同样存在一个性能缺点就是包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
下面是 poll
的函数原型:
int poll(struct pollfd *ufds, unsigned int nfds, int timeout);
struct pollfd{
int fd; //文件描述符
short events; //监控的事件
short revents; //监控事件中满足条件返回的事件
};
//-----------------poll服务端实现伪码---------------------
struct pollfd fds[POLL_LEN];
unsigned int nfds = 0;
fds[0].fd = server_sockfd;
fds[0].events = POLLIN | POLLPRI;
nfds++;
while(1){
res = poll(fds, nfds, -1);
if(fds[0].revents & (POLLIN | POLLPRI)){
//执行accept并加入fds中,nfds++
if(--res <= 0) continue;
}
//循环之后的fds
if(fds[i].revents & (POLLIN | POLLERR)){
//读操作或处理异常等
if(--res <= 0) continue;
}
}
poll
相比于 select
的优点:使用了 pollfd结构
,使得 poll
支持的 fds
集合限制远大于 select
的1024。
由于 poll
基于链表存储
,无最大连接数限制,所以有如下缺点:(1)大量描述符数组被整体复制于用户态和内核态的地址空间之间,以及个别描述符就绪触发整体描述符集合的遍历的低效问题。(2)poll
随着监控的 socket
集合的增加性能线性下降,使得 poll
也并不适合用于大并发场景。(3)若报告了 fd
后未被处理,下次 poll
时会再次报告该 fd
。
epoll(基于Linux2.4.5)
epoll
模型将主动轮询改为被动通知,当有事件发生时,被动接收通知。所以 epoll
模型注册套接字后,主程序可做其它事情,当事件发生时,接收到通知后再去处理。可理解为event poll,epoll
会把哪个流发生哪种 I/O
事件通知我们。所以 epoll
是事件驱动(每个事件关联 fd
),此时我们对这些流的操作都是有意义的,复杂度也降到
O
(
1
)
O(1)
O(1)。
创建一个 epoll
的句柄,size
表明要监听的 fd
数目。这个参数不同于 select()
中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好 epoll
句柄后,它就是会占用一个 fd
值,在 linux
下如果查看 /proc/进程id/fd/
,是能够看到这个fd的,所以在使用完 epoll
后,必须调用 close()
关闭,否则可能导致 fd
被耗尽。
epoll
的接口非常简单,一共就三个函数:
epoll_create
:创建一个epoll
句柄epoll_ctl
:向epoll
对象中添加/修改/删除要管理的连接epoll_wait
:等待其管理的连接时的I/O
事件
epoll_create 函数
int epoll_create(int size);
- 功能:生成一个
epoll
专用的文件描述符。 - 参数
size
:表明内核监听的文件描述符数目。并不是限制epoll
所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。自从linux 2.6.8
后,size
参数可以填大于0的任意值。 - 返回值:成功则返回
epoll
专用的文件描述符,失败则返回 -1。
epoll_create
的源码实现:
asmlinkage int sys_epoll_create(int maxfds){
int error = -EINVAL, fd;
unsigned long addr;
struct inode *inode;
struct file *file;
struct eventpoll *ep;
//eventpoll接口中不可能存储超过MAX_FDS_IN_EVENTPOLL的fd
if (maxfds > MAX_FDS_IN_EVENTPOLL)
goto eexit_1;
/*
* Creates all the items needed to setup an eventpoll file. That is,
* a file structure, and inode and a free file descriptor.
*/
error = ep_getfd(&fd, &inode, &file);
if (error)
goto eexit_1;
/*
* 调用去初始化eventpoll file. 这和"open" file operation callback一样,因为 inside
* ep_getfd() we did what the kernel usually does before invoking
* corresponding file "open" callback.
*/
error = open_eventpoll(inode, file);
if (error)
goto eexit_2;
/* "private_data" 由open_eventpoll()设置 */
ep = file->private_data;
/* 分配页给event double buffer */
error = ep_do_alloc_pages(ep, EP_FDS_PAGES(maxfds + 1));
if (error)
goto eexit_2;
//创建event double buffer的一个用户空间的映射,以避免当返回events给调用者时,内核到用户空间的内存复制
down_write(¤t->mm->mmap_sem);
addr = do_mmap_pgoff(file, 0, EP_MAP_SIZE(maxfds + 1), PROT_READ,
MAP_PRIVATE, 0);
up_write(¤t->mm->mmap_sem);
error = PTR_ERR((void *) addr);
if (IS_ERR((void *) addr))
goto eexit_2;
return fd;
eexit_2:
sys_close(fd);
eexit_1:
return error;
}
epoll_ctl
每次注册新事件到 epoll
句柄中时(在 epoll_ctl
中指定 EPOLL_CTL_ADD
),会把所有 fd
拷贝进内核,而非在 epoll_wait
时重复拷贝。epoll
保证每个 fd
在整个过程中只会拷贝一次。
//成功则返回0,失败则返回-1
int epoll_ctl(int epfd, //epoll专用的文件描述符,epoll_create的返回值
int op, //表示动作,用三个宏来表示:1.EPOLL_CTL_ADD:注册新的fd到epfd中
// 2.EPOLL_CTL_MOD:修改已注册的fd的监听事件
// 3.EPOLL_CTL_DEL:从epfd中删除一个fd
int fd, //需要监听的文件描述符
struct epoll_event *event //内核要监听的事件类型
);
epoll_wait
//成功则返回要处理的事件数目,超时返回0,失败返回-1
int epoll_wait(int epfd, //epoll专用的文件描述符,epoll_create的返回值
struct epoll_event * events, //内核要监听的事件类型
int maxevents, //事件个数
int timeout); //超时时间,为-1时,函数为阻塞
epoll
不像 select/poll
每次都把当前文件流加入 fd
对应的设备等待队列,而只在 epoll_ctl
时把当前文件挂一遍(这一遍必不可少),并为每个 fd
指定一个回调函数。
当设备就绪,唤醒等待队列上的等待者时,就会调用该回调函数,而回调函数会把就绪 fd
加入一个就绪链表。epoll_wait
实际上就是在该就绪链表中查看有无就绪 fd
。
函数实现伪代码如下:
const int MAX_EVENT_NUMBER = 10000; //最大事件数
// 设置句柄非阻塞
int setnonblocking(int fd)
{
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
int main(){
// 创建套接字
int nRet=0;
int m_listenfd = socket(PF_INET, SOCK_STREAM, 0);
if(m_listenfd<0)
{
printf("fail to socket!");
return -1;
}
//
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);
address.sin_port = htons(6666);
int flag = 1;
// 设置ip可重用
setsockopt(m_listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
// 绑定端口号
int ret = bind(m_listenfd, (struct sockaddr *)&address, sizeof(address));
if(ret<0)
{
printf("fail to bind!,errno :%d",errno);
return ret;
}
// 监听连接fd
ret = listen(m_listenfd, 200);
if(ret<0)
{
printf("fail to listen!,errno :%d",errno);
return ret;
}
// 初始化红黑树和事件链表结构rdlist结构
epoll_event events[MAX_EVENT_NUMBER];
// 创建epoll实例
int m_epollfd = epoll_create(5);
if(m_epollfd==-1)
{
printf("fail to epoll create!");
return m_epollfd;
}
// 创建节点结构体将监听连接句柄
epoll_event event;
event.data.fd = m_listenfd;
//设置该句柄为边缘触发(数据没处理完后续不会再触发事件,水平触发是不管数据有没有触发都返回事件),
event.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
// 添加监听连接句柄作为初始节点进入红黑树结构中,该节点后续处理连接的句柄
epoll_ctl(m_epollfd, EPOLL_CTL_ADD, m_listenfd, &event);
//进入服务器循环
while(1)
{
int number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
if (number < 0 && errno != EINTR)
{
printf( "epoll failure");
break;
}
for (int i = 0; i < number; i++)
{
int sockfd = events[i].data.fd;
// 属于处理新到的客户连接
if (sockfd == m_listenfd)
{
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(m_listenfd, (struct sockaddr *)&client_address, &client_addrlength);
if (connfd < 0)
{
printf("errno is:%d accept error", errno);
return false;
}
epoll_event event;
event.data.fd = connfd;
//设置该句柄为边缘触发(数据没处理完后续不会再触发事件,水平触发是不管数据有没有触发都返回事件),
event.events = EPOLLIN | EPOLLRDHUP;
// 添加监听连接句柄作为初始节点进入红黑树结构中,该节点后续处理连接的句柄
epoll_ctl(m_epollfd, EPOLL_CTL_ADD, connfd, &event);
setnonblocking(connfd);
}
else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR))
{
//服务器端关闭连接,
epoll_ctl(m_epollfd, EPOLL_CTL_DEL, sockfd, 0);
close(sockfd);
}
//处理客户连接上接收到的数据
else if (events[i].events & EPOLLIN)
{
char buf[1024]={0};
read(sockfd,buf,1024);
printf("from client :%s");
// 将事件设置为写事件返回数据给客户端
events[i].data.fd = sockfd;
events[i].events = EPOLLOUT | EPOLLET | EPOLLONESHOT | EPOLLRDHUP;
epoll_ctl(m_epollfd, EPOLL_CTL_MOD, sockfd, &events[i]);
}
else if (events[i].events & EPOLLOUT)
{
std::string response = "server response \n";
write(sockfd,response.c_str(),response.length());
// 将事件设置为读事件,继续监听客户端
events[i].data.fd = sockfd;
events[i].events = EPOLLIN | EPOLLRDHUP;
epoll_ctl(m_epollfd, EPOLL_CTL_MOD, sockfd, &events[i]);
}
//else if 可以加管道,unix套接字等等数据
}
}
}
触发模式
EPOLL LT
和 EPOLL EF
两种:
LT
,水平触发(默认),只要该fd
还有数据可读,每次epoll_wait
都会返回它的事件,提醒用户程序去处理。ET
,边缘触发(高速),无论fd
中是否还有数据都只提示一次,直到下次有数据流入前都不会提示。所以ET
模式下,read
一个fd
时,一定要把它的buffer
读完,即读到read
返回值小于请求值或遇到EAGAIN
错误(稍后重试)。
epoll
使用 事件
就绪通知方式,通过 epoll_ctl
注册 fd
,一旦该 fd
就绪,内核就会采用类似回调机制激活该 fd
,epoll_wait
便可收到通知。
ET 的意义
若用 LT
,系统中一旦有大量无需读写的就绪文件描述符,它们每次调用 epoll_wait
都会返回,这大大降低处理程序检索自己关心的就绪文件描述符的效率。
而采用 ET
,当被监控的文件描述符上有可读写事件发生时,epoll_wait
会通知处理程序去读写。若这次没有把数据全部读写完(如读写缓冲区太小),则下次调用 epoll_wait
时,它不会通知你,即只会通知你一次,直到该文件描述符上出现第二次可读写事件才通知你。这比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
优点
- 无最大并发连接的限制,能打开的
fd
上限远大于1024(1G内存能监听约10万个端口)。 - 效率提升,不是轮询,不会随
fd
数目增加而效率下降。只有活跃可用的fd
才会调用callback
函数,即epoll
最大优点在于它只关心“活跃”连接,而跟连接总数无关。 - 内存拷贝,利用
mmap()
文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。 epoll
通过内核和用户空间共享一块内存而实现。
缺点
- 在连接数少且都十分活跃情况下,
select
和poll
性能都可能比epoll
好,因为epoll
通知机制需要很多函数回调。 epoll
是Linux
所特有的,而select
是POSIX
所规定,一般os
均有实现。
四、总结
select,poll,epoll
都是 I/O
多路复用机制,即能监视多个 fd
,一旦某 fd
就绪(读或写就绪),能够通知程序进行相应读写操作。 但 select,poll,epoll
本质都是同步I/O,因为他们都需在读写事件就绪后,自己负责进行读写,即该读写过程是阻塞的,而异步 I/O
则无需自己负责进行读写,异步 I/O
实现会负责把数据从内核拷贝到用户空间。
select,poll
需自己主动不断轮询所有 fd
集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而 epoll
其实也需调用 epoll_wait
不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但它是设备就绪时,调用回调函数,把就绪 fd
放入就绪链表,并唤醒在 epoll_wait
中进入睡眠的进程。虽然都要睡眠和交替,但 select
和 poll
在“醒着”时要遍历整个 fd
集合,而 epoll
在“醒着”的时候只需判断就绪链表是否为空,节省大量CPU时间,这就是回调机制带来的性能提升。
select,poll
每次调用都要把 fd
集合从用户态往内核态拷贝一次,且要把当前文件往设备等待队列中挂一次,而 epoll
只要一次拷贝,且把当前文件往等待队列上挂也只挂一次(在 epoll_wait
开始,注意这里的等待队列并不是设备等待队列,只是一个 epoll
内部定义的等待队列),这也能节省不少开销。
参考:
- 一文搞懂select、poll和epoll区别
- IO多路复用——深入浅出理解select、poll、epoll的实现