select-poll-epoll
select-poll-epoll
简介
在没有多路复用之前,一次read只能关注一个fd资源,而客户端如果需要若干个资源就需要发起若干次read调用,而每次read都是阻塞的,且都会创建线程,对性能和资源的消耗是巨大的。
对于select而言,他就允许调用方一次性注册1024个fd资源,然后开启一个线程就能同时监控最多1024个fd资源的准备状态,资源准备就绪后再通知。
但同时需要注意的是,只要有任意fd就绪(可能是一个,可能是部分也可能都就绪)就会返回这些就绪的fd然后让调用方read。那些之前注册的fd但未就绪的,需要重新调用select注册,如果不重新select,即便就绪了也不会通知。(和epoll的区别之一)。
select和poll的机制几乎一致,只是select有fd长度上限,通常为1024,而poll使用动态数组,理论上没有fd长度上限。
整体的大致流程为:
- 客户端调用socket方法创建对应socket
- 客户端将socket注册到select中
- 客户端通过connect方法与服务端建立连接,并调用write/send方法向服务端请求数据
- 服务端收到请求后,调用read方法收集数据,准备响应
- 服务端数据收集好后,调用write/send方法发送数据
- 客户端网卡接受数据后,拷贝到内核空间暂存
- 客户端select检测到fd资源就绪,select返回就绪的fd_set,通知客户端读取数据
- 客户端调用read方法,将资源从内核空间拷贝到用户空间
首先select,poll和epoll都是操作系统中用于I/O多路复用的机制
I/O 多路复用是指利用一个线程或进程通过一个系统调用同时监听多个文件描述符(如 Socket)的 IO 状态变化,从而提高网络编程中高并发场景的处理效率。它通过操作系统提供的系统调用实现,包括 select、poll 和 epoll。
在高并发场景下,相比传统的多线程或多进程模型,IO 多路复用能显著减少系统资源开销,避免线程或进程频繁切换的问题。
select:
- 早期的多路复用机制,使用固定长度的位图来表示需要监控的文件描述符,每次调用
select都需要重新构建和检查文件描述符集 - 支持的文件描述符集长度有限,通常为1024,在大规模连接下效率较低
poll
poll与select类似,但使用动态数组来存储文件描述符,因此没有select的最大连接数限制。- 每次调用时仍需遍历全部描述符,在处理大量连接时效率不高。
epoll:
epoll是Linux系统对select和poll的优化,提供了 边缘触发(ET)和水平触发(LT) 模式。- 不会遍历所有文件描述符,而是通过事件通知的方式,只处理实际发生变化的描述符,适合高并发服务器。
epoll在注册文件描述符后,只需调用一次添加操作,后续的事件管理更高效。
三种 IO 多路复用方式的简要对比:
| 特性 | select | poll | epoll |
|---|---|---|---|
| 触发机制 | 水平触发 | 水平触发 | 水平触发(默认),支持边缘触发 |
| 文件描述符存储方式 | 位图(固定大小) | 动态数组 | 红黑树 + 就绪链表 |
| 最大文件描述符数 | 1024(可通过宏调整) | 无限制,但受系统最大文件描述符数限制 | 无限制,但受系统最大文件描述符数限制 |
| 性能 | 随文件描述符数量增加,性能下降(O(N)) | 随文件描述符数量增加,性能下降(O(N)) | 高效(监听列表为 O(1),调用事件回调 O(k)) |
| 内核与用户态交互 | 每次调用需拷贝整个文件描述符集合(两次拷贝) | 同 select | 仅需第一次注册时拷贝,后续无拷贝开销 |
| 平台支持 | 跨平台,几乎所有系统支持 | 跨平台,几乎所有系统支持 | 仅支持 Linux(2.6 及以上内核) |
select
select函数使用一个固定大小的位图来表示文件描述符集,通过将文件描述符的状态(如可读、可写)存储在一个数组中,调用select时检查这些描述符的状态。
每次调用select时,程序需要重新构建位图,并将所有文件描述符集传递给内核检查状态,判断是否有I/O操作就绪。
局限:
- 文件描述符限制:通常为1024(可以通过修改系统参数调整),限制了并发处理的数量。
- 性能低:在高并发场景中,每次都需要遍历整个文件描述符集进行检查,性能开销大。
- 不适合高并发场景:随着连接数的增加,
select的效率会急剧下降,因为每次调用都需要线性扫描整个文件描述符集。
poll
poll使用一个动态数组来管理文件描述符,能够支持更多的连接数。每个文件描述符有一个对应的结构体(pollfd),包含文件描述符和事件类型。
调用poll时,程序传入的描述符数组会被内核修改,以反映当前文件描述符的状态。
改进:
- 打破文件描述符数量限制:
poll不再依赖于固定大小的位图,可以支持任意数量的文件描述符。 - 接口更灵活:比
select更灵活,适合大部分网络应用场景。
不足:
- 每次调用时仍需遍历所有描述符:即使只有少数描述符发生变化,也需要检查整个数组。
- 性能开销较大:在大规模并发场景下,性能问题依然存在。
epoll
epoll使用一个内核空间的事件列表,应用程序可以通过epoll_ctl向epoll实例注册、修改或删除感兴趣的文件描述符及其事件。
调用epoll_wait时,只会返回发生事件的文件描述符,而不是检查所有描述符。
优势:
1)事件驱动模型:epoll基于事件驱动,不再像select和poll那样需要线性扫描所有描述符。只有当注册的事件发生时,epoll才会通知应用程序。
2)边缘触发与水平触发:
- 水平触发(LT,Level Triggered):是默认模式,类似于
select/poll的工作方式,只要文件描述符上有未处理的数据,每次调用epoll_wait都会返回该文件描述符 - 边缘触发(ET,Edge Triggered):仅在状态发生变化时通知一次,需要用户在事件发生时读取所有数据,否则可能会错过后续事件。减少了重复事件通知的次数,但增加了编程的复杂度,通常需要结合非阻塞 I/O 使用。
3)内存映射:epoll 通过内存映射(mmap)减少了在内核和用户空间之间的数据复制,进一步提高了性能。
select 底层原理分析
select 的核心数据结构:文件描述符集合(fd_set),用来管理需要监视的文件描述符。
fd_set 本质上是一个位图,位图中的每一位对应一个文件描述符的状态。大小为 1024 位(与 FD_SETSIZE 定义相关),每一位表示一个文件描述符。位图中的每一位的值为 1 表示该文件描述符需要监视,为 0 表示不需要监视。
再了解下三种监视类型,分别存储在不同的 fd_set 中:
- 可读事件(readfds):监视文件描述符是否有数据可读。
- 可写事件(writefds):监视文件描述符是否可写(即是否可以发送数据)。
- 异常事件(exceptfds):监视文件描述符上是否有异常情况(如带外数据)。
select的操作流程如下
1)构建 fd_set 并调用 select:
在调用 select 之前,程序需要根据需要监视的文件描述符和事件类型,将文件描述符添加到 readfds、writefds 或 exceptfds 中。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- nfds:表示要监视的文件描述符的最大值加 1。系统会检查文件描述符的范围
[0, nfds)。 - readfds、writefds、exceptfds:分别表示要监视的可读、可写、异常的文件描述符集合。
- timeout:指定
select的超时时间,可以是阻塞(NULL)、立即返回(timeout为 0)、或指定等待的时间。
2)进入内核态进行检查: 调用 select 后,程序进入内核态。内核会扫描 fd_set 中的每一个文件描述符,并检查对应的状态是否符合监视的事件。
内核会遍历所有传入的 fd_set,检查每个文件描述符是否处于可读、可写或异常状态。如果有符合条件的描述符,内核将其标记为就绪。
3)阻塞等待或超时:
如果在遍历所有文件描述符时,没有任何描述符符合就绪条件,则 select 调用会根据 timeout 参数进行阻塞等待。
- 阻塞等待:如果
timeout为NULL,select会无限期地等待,直到有文件描述符变为就绪。 - 超时返回:如果
timeout为 0,select会立即返回,表示非阻塞调用。如果timeout指定了时间,则等待指定的时间后仍然无事件发生时返回。
4)返回就绪的文件描述符:
当 select 发现有文件描述符就绪时,内核会将这些文件描述符的状态写回到 readfds、writefds、exceptfds 中,并返回就绪的文件描述符数量。
程序可以通过遍历更新后的 fd_set,找到哪些文件描述符发生了事件,并执行相应的处理操作。
poll 底层原理分析
poll 的核心数据结构:pollfd 结构体数组。
poll 通过一个数组来管理需要监视的文件描述符,每个数组元素是一个 pollfd 结构体。该结构体描述了需要监听的文件描述符及其关注的事件类型。
struct pollfd {
int fd; // 文件描述符
short events; // 感兴趣的事件
short revents; // 实际发生的事件
};
fd:表示需要监视的文件描述符。events:表示该文件描述符上用户感兴趣的事件,如可读(POLLIN)、可写(POLLOUT)、出错(POLLERR)等。revents:用于在poll返回后表示实际发生的事件,由内核填充。
再了解下事件类型,通过设置 events 字段来指定感兴趣的事件:
POLLIN:文件描述符上有可读数据。POLLOUT:文件描述符上可以写数据。POLLERR:文件描述符上发生错误。POLLHUP:文件描述符上发生挂起。POLLPRI:文件描述符上有紧急数据可读。
poll的操作流程如下
1)构建 pollfd 数组并调用 poll:
需要先构建一个 pollfd 数组,并在数组中指定需要监视的文件描述符和感兴趣的事件。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds:指向pollfd结构体数组的指针。nfds:表示数组中元素的数量(即需要监视的文件描述符的数量)。timeout:指定poll等待的超时时间,以毫秒为单位。
2)进入内核态进行检查: 调用 poll 后,程序会从用户态切换到内核态。内核会遍历 fds 数组中的所有文件描述符,检查它们的状态是否与 events 字段中的感兴趣事件匹配。
内核会逐一检查每个文件描述符,判断其当前状态是否有数据可读、可写,或是否发生了错误等。
3)阻塞等待或超时: 如果在遍历过程中,没有找到任何就绪的文件描述符,则 poll 会根据 timeout 参数进行阻塞等待。
- 阻塞等待:如果
timeout为-1,poll会无限期地等待,直到有文件描述符的状态发生变化。 - 非阻塞调用:如果
timeout为 0,poll会立即返回,即使没有文件描述符发生状态变化。
4)更新 revents 字段并返回:
当文件描述符的状态与指定的 events 匹配时,poll 会将实际发生的事件写入 revents 字段。
poll 返回时,会返回就绪文件描述符的数量,程序可以遍历 fds 数组,检查 revents 字段以确定哪些文件描述符发生了事件。
epoll 底层原理分析
首先了解下两个核心数据结构:
1)红黑树(用于管理注册的文件描述符):
epoll通过一棵红黑树来存储和管理注册到epoll实例中的文件描述符。红黑树的特性使得在插入、删除、查找文件描述符时,操作的时间复杂度为O(log N),其中N是注册的文件描述符数量。- 每次调用
epoll_ctl添加(EPOLL_CTL_ADD)、修改(EPOLL_CTL_MOD)或删除(EPOLL_CTL_DEL)文件描述符时,都会对这棵红黑树进行操作,以维护文件描述符和其感兴趣事件的对应关系。
2)就绪事件链表(用于存储触发的事件):
epoll使用一个双向链表(就绪列表)存储所有发生了事件变化的文件描述符。当某个文件描述符上的事件就绪(如可读、可写)时,epoll会将该文件描述符添加到就绪链表中。- 当调用
epoll_wait时,系统会检查这个链表,并将其中就绪的文件描述符返回给用户。由于只需要遍历就绪的文件描述符,epoll_wait的性能与注册的文件描述符总数无关,而是与就绪的描述符数目相关。
epoll 的操作流程如下
1)创建 epoll 实例(epoll_create):
epoll_create调用会在内核中创建一个epoll实例,分配相应的数据结构,并返回一个epoll文件描述符。此时,内核会分配一棵红黑树用于管理文件描述符,以及一个就绪事件的链表。
2)注册、修改、删除事件(epoll_ctl):epoll_ctl 是 epoll 用于管理文件描述符与事件关系的接口。
它有三种操作模式:
EPOLL_CTL_ADD:将一个文件描述符添加到epoll实例中,并指定关注的事件类型(如EPOLLIN、EPOLLOUT等)。这个操作会将文件描述符和相关事件添加到红黑树中。EPOLL_CTL_MOD:修改已经注册的文件描述符的事件类型,这会更新红黑树中的相应节点。EPOLL_CTL_DEL:将文件描述符从epoll实例中移除,这会从红黑树中删除对应的节点,并清理关联的数据。
3)等待事件(epoll_wait):
- 调用
epoll_wait时,内核会检查就绪事件链表,将链表中所有就绪的文件描述符返回给用户空间。 - 如果就绪链表为空,
epoll_wait会将调用线程挂起,直到有新的事件发生或超时时间到达为止。 epoll_wait的高效性主要得益于它返回的是已经发生事件的文件描述符,而不是遍历所有注册的文件描述符。
补充一下select,poll,epoll之间的一些差别和特点:
你每次调用需要选择你的监听类型,即readfds,writefds,exceptfds,把你想监听的fd分别存放在这些fds中,作为参数传入,这些fd_set有大小限制,通常只能存放1024个fd。
poll在select的基础上由位图更变为动态数组,也就是链表,理论上能监控的fd无具体上限。
select和poll的每次调用都会涉及完整的两次拷贝过程。第一次时内核态拷贝用户态的fds,然后内核态遍历fds,查看哪些fd是就绪的,然后用户态再拷贝内核态更新后的fds,最后用户态遍历fds检查哪些fd是就绪的。无论是否有数据返回这个流程一直都存在
对于select而言:他的fd_set是一个位图,每一位对应一个fd,用户在调用时会把想要监控的 fd 对应的位 置 1。内核返回时,返回的是修改后的fd_set,他会把就绪的fd保留为1,而未就绪的fd置为0,并返回就绪的fd数量
对于poll而言,他的fds的数据结构是
struct pollfd { int fd; // 要监控的文件描述符 short events; // 用户关心的事件,比如 POLLIN、POLLOUT short revents; // 内核返回的事件 }; int poll(struct pollfd *fds, nfds_t nfds, int timeout);revents是由内核填充的,0表示没就绪,非零表示各种事件,如POLLIN,POLLOUT等
而epoll,这是在内部维护一个注册表(红黑树)和一个就绪链表,将就绪的fd放入就绪链表中,当用户调用epoll_wait获取资源时,需要提供events数组,和期望返回的最大events数量(这里需要注意的是,如果maxevents大于就绪链表的长度n,实际events数组的长度也是n,如果maxevents小于就绪链表长度n,则只会返回前maxevents个)
int n = epoll_wait(epfd, events, maxevents, timeout);无论是selelct还是poll还是epoll,他们返回的都是fd或者还会携带当前fd的一个事件状态,用户态依然需要通过获取的这个fd来调用对应的read,write,send,recv方法来获取数据
关于ET、LT两种工作模式
epoll监控多个文件描述符的I/O事件。epoll支持边缘触发(edge trigger,ET)或水平触发(level trigger,LT),通过epoll_wait等待I/O事件,如果当前没有可用的事件则阻塞调用线程。
select和poll只支持LT工作模式,epoll的默认的工作模式是LT模式。
(1)水平触发的时机
- 对于读操作,只要缓冲内容不为空,LT模式返回读就绪。
- 对于写操作,只要缓冲区还不满,LT模式会返回写就绪。
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。
如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。
如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。
(2)边缘触发的时机
-
对于读操作
-
当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。
-
当有新数据到达时,即缓冲区中的待读数据变多的时候。
-
当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时。
-
对于写操作
-
当缓冲区由不可写变为可写时。
-
当有旧数据被发送走,即缓冲区中的内容变少的时候。
-
当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLOUT事件时。
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。
如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。
这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
LT就相当于内核缓冲区有100字节的数据,epoll_wait通知你xxxfd好了,你去read数据时,如果只read了一部分,那另一部分你下次调用epoll_wait时还会告诉你这里还剩多少字节就绪没读。
而ET则正好相反,你如果第一次读了一部分,剩下的部分在你下次调用epoll_wait的时候就不会通知你,所以需要放在循环里面read,避免丢失数据。
优缺点就是:
- LT简单,不容易丢失数据。但代价是每次都要扫描就绪队列,反复通知同一个fd,通知次数多了cpu开销就大,因为如果有10w个fd都未读完,那下一次epoll_wait就要重新通知10w个fd
- 而ET几乎不会重复通知,适合高并发以及多连接场景,但如果业务层处理不好,就容易丢数据
(3)总结
LT模式是只要有数据没有处理就会一直通知下去的。
ET模式仅当状态发生变化的时候才获得通知,这里所谓的状态的变化并不包括缓冲区中还有未处理的数据。
也就是说,如果要采用ET模式,需要一直read/write直到出错为止,很多人反映为什么采用ET模式只接收了一部分数据就再也得不到通知了,大多因为这样。