目录
[select & poll:海量复制+全部遍历](#select & poll:海量复制+全部遍历)
[1. select](#1. select)
[2. poll](#2. poll)
[3. epoll (Linux 特有)](#3. epoll (Linux 特有))
epoll的背景和优势
select & poll:海量复制+全部遍历
设想一下这个场景:你的服务器同时承载了 100 万个用户连接。但幸运的是,在任意一个时刻,只有几十到几百个连接是真正活跃的,也就是说,只有一小部分用户在发送或接收数据。那么,你的服务器如何才能高效地找出并处理这些活跃的连接,而不被那百万级别的"沉默"连接拖垮呢?

在 Linux 2.4 版本之前,我们主要依赖 select
或 poll
这样的机制 。它们的工作方式是:每次你需要检查哪些连接有事件发生时,都得把这 100 万个连接的清单一股脑地发给操作系统内核。内核收到清单后,再一个一个去检查,看看哪个连接上有数据来了。
这个过程就像你有一百万个学生,每次点名时,不管谁举手了,你都得把所有学生的名字念一遍,然后等着举手的学生回应。这显然效率不高:
-
海量复制: 每次都要把百万连接的信息从程序内存复制到内核内存,消耗巨大。
-
重复遍历: 内核每次都得遍历所有连接,即使大部分都是"沉默"的,这导致了严重的性能瓶颈。
所以,select 和 poll 最多也只能处理几千个并发连接,再多就力不从心了
Epoll:高效事件驱动
epoll
彻底改变了这种低效的模式。 它不再让你的程序每次都"唠唠叨叨"地把所有连接告诉内核。相反,epoll
在 Linux 内核中为我们创建了一个更智能的"事件管理器":
-
创建事件表: 在你的程序启动时,或者当一个新的连接建立时,你首先需要通过epoll_create在内核中创建一个epoll 对象(文件描述符的事件表)
-
一次性注册 :通过 epoll_ctl 将这个连接"注册"到
epoll
对象中。就像你把这 100 万个学生的信息 一次性都交给了教务处,告诉它"请帮我留意这些学生"。 -
内核智能监控: 接下来,内核会持续监控 所有你注册过的连接。一旦某个连接有数据到达,或者可以发送数据了(也就是说,它"活跃"了),内核就会主动 把它添加到一份"就绪列表"中。
-
高效获取活跃连接: 当你的程序调用epoll_wait 来询问"有哪些连接有新事件了?"时,内核不再去遍历那 100 万个连接,而是直接把"就绪列表"中那些真正活跃的、几十到几百个连接信息返回给你。
epoll的三个系统调用
epoll 提供了三个主要的系统调用来管理事件, 我们先来介绍系统调用接口,后面再来介绍其底层原理!
epoll_create()
epoll
需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表,而这个文件描述符就要使用如下函数来创建:
函数作用:创建一个epoll实例,返回一个epoll文件描述符。这个文件描述符代表了内核中的一个事件表,用于存放所有感兴趣的文件描述符及其监听的事件。
函数原型:
cpp
int epoll_create(int size); // size参数在现代Linux内核中已被忽略,但必须大于0
size : 这个参数在 Linux 2.6.8 版本之后已经被废弃,但仍然需要提供一个大于 0 的任意值,内核会动态调整事件表大小。
返回值:
-
成功时返回一个非负的文件描述符(
epfd
),这个文件描述符就是epoll
实例的句柄。后续对epoll
实例的操作都需要使用这个epfd
。 -
失败时返回 -1,并设置
errno
来指示错误原因。
epoll_ctl()
函数原型
函数作用:epoll_ctl() 函数用于对 epoll 实例( 事件表 )进行操作,即添加、修改或删除感兴趣的文件描述符及其事件。
cpp
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd:epoll_create() 返回的epoll文件描述符。
op:操作类型,可以是以下之一:
- EPOLL_CTL_ADD:添加新的文件描述符与事件到epoll实例中。
- EPOLL_CTL_MOD:修改已存在的文件描述符的事件。
- EPOLL_CTL_DEL:从epoll实例中删除文件描述符fd。
fd:要操作的文件描述符。
event :一个指向epoll_event结构体的指针,用于指定要监听的事件类型和用户数据,告诉内核需要监听哪些文件描述符上的哪些事件。
epoll_event结构体类型
cppstruct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ }; typedef union epoll_data { void *ptr; int fd;//最常设置的是fd字段,很少情况会设置别的字段 uint32_t u32; uint64_t u64; } epoll_data_t;
events
: 表示需要监听的事件类型,可以是以下宏的组合:
EPOLLIN
: 文件描述符可读(有数据可供读取)。
EPOLLOUT
: 文件描述符可写(可以写入数据)。
EPOLLRDHUP
: 对端关闭连接或写半部关闭。
EPOLLPRI
: 文件描述符有紧急数据可读。
EPOLLERR
: 文件描述符发生错误。
EPOLLHUP
: 文件描述符被挂断。
EPOLLET
: 边缘触发(Edge-Triggered)模式 。这是epoll
的高性能模式,只在事件状态从不就绪变为就绪时通知一次。要求非阻塞 I/O。
EPOLLONESHOT
: 一次性触发,事件就绪后,内核会自动移除对该文件描述符的监听。如果需要继续监听,需要再次调用epoll_ctl
重新注册。
EPOLLWAKEUP
: 防止系统在事件发生时进入休眠状态。
data
: 联合体,用于存储用户自定义数据。通常将要监听的文件描述符本身或者一个指向自定义数据结构的指针放在这里,方便在事件就绪时快速获取相关信息。
返回值:
-
成功时返回 0。
-
失败时返回 -1,并设置
errno
。
使用示例:
将服务器端的监听套接字添加到事件表中
cpp
前面已经通过select创建了一个监听套接字
// 创建epoll对象------事件表(集合)
int epfd = epoll_create(1);
if (-1 == epfd)
{
perror("epoll_create");
exit(1);
}
// 把监听套接字------sockfd添加到事件表中
struct epoll_event ev; // 表示事件
ev.data.fd = sockfd;
ev.events = EPOLLIN; 内核监听的sockfd的事件类型为可读(监听套接字的可读表示有新连接来了)
// 将监听套接字以及其对应的事件添加到事件表中
int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
if (-1 == ret)
{
perror("epoll_ctl");
exit(2);
}
将服务器端的连接套接字添加到事件表中
cpp
struct epoll_event event;
event.events = EPOLLIN | EPOLLET; // 监听读事件,并设置为边缘触发模式
event.data.fd = client_sockfd; // 存储客户端socket文件描述符
// 添加新的客户端socket到epoll实例
if (epoll_ctl(epfd, EPOLL_CTL_ADD, client_sockfd, &event) == -1) {
perror("epoll_ctl: add");
// 错误处理
}
// 修改已注册的事件
event.events = EPOLLOUT; // 只监听写事件
if (epoll_ctl(epfd, EPOLL_CTL_MOD, client_sockfd, &event) == -1) {
perror("epoll_ctl: mod");
// 错误处理
}
// 删除已注册的事件
if (epoll_ctl(epfd, EPOLL_CTL_DEL, client_sockfd, NULL) == -1) {
perror("epoll_ctl: del");
// 错误处理
}
epoll_wait()
上面两个函数调用完之后,只是我们告诉了操作系统需要关心哪个文件描述符的何种事件,但是我们还需要知道操作系统告诉我们哪些事件就绪了,就得使用 下面这个函数来实现!!!
函数作用:它的作用就是 在一段超时时间内(timeout)等待一组文件描述符上的事件,也就是收集在 epoll 监控的事件表中已经发送的事件(放在enents这个数组中)
函数原型:
cpp
int epoll_wait(
int epfd,
struct epoll_event *events,
int maxevents,
int timeout
);
参数说明:
-
epfd: 就是epoll_create() 返回的 epoll 实例的文件描述符。
-
events: 如果 epoll_wait() 函数检测到事件,就将所有就绪的事件从内核事件表(即 epfd 指定的)中复制到该参数 events 数组中,所以它是一个输出型参数!
要注意的是,events 数组不可以为空,因为内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存(由于内核和用户空间的隔离,内核也不可能实现直接在用户空间为我们分配内存)
这个参数是一个指向 epoll_event 结构体数组的指针。epoll_wait() 会将所有已就绪的事件填充到这个数组中。(注意:我们都会新建一个 struct epoll_event 类型的数组 来作为 epoll_wait() 函数的参数。而不是使用向epoll_ctl中传入的epoll_event 类型的数组(内核事件表),这两个数组的目的是不同的)
-
maxevents: events 数组的最大容量,即一次调用 epoll_wait() 最多可以获取的事件数量。
- 该参数告诉内核用户空间事件数组的大小,即最多可以返回多少个就绪的事件。这是用于底层方便遍历和排序 events 数组的。要注意的是这个 maxevents 的值不能大于创建 epoll_create() 时的 size。
- 如果实际就绪事件的数量超过了 maxevents,那么只会填充 maxevents 个事件到数组中,并返回 maxevents 作为函数的返回值。剩余的事件将保持在内核中等待下次的 epoll_wait() 调用。
-
timeout: 超时时间,单位为毫秒:
- -1: 永远等待,直到有事件发生。
- 0: 立即返回,不阻塞。
- > 0: 等待指定毫秒数,如果期间没有事件发生,则超时返回。
返回值:
-
成功时返回就绪事件的数量。
-
如果超时,返回 0。
-
失败时返回 -1,并设置
errno
。
使用示例
cpp
struct epoll_event events[MAX_EVENTS]; // MAX_EVENTS 是预定义的常量,表示最大事件数量
int nfds; // 就绪的文件描述符数量
while (1) {
nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); // 永远等待事件
if (nfds == -1) {
perror("epoll_wait");
// 错误处理
break;
}
for (int i = 0; i < nfds; ++i) {
if (events[i].data.fd == listen_sockfd) {
// 处理新的连接
// ... accept()
// 将新的客户端socket添加到epoll实例中
} else if (events[i].events & EPOLLIN) {
// 处理可读事件
// ... read()
} else if (events[i].events & EPOLLOUT) {
// 处理可写事件
// ... write()
}
// ... 其他事件处理
}
}
epoll的两种工作模式

水平触发(LT)
水平触发 (Level-Triggered, LT) :这是 epoll 的默认工作模式。当一个文件描述符就绪时,只要条件满足,epoll_wait() 就会一直通知你。例如,如果一个套接字有数据可读,即使你只读取了一部分,下次调用 epoll_wait() 仍然会告诉你该套接字可读,直到所有数据都被读完。LT 模式可以与阻塞或非阻塞 I/O 一起使用。
之前介绍的select和poll都是这种模式
边沿触发(ET)
边缘触发 (Edge-Triggered, ET) :只有当文件描述符的状态发生变化 时,epoll_wait() 才会通知你一次。例如,当套接字从不可读变为可读时,只会通知一次。即使还有数据未读,也不会再次通知,除非有新的数据到达。
ET
模式在事件就绪时候通知用户一次,而往后不再重复通知,只有当该事件的数据变化的时候比如数据增多了,才会再次通知用户,此时如果在代码中没有尽可能的将所有可读数据读取上来的话,会发生数据丢失 :在 ET
模式下,当有新数据到达时,只会触发一次事件通知。如果应用程序没有及时读取并处理所有的可读数据,那么下一次事件通知到来时,之前未读取的数据将会丢失,因为内核只关心是否有新的数据到达,而不会保存之前未读取的数据。
因为有以上的这些问题,所以也就自然的倒逼我们要尽量在代码中将就绪的可读数据都读取上来 ,那么可以采取以循环的方式读取数据,直到返回的读取结果表明没有更多数据可读。
ET 模式必须搭配非阻塞 I/O 来使用,以避免因为没有一次性读取或写入所有数据而导致进程阻塞,进而丢失事件。ET 模式效率更高,因为它减少了不必要的系统调用。
文件描述符的阻塞模式和非阻塞模式是什么?
文件描述符的阻塞模式和非阻塞模式是 I/O 操作的两种基本行为,它们决定了当数据尚未准备好或缓冲区不可用时,系统调用(如 read()、write()、accept() 等)会如何响应。
阻塞模式
在阻塞模式下,当你对一个文件描述符执行 I/O 操作时,如果当前条件不满足操作的立即完成(比如读取时没有数据可读,或者写入时发送缓冲区已满),那么这个系统调用会暂停程序的执行,直到操作能够完成或发生错误。
形象比喻: 就像你打电话给客服,客服告诉你"请稍等,我正在查询您的信息",然后你就一直拿着电话等着,什么也做不了,直到客服给你答复。
特点:
程序暂停: 调用 I/O 操作的线程会被挂起(阻塞),直到操作完成。在此期间,该线程无法执行任何其他任务。
编程简单: 你不需要考虑数据何时到达或何时可以发送,系统会自动处理等待,这简化了编程逻辑。
效率低下(高并发场景): 在需要处理多个并发连接(如网络服务器)的场景下,一个线程阻塞在一个 I/O 操作上会导致该线程无法服务其他连接。这意味着你可能需要为每个并发连接分配一个独立的线程或进程,这会消耗大量的系统资源(内存、CPU 上下文切换)。
非阻塞模式
在非阻塞模式下,当你对一个文件描述符执行 I/O 操作时,如果当前操作无法立即完成,该系统调用会立即返回一个错误码 (通常是
-1
,并设置全局变量errno
为EAGAIN
或EWOULDBLOCK
),而不会使程序暂停。应用程序需要通过循环或 I/O 多路复用机制来不断检查 I/O 状态,判断何时可以进行读写操作。形象比喻: 你打电话给客服,客服告诉你"您的信息正在查询中,请稍后重拨"。你挂断电话,可以去做其他事情,过一段时间再打回去问查询结果。
特点:
立即返回: I/O 调用不会阻塞线程,无论数据是否准备好,都会立即返回。
需要轮询/多路复用: 应用程序需要通过不断检查(轮询)或通过 I/O 多路复用机制(如
select
、poll
、epoll
)来判断 I/O 何时就绪。高效率(高并发场景): 一个线程可以同时管理多个 I/O 操作。当一个 I/O 操作暂时无法完成时,线程可以立即去处理其他 I/O 操作,从而提高了并发处理能力和资源利用率。
编程复杂性高: 应用程序需要自己处理 I/O 状态的轮询和特定的错误码(
EAGAIN
/EWOULDBLOCK
),以及在没有数据时如何避免 CPU 空转(通常通过 I/O 多路复用解决)。
细致理解一下阻塞模式和非阻塞模式的差别:
有在缓冲区中完全没有数据可读(对于 read())或者发送缓冲区完全没有空闲空间可写(对于 write())时,阻塞模式和非阻塞模式的行为才会有根本的区别!!!!!
读操作 (read())
缓冲区有数据(哪怕只有一个字节):
- 阻塞模式: 不会阻塞。read() 会读取当前缓冲区中所有可用的数据(最多到你指定的 count 字节),并立即返回实际读取的字节数。
- 非阻塞模式: 不会阻塞。read() 会读取当前缓冲区中所有可用的数据(最多到你指定的 count 字节),并立即返回实际读取的字节数。
- 结论: 只要有数据可读,两者都不会阻塞。
缓冲区完全没有数据:
- 阻塞模式: 会阻塞。 read() 会暂停程序执行,直到有数据到达可供读取,或者对端关闭连接(返回0),或者发生错误。
- 非阻塞模式: 不会阻塞。 read() 会立即返回 -1,并将 errno 设置为 EAGAIN 或 EWOULDBLOCK。
写操作 (write())
缓冲区有空位(哪怕只有一个字节):
- 阻塞模式: 不会阻塞。write() 会尝试写入数据到缓冲区,并立即返回实际写入的字节数。
- 非阻塞模式: 不会阻塞。write() 会尝试写入数据到缓冲区,并立即返回实际写入的字节数。
- 结论: 只要有空位可写,两者都不会阻塞。
缓冲区完全没有空位(缓冲区已满):
- 阻塞模式: 会阻塞。 write() 会暂停程序执行,直到发送缓冲区有足够的空间来容纳数据,或者发生错误。
- 非阻塞模式: 不会阻塞。 write() 会立即返回 -1,并将 errno 设置为 EAGAIN 或 EWOULDBLOCK。
为什么 ET 模式必须将文件描述符设置为非阻塞模式❓❓❓
如果 ET 模式不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。因为如果使用阻塞式 IO 进行读取操作,在读取完所有可用数据后,如果没有更多的数据到达,读取函数将会阻塞等待数据的到达。由于 ET 模式只在状态发生变化时触发事件通知,所以没有新数据到达时不会再次触发事件通知,导致读取操作阻塞在最后一次读取上。
同样地,如果使用阻塞式 IO 进行写入操作,在发送完所有数据后,如果无法立即发送更多数据(例如发送缓冲区已满),写入函数将会阻塞等待数据的发送。由于 ET 模式只在状态发生变化时触发事件通知,所以无法发送更多数据时不会再次触发事件通知,导致写入操作阻塞在最后一次写入上。
所以要设置为非阻塞模式,可以使用 fcntl() 函数进行设置!而 LT 模式既可以是阻塞式读写也可以是非阻塞式读写。
fcntl() 是 POSIX 标准定义的系统调用,fcntl() 可以对任何已打开的文件描述符 进行操作,包括通过 socket()、accept()、open() 等创建的 FD,通过这个函数,你可以查询或修改文件描述符的各种属性,例如文件状态标志(阻塞/非阻塞)、文件锁、异步 I/O 所有权等
关于fcntl函数的详细用法这里不多赘述,可自行查阅。
epoll的底层原理
某一进程调用 epoll_create
方法时,Linux
内核会创建一个 eventpoll 结构体,这个结构体中有两个成员与 epoll
的使用方式密切相关,如下所示:(这里只列出部分重要字段)
struct eventpoll
{
/* 红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,也就是这个epoll监控的事件 */
struct rb_root rbr;
/* 双向链表,表示已就绪事件的列表,列表中保存着将要通过epoll_wait()返回给用户的、满足条件的事件 */
struct list_head rdllist;
// ......
};

-
红黑树(RBTrees)存储结构: 当你调用
epoll_ctl()
函数向epoll
实例中添加(EPOLL_CTL_ADD
)或删除(EPOLL_CTL_DEL
)一个文件描述符时**,内核会在 epoll 实例内部维护一个数据结构来存储这些被监控的文件描述符及其对应的事件。这个数据结构通常是红黑树**。- 优点: 红黑树是一种自平衡二叉查找树,它允许快速地进行插入、删除和查找操作(时间复杂度为 O(logn))。这意味着即使你添加了成千上万个文件描述符,
epoll_ctl
的操作效率仍然很高。相较于select
/poll
每次都要遍历整个文件描述符集合,红黑树的查找和维护开销要小得多。
- 优点: 红黑树是一种自平衡二叉查找树,它允许快速地进行插入、删除和查找操作(时间复杂度为 O(logn))。这意味着即使你添加了成千上万个文件描述符,
-
双向链表(
rdllist
)存储就绪事件: 除了红黑树,epoll
实例内部还有一个关键的数据结构------一个双向链表(ready list),用于存储已经就绪的文件描述符。-
优点: 当某个文件描述符上的事件发生时(例如,数据到达网卡,内核将数据放入接收缓冲区),内核会触发一个回调函数。这个回调函数会将该文件描述符从红黑树中找到,然后将其添加到这个就绪链表中。
-
"惰性"收集: 当你调用
epoll_wait()
时,epoll
不会去遍历所有注册的文件描述符,而是直接检查这个就绪链表 。如果链表不为空,它就直接将链表中的所有就绪文件描述符拷贝到用户空间(你提供的events
数组),然后清空链表。如果链表为空,epoll_wait
才会阻塞。
-
-
回调机制/异步通知:
epoll
最重要的优化在于它利用了文件系统、设备驱动等底层机制提供的回调函数。-
原理: 当用户通过
epoll_ctl()
注册一个文件描述符时,内核会注册一个回调函数到该文件描述符对应的设备(如网卡驱动、管道、文件系统等)的等待队列中。 -
事件触发: 当该设备的数据就绪(例如,网卡接收到数据),或者设备状态发生变化时,它会唤醒等待队列上的所有进程或执行注册的回调函数。这个回调函数的作用就是将对应的文件描述符添加到
epoll
实例的就绪链表中。 -
优势: 这使得
epoll
能够主动 地知道哪个文件描述符就绪了,而不需要应用程序通过循环遍历来被动 地查询每个文件描述符的状态。这是一种典型的"中断驱动 "或"事件驱动"机制。
-
总结 epoll
相比 select
/poll
的优势:
-
性能提升的关键: 不再需要每次都将整个
fd
集合从用户空间拷贝到内核空间。 -
效率提升的关键: 不再需要每次都在内核空间遍历整个
fd
集合来判断哪些fd
就绪。 -
按需通知: 通过回调机制,只有真正发生事件的文件描述符才会被添加到就绪链表中,
epoll_wait
只需要检查这个链表即可。 -
支持大量并发: 由于红黑树的 O(logn) 复杂度以及就绪链表的 O(1) 查找,
epoll
可以高效地处理成千上万的文件描述符。select
/poll
会随着fd
数量的增加,性能线性下降。
重新理解epoll的函数接口
- 执行 epoll_create() 时,创建了红黑树 和就绪链表 ;
- 执行 epoll_ctl() 时,如果增加 socket 句柄,则检查在红黑树中是否存在,如果存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据;
- 执行 epoll_wait() 时返回准备就绪链表 rdllist 里的数据即可。
我们将上面的回调机制以及前面的红黑树、链表结合起来,统称为一个 epoll 模型!下面让我们来重新理解一下 epoll 接口的调用,以及其底层原理的联系!
- 首先就是调用 epoll_create (),创建一个 epoll 模型(包括空的红黑树、空的链表等等结构),而这整个 epoll 模型,其实就由一个 struct file 结构体数组来管理,返回值就是一个文件描述符 epfd来标识多个文件描述符------事件类型的事件组。
- 然后就是调用 epoll_ctl (),根据上面得到的 epoll 模型的文件描述符 epfd,再根据需要选择增删改操作,将感兴趣的事件以及要监听的文件描述符传入到 epoll_ctl() 中,其底层其实就是红黑树的插入、删除、修改操作,以及一些事件字段的设置罢了!
- 最后就是调用 epoll_wait(),这个函数只关心 rdllist 就绪链表。其底层原理就是在我们传入的 timeout 时间后,去访问 rdllist 就绪链表,看看有没有事件已经就绪了,没有的话则返回继续 timeout 事件的阻塞;如果有的话则将就绪链表中的内容通过返回值、输出型参数以及文件缓冲区拷贝,反馈给用户!
所以我们也能看出来,epoll 的底层其实就不需要去遍历所有的事件判断是否就绪,只需要通过 O(1) 时间复杂度,看看 rdllist 就绪链表中是否存在节点就能知道有没有事件就绪了,这是非常高效的!
并且对于 epoll 的增删查是一个 O(logn) 的时间复杂度,也是非常优秀的!
使用epoll编写服务器端示例
下面是一个使用 epoll
实现的简单 TCP Echo 服务器示例。它会监听一个端口,接受客户端连接,并将客户端发送过来的数据原样返回。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <fcntl.h> // for fcntl
#define BUF_SIZE 1024
#define EPOLL_SIZE 50 // epoll事件表的初始大小,现代内核中实际是动态的
#define MAX_EVENTS 10 // epoll_wait一次最多返回的事件数量
// 设置文件描述符为非阻塞模式
void set_nonblocking_mode(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(int argc, char *argv[]) {
if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}
int serv_sock;
struct sockaddr_in serv_addr;
// 1. 创建服务器套接字
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1) {
perror("socket()");
exit(1);
}
// 设置套接字选项,允许地址重用
int opt = 1;
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(argv[1]));
// 绑定套接字
if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
perror("bind()");
exit(1);
}
// 监听连接
if (listen(serv_sock, 5) == -1) {
perror("listen()");
exit(1);
}
// 设置服务器套接字为非阻塞模式 (推荐在epoll中使用非阻塞I/O)
set_nonblocking_mode(serv_sock);
// --- epoll 工作流程开始 ---
// 1. 创建 epoll 实例
int epfd = epoll_create(EPOLL_SIZE); // size参数现在不起实际作用,但需大于0
if (epfd == -1) {
perror("epoll_create()");
exit(1);
}
struct epoll_event event; // 用于注册事件
struct epoll_event ep_events[MAX_EVENTS]; // 用于接收就绪事件
// 2. 将服务器监听套接字注册到 epoll 实例
// 监听 EPOLLIN (可读) 事件,并设置为边缘触发 (EPOLLET) 模式
event.events = EPOLLIN | EPOLLET;
event.data.fd = serv_sock; // 将监听套接字本身存入data.fd
if (epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event) == -1) {
perror("epoll_ctl() add serv_sock");
exit(1);
}
printf("Server listening on port %s using epoll...\n", argv[1]);
while (1) {
// 3. 等待事件发生
// timeout 为 -1 表示一直阻塞直到有事件发生
int event_count = epoll_wait(epfd, ep_events, MAX_EVENTS, -1);
if (event_count == -1) {
perror("epoll_wait()");
break;
}
// 4. 处理就绪事件
for (int i = 0; i < event_count; i++) {
// 如果是监听套接字事件 (新连接请求)
if (ep_events[i].data.fd == serv_sock) {
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_sz = sizeof(clnt_addr);
int clnt_sock;
// 接受所有等待中的新连接 (边缘触发模式下需要循环accept)
while ((clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_sz)) != -1) {
printf("New client connected: %s:%d (fd: %d)\n",
inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port), clnt_sock);
// 设置客户端套接字为非阻塞模式
set_nonblocking_mode(clnt_sock);
// 将新的客户端套接字注册到 epoll 实例,监听读事件,边缘触发
event.events = EPOLLIN | EPOLLET;
event.data.fd = clnt_sock;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event) == -1) {
perror("epoll_ctl() add clnt_sock");
close(clnt_sock); // 添加失败,关闭套接字
}
}
// 如果 accept 返回 -1 且 errno 不是 EAGAIN/EWOULDBLOCK,说明是真正的错误
if (clnt_sock == -1 && (errno != EAGAIN && errno != EWOULDBLOCK)) {
perror("accept()");
}
}
// 如果是客户端套接字事件 (数据可读或连接关闭)
else {
char buf[BUF_SIZE];
int str_len = 0;
// 检查是否是错误或挂断事件
if (ep_events[i].events & (EPOLLERR | EPOLLHUP)) {
printf("Client (fd: %d) error or hung up.\n", ep_events[i].data.fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL); // 从epoll中删除
close(ep_events[i].data.fd); // 关闭套接字
continue; // 继续处理下一个事件
}
// 检查是否可读
else if (ep_events[i].events & EPOLLIN) {
// 由于是边缘触发,需要循环读取所有数据,直到 read 返回 0 (连接关闭) 或 -1 (无数据可读)
while ((str_len = read(ep_events[i].data.fd, buf, BUF_SIZE)) > 0) {
printf("Received from fd %d: %.*s", ep_events[i].data.fd, str_len, buf);
write(ep_events[i].data.fd, buf, str_len); // 回写数据
}
if (str_len == 0) { // 客户端关闭连接
printf("Client (fd: %d) disconnected.\n", ep_events[i].data.fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL); // 从epoll中删除
close(ep_events[i].data.fd); // 关闭套接字
} else if (str_len == -1) {
if (errno != EAGAIN && errno != EWOULDBLOCK) { // 非EAGAIN/EWOULDBLOCK错误
perror("read()");
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
}
// 如果是 EAGAIN 或 EWOULDBLOCK,表示所有数据已读完,不是错误
}
}
// 可以添加 EPOLLOUT 的处理,如果需要发送大量数据且一次不能发完
// else if (ep_events[i].events & EPOLLOUT) {
// // 处理可写事件
// }
}
}
}
// 清理资源
close(serv_sock);
close(epfd);
return 0;
}
epoll实现TCP服务器流程
-
统一管理文件描述符:
epoll
实例 传统的服务器模型中,如果使用多线程或多进程,每个连接都需要一个独立的线程/进程来处理,资源消耗大。而epoll
的核心思想是创建一个统一的epoll
实例(epfd
) ,让内核来帮助我们"看管"所有感兴趣的文件描述符(包括监听套接字和所有已连接的客户端套接字)。这样,我们只需要一个主线程就可以管理所有 I/O 事件。 -
非阻塞 I/O 与边缘触发 (ET) 模式:提升效率
-
非阻塞 I/O (
O_NONBLOCK
) :这是epoll
高性能的关键之一。它意味着当你进行read()
或write()
操作时,如果数据还没准备好或缓冲区已满,函数会立即返回错误 (-1
) 并设置errno
为EAGAIN
或EWOULDBLOCK
,而不会阻塞程序的执行。这允许程序在等待数据时去做其他事情。 -
边缘触发 (ET) 模式 (
EPOLLET
) :epoll
的默认模式是水平触发 (LT),它会重复通知你一个文件描述符已就绪,直到你完全处理它。而 ET 模式则只在文件描述符的状态从"不就绪"变为"就绪"时通知一次。例如,只有当数据从无到有,或写入缓冲区从满到空时,才会收到通知。-
为什么选择 ET? ET 模式减少了不必要的事件通知,降低了内核与用户空间之间的上下文切换次数,从而提升了效率。
-
ET 的挑战与应对: 由于只通知一次,你需要确保在收到通知后,一次性地、尽可能多地 处理所有可用的 I/O(比如,循环
read()
直到返回0
或EAGAIN
)。代码中的while ((str_len = read(ep_events[i].data.fd, buf, BUF_SIZE)) > 0)
就是为了应对 ET 模式下需要"读到无数据可读为止"的策略。
-
-
-
事件驱动的循环:
epoll_wait
服务器程序进入一个无限循环,核心是调用epoll_wait()
。这个函数会阻塞,直到epoll
实例中注册的任何文件描述符上有事件发生。一旦有事件就绪,epoll_wait()
就会返回就绪事件的数量以及这些事件的详细信息(存储在ep_events
数组中)。 -
区分并处理事件:连接事件 vs. 数据事件
epoll_wait()
返回后,程序遍历ep_events
数组,根据每个事件对应的文件描述符来判断事件类型:-
如果是服务器监听套接字 (
serv_sock
) 上的事件: 这意味着有新的客户端尝试连接。我们循环调用accept()
来接受所有等待中的新连接(因为 ET 模式下可能一次有多个连接请求),并将每个新接受的客户端套接字也设置为非阻塞,然后注册到epoll
实例中,继续监听它们的读事件。 -
如果是已连接客户端套接字上的事件:
-
可读事件 (
EPOLLIN
): 表示客户端发送了数据。程序会循环调用read()
,尽可能多地读取数据,然后立即调用write()
将数据回传给客户端(Echo 功能)。 -
断开连接或错误 (
0
或EPOLLERR
/EPOLLHUP
): 如果read()
返回0
,表示客户端正常关闭连接。如果是EPOLLERR
或EPOLLHUP
,表示连接出现错误或异常断开。无论是哪种情况,都需要将该客户端套接字从epoll
实例中移除并关闭它,释放资源。
-
-
epoll中的惊群问题
惊群问题是什么?
惊群效应只会发生在多线程/多进程中。
惊群效应 (Thundering Herd)是指当多个进程或线程都在等待同一个事件发生时,一旦这个事件发生,所有等待的进程/线程都会被同时唤醒,然后它们会争抢着去处理这个事件。然而,通常只有一个进程/线程能成功处理,而其余的进程/线程则会发现事件已经被处理了,然后它们又会进入休眠状态。
这种现象会导致以下问题:
-
资源浪费: 大量进程/线程同时被唤醒,发生不必要的上下文切换,消耗 CPU 资源。
-
锁竞争: 为了防止多个进程/线程同时处理同一个事件导致数据不一致,通常需要加锁。这会引入锁竞争,进一步降低效率。
-
缓存失效: 大量进程/线程同时被调度,可能导致 CPU 缓存失效,影响性能。
epoll中的accept惊群问题
在一个典型的 TCP 服务器中,listen()
套接字负责监听新的客户端连接请求。当有新连接到来时,accept() 函数用于从内核的连接队列中取出这个新连接,并为其创建一个新的套接字文件描述符。
想象一下以下场景:
- 单线程
accept()
的局限性: 如果只有一个线程负责调用accept()
,当服务器在高并发情况下(例如,每秒有成千上万个新连接),这个单线程可能会成为瓶颈。它可能无法及时地从内核的连接队列中取出所有新连接,导致队列溢出,进而拒绝一些客户端的连接请求。
为了解决单线程accept的局限性,我们可以实现多线程 accept,这样可以及时地从内核的连接队列中取出所有新连接,常见的模型有:
- 多进程/线程共享监听套接字: 这是最直接的方式。多个子进程或线程通过
fork()
或pthread_create()
创建,它们都继承或共享同一个监听套接字 。然后每个子进程/线程都循环调用accept()
。但是这种模型就容易导致惊群效应。
多线程accept惊群问题的现象:
当一个新客户端连接请求到达服务器时,操作系统内核会将这个新连接放入监听套接字的连接队列中,并标记该套接字为可读(即有新连接就绪)。此时,惊群现象就会发生:
所有(或几乎所有)阻塞在
accept()
上的进程/线程同时被内核唤醒。 它们会从睡眠状态中恢复,争先恐后地尝试从监听套接字上取出新连接。只有其中一个进程/线程会成功调用
accept()
,并获取到这个新的客户端连接。 这是因为一个新连接只能被一个accept()
调用成功处理。其他被唤醒的进程/线程在调用
accept()
时会失败 (通常会立即返回错误,例如EAGAIN
或EWOULDBLOCK
,如果是非阻塞accept
;或者发现连接已被其他进程/线程取出,然后再次进入阻塞状态,如果它们是阻塞accept
)。这些失败的进程/线程会发现自己白白被唤醒,没有获得连接,于是它们又会立即再次进入休眠状态,等待下一个连接。
这种"白白被唤醒又休眠"的过程,就造成了资源的浪费和性能的下降:
大量的上下文切换: 内核需要将 CPU 资源从当前运行的进程/线程切换给这些被唤醒的进程/线程,然后又立即切换回来或切换给另一个进程/线程。频繁的上下文切换会消耗宝贵的 CPU 时间。
CPU 缓存失效: 进程/线程的频繁切换会导致 CPU 的高速缓存(Cache)中的数据被污染,降低了缓存命中率,进而影响程序的执行效率。
多accept线程惊群问题的解决方式
针对 accept()
的惊群问题,Linux 内核采取了以下策略:
-
等待队列的优化: 在 Linux 内核中,每个文件描述符(包括监听套接字)内部都维护了一个或多个等待队列。当进程/线程调用阻塞的
accept()
或epoll_wait()
并等待特定事件时,它们会把自己添加到相应的等待队列中。 -
排他性唤醒: 这是解决惊群问题的核心机制。当一个新连接到达监听套接字时,内核会触发一个唤醒事件。在唤醒等待队列上的进程/线程时,内核并不是一股脑地唤醒所有等待者,而是:
-
优先唤醒标记为"排他"的等待者。
-
如果存在多个排他等待者,只唤醒其中一个。这个被唤醒的进程/线程通常是等待时间最长的(或者根据调度策略选择一个)。
-
只有当被唤醒的排他等待者成功获取了资源(例如,成功调用了
accept()
并处理了新连接)后,队列上其他等待者才会在后续有新的事件时,再次有机会被唤他醒。
-
这种优化是内核层面实现的,对于应用程序开发者而言,在现代 Linux 系统上,你通常不需要额外编写复杂的同步代码来避免 accept() 的惊群问题,因为内核已经为你处理了。
一个字:爽!!!

select、poll、epoll三者对比
1. select
select 是最早出现也是最通用的 I/O 多路复用机制,几乎所有操作系统都支持它。
工作原理:
- 你提供三组文件描述符集合(一个用于读事件,一个用于写事件,一个用于异常事件)。
- select 会阻塞等待,直到这些集合中的任何一个文件描述符准备好进行 I/O 操作(比如有数据可读、可写或者发生错误)。
- 当 select 返回时,它会修改这三个集合,只留下那些已经就绪的文件描述符。你需要遍历所有文件描述符,才能知道具体是哪些就绪了。
优点:
- 可移植性好:几乎所有 Unix-like 系统都支持。
- 简单易用:API 相对简单。
缺点:
- 文件描述符数量限制:通常有 FD_SETSIZE 的硬性限制(在大多数系统上默认为 1024),这意味着你不能同时监控超过这个数量的文件描述符。
- 效率问题:每次调用 select 都需要将整个文件描述符集合从用户空间复制到内核空间,开销较大。除此之外,返回后需要遍历整个文件描述符集合来查找就绪的描述符,当监控的文件描述符数量很大时,这个遍历操作会变得很慢(O(N) 复杂度)。
2. poll
poll 是为了解决 select 的一些缺点而出现的,尤其是在文件描述符数量上的限制。
工作原理:
- 你提供一个 pollfd 结构体数组,每个结构体包含一个文件描述符和它所关心的事件(读、写等)。
- poll 同样会阻塞等待,直到数组中的任何一个文件描述符就绪。
- 当 poll 返回时,它会在对应的 pollfd 结构体中标记就绪的事件。你依然需要遍历整个数组来查找就绪的描述符。
优点:
没有 FD_SETSIZE 限制:可以监控的文件描述符数量只受限于系统内存。
API 稍微灵活:使用 pollfd 结构体数组,比 select 的位图更容易管理。
缺点:
效率问题:和 select 类似,每次调用 poll 都需要将整个 pollfd 数组从用户空间复制到内核空间。
返回后依然需要遍历整个数组来查找就绪的描述符,效率 O(N)。
3. epoll (Linux 特有)
epoll 是 Linux 内核为高性能网络编程专门设计的 I/O 多路复用机制,解决了 select 和 poll 在大规模并发连接下的所有主要性能问题。
工作原理:
- 事件驱动:epoll 不像 select/poll 那样每次都重新传递和遍历所有文件描述符。它采用"事件驱动"和"回调"的方式。
- 你需要先创建一个 epoll 实例(epoll_create)。
- 然后,你可以通过 epoll_ctl 注册你想要监控的文件描述符及其感兴趣的事件(EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL)。这些信息会存储在内核中。
- 调用 epoll_wait 时,它只会返回已经就绪的文件描述符列表,而不会返回所有监控的描述符。
优点:
- 无文件描述符数量限制:只受限于系统内存(理论上)。
- 效率极高:零拷贝/共享内存:注册的文件描述符信息存储在内核中,避免了频繁的用户空间到内核空间的数据拷贝。
- 只返回就绪事件:epoll_wait 只返回真正就绪的事件,无需遍历所有监控的文件描述符,效率是 O(K)(K 为就绪事件的数量,通常 K 远小于 N)。
- 边缘触发 (ET) 和水平触发 (LT):epoll 支持两种工作模式。边缘触发只在状态发生变化时通知一次(比如新数据到来),需要更精细的控制,但效率更高。水平触发只要状态满足条件就一直通知(比如缓冲区有数据就一直通知),相对更简单。
缺点:
Linux 独有:不具备跨平台性,在其他操作系统(如 macOS, FreeBSD,Windows 等)上没有 epoll,需要使用 kqueue(macOS/FreeBSD 等)或 IOCP(Windows)。
参考文章: