目录
I/O 多路复用是允许单个线程或进程同时去监控多个 I/O 操作的技术,其核心就是通过一个系统调用去内核监听多个文件描述符,当内核将数据准备好了之后会返回可读的条件,这时候我们再去调用系统调用将数据拷贝到用户缓冲区里。不了解网络 I/O 可以先看【UNIX网络编程】5种I/O模型这篇文章

常见的 I/O 复用技术有 select、poll、epoll。
1、select
select的核心原理是通过单进程创建一个文件描述集合,我们将关心的文件描述符添加到这里。内核通过轮询检测的方式监控是否有文件描述符可读写,一但有文件描述符就绪,通知进程进行 I/O。
1.1、函数API
#include <sys/select.h>
#include <sys/time.h>
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset,
const struct timeval *timeout);
/* 成功返回就绪描述符数据,超时返回0,出错返回-1 */
maxfdp1:用于指定待监测的文件描述符的最大编号,它的值是待测试的最大文件描述符加1。
从0、1、2......一直到 maxfdp1-1。例如我们要监测 {0,2,7},那我们的 maxfdp1 就要设置为8。
头文件 #include <sys/select.h> 定义的 FD_SETSIZE 是 fd_set 中的最大文件描述符编号,默认是1024。
由于系统的限制,进程默认能打开的最大文件描述符数通常也是 1024(通过
ulimit -n查看)。所以即使FD_SETSIZE更大,若进程无法打开更多fd,select()也无法使用。虽然我们能够去修改进程默认打开的最大文件描述符数量,但是从可以移植性来说,我们需要更加小心。
readset,writeset,exceptset:分别时指向可读、可写、异常事件的文件描述符
timeout:超时时间结构体指针,指定 select() 等待的最长时间。支持秒和微秒。当不检测任何 fd 的时候 ,可以用于实现微秒级定时器。
当传入值为 NULL 时,默认是一直阻塞等待。timeval = {0, 0} 时非阻塞立马返回。
struct timeval
{
long tv_sec; // 秒
long tv_usec; // 微秒
};
操作文件描述符的集合函数
将文件描述符添加到文件描述符集合中
void FD_SET(int fd,fd_set *set)
将fd从文件描述符集合中删除
void FD_CLR(int fd,fd_set *set)
判断fd是否在文件描述符集合中
int FD_ISSET(int fd,fd_set *set)
将文件描述符集合清空
void FD_ZERO(fd_set *set)
参数描述:
fd:文件描述符
set:文件描述符集合的指针
1.2、使用流程
步骤1:初始化监控集合
使用 FD_ZERO 清空集合,FD_SET 添加需监视的 fd。
fd_set readfds;
FD_ZERO(&readfds); /* 清空集合 */
FD_SET(socket_fd, &readfds); /* 添加 socket_fd 到读集合 */
步骤 2:调用 select()
int select(int maxfdp1, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
步骤 3:检查就绪的 fd
使用 FD_ISSET 判断具体哪个 fd 就绪。
if (FD_ISSET(socket_fd, &readfds)) {
/* socket_fd 可读,执行 recv() */
}
1.3、底层原理
核心数据结构
select 使用3个位图 fdset 来标记监控的fd,这个位图实际上就是一个固定大小的位数组,默认大小是1024,即 FD_SETSIZE = 1024。
fd_set readfds; /* 监视可读事件 */
fd_set writefds; /* 监视可写事件 */
fd_set exceptfds; /* 监视异常事件 */
轮询机制
调用 select 后,内核会去以轮询的方式监测这个位数组,如果哪个位上的文件描述符有事件发生了,就会通知应用层去做处理。所以这就是为什么描述符上限是1024,因为时间复杂度是O(n),当监控的文件描述符数量太多了,会大大降低服务器响应效率,不应在select上投入更多精力。
1.4、优缺点
优点:
- 跨平台
- 可以实现微秒级定时器
缺点:
- 文件描述符上限 1024
- 轮询检测fd,效率低
- 每次都需要将需要监听的文件描述符集合由应用层拷贝到内核
1.5、代码示例
#include <sys/select.h>
#include <unistd.h>
#include <stdio.h>
int main()
{
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO, &readfds); /* 监视标准输入 */
struct timeval timeout = {5, 0}; /* 5秒超时 */
int ready = select(1, &readfds, NULL, NULL, &timeout);
if (ready == -1)
{
perror("select失败!");
}
else if (ready == 0)
{
printf("超时,无数据!.\n");
}
else
{
if (FD_ISSET(STDIN_FILENO, &readfds))
{
char buf[256];
read(STDIN_FILENO, buf, sizeof(buf));
printf("接收到的数据: %s", buf);
}
}
return 0;
}
2、poll
poll 与 select的原理非常相似,也是通过轮询的方式来监控 fd 是否就绪。但是与 select 不同的是,poll 是采用动态数组存储的 fd,表面上突破了 FD_SETSIZE 的限制。
2.1、函数API
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds:指向 pollfds 结构数组的指针,每个元素描述一个待监测的 fd。
pollfd结构体
struct pollfd
{
int fd; /* 文件描述符 */
short events; /* 监视的事件(输入) */
short revents; /* 实际发生的事件(输出) */
};
常用事件:
| 事件常量 | 描述 | 作为events输入 | 作为revents结果 |
|---|---|---|---|
| POLLIN | 普通或优先级带数据可读 | 是 | 是 |
| POLLRDNORM | 普通数据可读 | 是 | 是 |
| POLLRDBAND | 优先级带数据可读 | 是 | 是 |
| POLLPRI | 高优先级数据可读 | 是 | 是 |
| POLLOUT | 普通数据可写 | 是 | 是 |
| POLLWRNORM | 普通数据可写 | 是 | 是 |
| POLLWRBAND | 优先级带数据可写 | 是 | 是 |
| POLLERR | 发生错误 | 否 | 是 |
| POLLHUP | 发生挂起 | 否 | 是 |
| POLLNVAL | 描述符不是一个打开的文件 | 否 | 是 |
nfds:数组长度,即监控的fd数量。
timeout:超时时间,-1为阻塞,0为非阻塞。
2.2、使用流程
步骤1:初始化 pollfd 数组
struct pollfd fds[2];
fds[0].fd = socket_fd1;
fds[0].events = POLLIN; /* 监视可读事件 */
fds[1].fd = socket_fd2;
fds[1].events = POLLOUT; /* 监视可写事件 */
步骤2:调用 poll
int ready = poll(fds, 2, 1000);
步骤3:检查就绪事件
if (fds[0].revents & POLLIN)
{
recv(socket_fd1, buf, sizeof(buf), 0); /* 处理可读事件 */
}
if (fds[1].revents & POLLOUT)
{
send(socket_fd2, data, sizeof(data), 0); /* 处理可写事件 */
}
2.3、优缺点
优点(相比 select)
- 无
fd数量限制 - 支持更多事件类型
pollfd的events字段可长期保持,仅需检查revents。
缺点
- 高并发时遍历所有
fd效率低(与select相同)。 - 每次调用需将
pollfd数组从用户态拷贝到内核态。 - 跨平台差异,部分系统不支持某些事件标志。
3、epoll
epoll 相比于 select 和 poll 来说有较大的不同。
3.1、工作流程和原理
步骤1:创建 epoll 实例
int epoll_create(int size);
调用 epoll_create() 后会在内核创建一个 eventpoll 结构体,会去初始化结构体中三个关键的数据结构:
- 红黑树(rbr):存储所有被监控的文件描述符
- 就绪链表(rdllist):存储已经就绪的文件描述符
- 等待队列(wq):存放等待事件的进程
返回一个文件描述符指向这个 epoll 实例。
步骤2:添加/修改/删除监控项
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
op:指定操作类型,EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL
对于 EPOLL_CTL_ADD 操作,内核会创建一个 epitem 结构体,也就是我们要监控的内容,然后将 epitem 插入到红黑树上。向文件描述符的回调列表注册回调函数 ep_poll_callback。
步骤3:等待事件发生
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
调用 epoll_wait() 后会检查就绪链表是否为空,如果为空,将当前进程加入到 eventpoll 的等待队列,然后休眠。有事件发生的时候,ep_poll_callback 回调函数会被调用,将 epitem 加入到就绪链表。唤醒等待进程,然后将就绪事件拷贝到用户空间。
大致的调用流程如下图:

总结
epoll 会先在内核创建一个红黑树、一个就绪链表和一个等待队列。红黑树用于存储文件描述符节点,就绪链表用于存储准备好的文件描述符,等待队列用于存储阻塞中的进程/线程。我们通过 epoll_ctl() 将文件描述节点注册到红黑树上,注册回调到设备驱动上(socket等待队列)。epoll 底层是通过内核协议栈去接收网卡中断或数据包,当有网络事件来了,会触发回调,在红黑树上找到来网络事件的 fd 节点,将其添加到就绪链表上,并且唤醒阻塞等待的进程/线程来处理就绪链表。将就绪链表中的就绪事件信息拷贝到用户自己的缓冲区中,拷贝的过程中需要遍历链表。
3.2、水平触发和边缘触发
水平触发和边缘触发,是 I/O 复用中两种核心的事件机制。
1、水平触发 (LT)
水平触发是只要 fd 处于就绪状态了,就会不断地通知用户去处理这个 fd。例如,数据缓冲区里面有 1KB 数据没有读取,我们每次调用 epoll_wait() 的时候都会通知我们这个 fd 可读。
水平触发是默认的模式。
优点是变成起来比较简单,不容易遗漏事件;缺点是可能会频繁的唤醒进程。
2、边缘触发(ET)
边缘触发是 fd 就绪之后只会通知用户一次,不是否处理,后续都不会再通知了。例如,若 socket 接收缓冲区收到新数据,仅第一次 epoll_wait() 报告可读,即使数据未读完,后续不再通知。
优点是减少了 epoll_wait() 的调用次数,效率更高;缺点是编程复杂,需要手动处理事件。
ev.events = EPOLLIN | EPOLLET; 启用边沿触发(ET)
关键要求:
必须使用非阻塞 IO
/* 设置 fd 为非阻塞 */
fcntl(fd, F_SETFL, O_NONBLOCK);
循环读取 直到 ENGAIN
while (true)
{
ssize_t n = read(fd, buf, sizeof(buf));
if (n < 0)
{
if (errno == EAGAIN) break; /* 数据读完 */
else handle_error();
}
/* 处理数据... */
}
3.3、代码示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#define MAX_EVENTS 10
#define PORT 8080
#define BUFFER_SIZE 1024
/* 设置文件描述符为非阻塞模式 */
void set_nonblocking(int fd)
{
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main()
{
int server_fd, epoll_fd;
struct sockaddr_in addr;
struct epoll_event ev, events[MAX_EVENTS];
/* 1. 创建TCP套接字 */
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
perror("创建套接字失败");
exit(EXIT_FAILURE);
}
/* 2. 设置套接字选项(避免地址占用错误) */
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
/* 3. 绑定地址和端口 */
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
{
perror("绑定地址失败");
exit(EXIT_FAILURE);
}
/* 4. 开始监听 */
if (listen(server_fd, 10) < 0)
{
perror("监听失败");
exit(EXIT_FAILURE);
}
printf("服务器正在监听端口 %d\n", PORT);
/* 5. 创建epoll实例 */
if ((epoll_fd = epoll_create1(0)) == -1)
{
perror("创建epoll实例失败");
exit(EXIT_FAILURE);
}
/* 6. 将服务器套接字加入epoll监控(水平触发模式) */
ev.events = EPOLLIN; /* 水平触发(LT) */
ev.data.fd = server_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev) == -1)
{
perror("epoll_ctl操作失败(服务器套接字)");
exit(EXIT_FAILURE);
}
/* 7. 事件循环 */
while (1)
{
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (nfds == -1)
{
perror("epoll_wait失败");
exit(EXIT_FAILURE);
}
for (int i = 0; i < nfds; i++)
{
/* 7.1 处理新的客户端连接 */
if (events[i].data.fd == server_fd)
{
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
if (client_fd == -1)
{
perror("接受连接失败");
continue;
}
/* 设置为非阻塞模式 */
set_nonblocking(client_fd);
/* 将新客户端加入epoll监控(水平触发模式) */
ev.events = EPOLLIN;
ev.data.fd = client_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) == -1)
{
perror("epoll_ctl操作失败(客户端套接字)");
close(client_fd);
}
printf("新客户端连接: 文件描述符=%d\n", client_fd);
}
/* 7.2 处理客户端数据 */
else
{
int client_fd = events[i].data.fd;
char buffer[BUFFER_SIZE];
ssize_t bytes_read;
/* 读取客户端数据 */
bytes_read = read(client_fd, buffer, BUFFER_SIZE);
if (bytes_read > 0)
{
/* 回显数据给客户端 */
write(client_fd, buffer, bytes_read);
printf("已回显 %zd 字节到客户端: 文件描述符=%d\n", bytes_read, client_fd);
}
/* 处理连接关闭或错误 */
else if (bytes_read == 0 || (bytes_read == -1 && errno != EAGAIN))
{
printf("客户端断开连接: 文件描述符=%d\n", client_fd);
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
close(client_fd);
}
}
}
}
close(server_fd);
return 0;
}
3.4、优缺点
优点
- 效率高,直接返回就绪的文件描述列表,无需遍历所有文件描述符,时间复杂度 O(1)。
- 文件描述符数量仅受系统内存限制
- 支持边缘触发和水平触发两种方式
- 可以实现百万级并发量,单进程可以管理数万到数十万连接(受系统内存限制)
缺点
- 仅 Linux 系统支持 epoll
- 低并发场景下,效率不如 selec/poll
1141

被折叠的 条评论
为什么被折叠?



