在初学网络编程时,我们难免会遇到这样的问题,我们用最基本的socket函数编写出来的服务端程序往往只能同事处理一个客户端的连接,如果这时候我新开了第二个客户端程序,就无法connect()到该服务端的listen()上,除非第一个客户端程序断开连接,但在这个连接的生命周期中,绝大部分时间都是空闲的,活跃时间(发送数据和接收数据的时间)占比极少。
但是落实到实际的场景中,一个网站往往会接受到成千上万人的访问,这样的访问必定是同时进行的,但正常情况下人们又可以得到很好的响应,那这种情况到底是怎么处理的呢?
我们很容易想到多线程,即每一个客户端请求都交给一个线程或者进程去处理,但者很明显会遇到性能瓶颈,特别是当客户端数量非常庞大时。想象有1000个连接同时到达的情况,难道还有开1000个线程去处理吗。
这时候就要用到一个概念,IO复用。IO复用
是通过一个进程或者线程去管理多个客户端的请求的技术,而不需要为每个连接创建一个单独的线程。linux提供了select、poll、epoll三种io复用方式。
select
、poll
与 epoll
:逐步升级在 select、poll 和 epoll 三者之间,主要的差异体现在它们如何处理大量文件描述符(即客户端连接)。我们从最原始的 select
开始,逐步解释 poll
和 epoll
的优点。
select
机制的工作原理select
是最早的 I/O 多路复用机制,它的工作方式简单但有明显的性能问题。它的基本流程是:
在实现上,select
会用一个位图来表示文件描述符,它会检查所有感兴趣的文件描述符。假设你有一个非常大的连接池,select
每次调用时,都要检查所有连接,看看它们是否有事件(如读或写)发生,这样做的效率很低,尤其是当连接数达到几千、几万时,性能瓶颈就显现出来了。
select
的典型函数签名:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds
:指定 fd_set
中最大的文件描述符加1(即文件描述符的数量)。readfds
:感兴趣的可读文件描述符集合。writefds
:感兴趣的可写文件描述符集合。exceptfds
:异常文件描述符集合(很少用到)。timeout
:超时时间,设置为 NULL
表示阻塞直到事件发生。poll
机制的工作原理poll
的出现是为了弥补 select
的不足,特别是它能够处理任意数量的文件描述符,因为 select
受限于 FD_SETSIZE
的大小。
在 poll
中,我们使用链表来存储文件描述符,每个文件描述符都有一个事件标志,内核会根据这些标志来判断事件是否就绪。与 select
不同的是,poll
不需要扫描位图,而是扫描链表,因此它可以支持更大的文件描述符集合,但同样的问题在于每次调用时,内核还是需要遍历所有的文件描述符,效率依然不高。
poll
的函数签名:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds
:一个 pollfd
结构体数组,表示所有感兴趣的文件描述符。nfds
:文件描述符的数量。timeout
:最大等待时间,单位是毫秒,设置为 -1
表示阻塞等待。每个 pollfd
结构体如下:
struct pollfd {
int fd; // 文件描述符
short events; // 感兴趣的事件
short revents; // 发生的事件
};
epoll
机制的工作原理epoll
是专门为高并发设计的,相比于 select
和 poll
,它不再是遍历所有的文件描述符,而是采用了更高效的机制。epoll
使用了 红黑树 和 双向链表 来管理文件描述符,因此它能够做到以下几点:
epoll
会返回所有就绪的文件描述符,不需要扫描所有的文件描述符。epoll
会通过回调机制及时通知应用程序,避免了重复通知。与 select
和 poll
的不同之处在于,epoll
并不需要轮询所有事件,而是通过事件驱动方式,只有发生变化的事件会被自动返回给 epoll
。
epoll
实例,获取 epfd
。epoll_ctl()
注册事件。epoll_wait()
等待事件的发生。// 设置 socket 非阻塞,因为之后要使用 ET 模式
setNoblocking(sockfd);
// 创建 epoll 实例
int epfd = epoll_create1(0);
// 注册服务器 socket 事件
struct epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN; // 关注可读事件
ev.data.fd = sockfd; // 关联服务器 socket
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); // 添加 socket 到 epoll
// 循环等待事件并处理
while (true) {
nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); // 阻塞直到有事件发生
// 遍历发生的事件
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == sockfd) {
// 如果事件发生在服务器 socket 上,表示有新客户端连接
int client_fd = accept(sockfd, NULL, NULL);
// 设置非阻塞
setnonblocking(client_fd)
// 注册客户端 socket 事件
ev.events = EPOLLIN | EPOLLET; // 关注可读事件并启用边缘触发(ET)
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
printf("New client connected: fd %d\n", client_fd);
} else if (events[i].events & EPOLLIN) {
// 如果是客户端 socket 上发生了可读事件
handle_client(events[i].data.fd); // 处理该客户端的请求
} else {
// 处理其他逻辑
}
}
}
epoll
函数接口详解epoll
的主要接口有 3 个:epoll_create1()
、epoll_ctl()
和 epoll_wait()
。当然还有一个非常重要的结构体epoll_event,下面就让我来以此介绍:
epoll_create1()
epoll_create1()
用于创建一个 epoll
实例,并返回一个文件描述符。通过这个文件描述符,程序可以管理需要监控的事件。
函数原型:
int epoll_create1(int flags); // epoll_create(int num) 已经废弃
参数:
flags
:控制 epoll
行为的标志,通常设置为 0
,但有一个有效的标志是 EPOLL_CLOEXEC
,用于设置在 exec()
调用时关闭文件描述符。
EPOLL_CLOEXEC
:当执行 exec()
时,自动关闭文件描述符。通常用于避免不必要的文件描述符在新进程中泄漏。返回值:
epoll
实例的文件描述符。-1
,并设置 errno
。使用流程:
epoll_create1()
创建一个 epoll
实例,得到 epfd
(epoll
文件描述符)。epfd
进行对事件的添加、删除和监控。例子:
int epfd = epoll_create1(0);
//一个非常经典的错误处理代码段,自己实际开发时可以将其打包成一个函数
if (epfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
struct epoll_event
结构体struct epoll_event
是 epoll
的核心数据结构之一,它用于表示一个文件描述符的事件信息。通过它,我们可以指定要监听的事件类型,并存储一些用户自定义的数据(通常是文件描述符或者指针)。
结构体定义:
struct epoll_event {
uint32_t events; // 事件类型,表示关注的 I/O 事件
epoll_data_t data; // 用户数据,关联文件描述符或者其他自定义信息
};
1. events
字段(事件类型)
events
字段用于指定文件描述符(FD)感兴趣的事件类型,它是一个位掩码,支持多个事件的组合。常用的事件类型包括:
EPOLLIN
:表示文件描述符可读,通常用于监控 socket 是否有数据可读取。EPOLLOUT
:表示文件描述符可写,通常用于监控 socket 是否可写。EPOLLERR
:表示文件描述符出现错误。EPOLLHUP
:表示文件描述符被挂起(如连接断开等)。EPOLLET
:边缘触发模式(Edge Triggered),设置该标志后,epoll
将在事件的状态变化时触发通知。EPOLLONESHOT
:单次触发模式。事件触发后,将会删除该文件描述符的事件,直到重新注册。事件类型的组合:events
字段支持位掩码操作,你可以同时监控多个事件。例如,你可以同时关注可读和可写事件:
ev.events = EPOLLIN | EPOLLOUT; // 同时监听可读和可写事件
2. data
字段(用户数据)
data
字段是一个联合体(union),它允许你存储各种类型的数据。data
字段用于存储与文件描述符关联的用户自定义数据,通常是文件描述符本身,或者是指向结构体的指针。
epoll_data_t
定义:
union的使用方法请自行搜索
typedef union epoll_data {
void *ptr; // 用户数据,可以是一个指针
int fd; // 文件描述符,常用于存储事件关联的 fd
uint32_t u32; // 无符号 32 位整数,用户自定义类型
uint64_t u64; // 无符号 64 位整数,用户自定义类型
} epoll_data_t;
data
字段的四个可能类型:
ptr
:指向一个结构体或数据的指针,可以用来传递更多的信息。fd
:用于存储文件描述符,通常与 epoll
事件相关联的文件描述符会被存储在此。u32
:一个 32 位无符号整数,适用于存储一些简单的 ID 或状态码等。u64
:一个 64 位无符号整数,适用于存储一些大范围的数字数据。struct epoll_event
假设你想监听一个 socket 是否有数据可读,即 EPOLLIN
:
struct epoll_event ev;
ev.events = EPOLLIN; // 关注可读事件
ev.data.fd = sockfd; // 关联文件描述符 sockfd
如果你希望同时监听某个 socket 的可读和可写事件:
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLOUT | EPOLLET; // 同时监听可读、可写事件,并启用边缘触发模式
ev.data.fd = sockfd; // 关联文件描述符 sockfd
epoll_ctl()
epoll_ctl()
用于向 epoll
实例中添加、修改或删除文件描述符的监控事件。
函数原型:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
epfd
:由 epoll_create1()
返回的 epoll
文件描述符。op
:操作类型,指定是添加(EPOLL_CTL_ADD
)、修改(EPOLL_CTL_MOD
)还是删除(EPOLL_CTL_DEL
)监控事件。fd
:需要监控的文件描述符(如客户端的 socket、服务器的监听 socket 等)。event
:指向一个 struct epoll_event
的指针,描述要监控的事件以及附带的用户数据。epoll_wait()
epoll_wait()
是事件等待的核心函数,用于获取发生的事件,通常被阻塞在这里,直到有事件发生。
函数原型:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数:
epfd
:由 epoll_create1()
返回的 epoll
文件描述符。events
:指向 epoll_event
数组的指针,用于保存发生事件的文件描述符。maxevents
:events
数组的大小。即每次最多返回多少个事件。timeout
:等待时间,单位是毫秒。如果设置为 -1
,表示无限期等待;设置为 0
,表示非阻塞;设置为正值表示等待一定时间。返回值:
-1
,并设置 errno
。使用流程:
epoll_wait()
等待事件发生。EPOLLIN
、EPOLLOUT
等),应用程序执行相应的操作。例子:
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); // 阻塞等待事件发生
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}
for (int i = 0; i < nfds; i++) {
// 处理事件
}
epoll
的事件触发模式epoll
支持两种事件触发模式:
对于触发模式我们只需要记住两者的区别即可,需要注意的是边缘触发模式需要一次性读取所有到达的数据,这些需要你在 handle_client()
中自己实现。编程并不会难很多。
水平触发是 epoll
的默认模式。在这种模式下,只要文件描述符上有事件发生(例如,数据可读),epoll_wait()
就会返回该事件,并持续返回,直到你对该文件描述符执行了相关操作(如 read()
或 write()
)来消耗或处理事件。
epoll
会返回事件,并等待你处理。若数据未被完全读取,它将继续返回该文件描述符的可读事件,直到你读取完所有的数据。epoll_wait()
返回事件时,你只需对返回的文件描述符进行相应的处理,处理完后就可以清除该事件,等待下一个事件。epoll
会一直通知你有可读数据,直到你将所有数据读取完,这会导致性能下降。适用场景:水平触发模式适合大多数应用,尤其是当你希望简单、直观地处理事件时。它对于那些对事件的处理可以按顺序、逐一完成的应用非常合适,如单一任务的客户端-服务器通信。
边缘触发是相对于水平触发的更高效的模式。它的特点是只在文件描述符的状态发生变化时才通知应用程序。具体来说,当文件描述符从不可读变为可读时,epoll
会通知你一次。之后,除非文件描述符变得不可读,epoll
不会再通知你。
epoll
会触发一次通知。换句话说,当数据变得可读时,epoll
会通知你一次,但在数据还未被读取之前,它不会再次通知你,直到数据变为不可读状态。适用场景:边缘触发非常适合那些需要高效处理大量连接的应用,比如高并发的Web服务器或实时数据处理应用。对于这些应用,减少重复通知和提高事件处理的效率是非常关键的。
在使用 epoll
时,可以在注册事件时通过 epoll_ctl()
来设置触发模式。通常情况下,水平触发是默认模式,而边缘触发模式需要显式指定。
设置为边缘触发模式时,ev.events
需要包含 EPOLLET
标志,例如:
ev.events = EPOLLIN | EPOLLET; // 设置为边缘触发模式(ET)
如果不希望使用边缘触发模式,只需忽略 EPOLLET
标志,epoll
默认会使用水平触发模式。
注意事项:
O_NONBLOCK
),否则可能会导致程序陷入死锁,无法正确处理事件。