简单通俗理解IO多路复用原理
核心技术
- 一个线程处理多个 I/O 连接
- 非阻塞式处理
- 基于事件驱动
1. 文件描述符与就绪状态
在 Linux 系统中,每个网络连接都被抽象为一个文件描述符(fd)。一个文件描述符可能处于以下几种状态:
// 可能的事件状态
#define EPOLLIN 0x001 // 可读
#define EPOLLOUT 0x004 // 可写
#define EPOLLERR 0x008 // 错误
#define EPOLLHUP 0x010 // 连接断开
2. 数据就绪的判定
以 TCP 连接为例,"有数据可读"的具体情况包括:
- Socket 缓冲区:
struct sock_buff {
char* buffer; // 数据缓冲区
size_t data_len; // 当前数据长度
size_t capacity; // 缓冲区容量
};
当以下任一情况发生时,系统会标记该 fd 为"可读":
- 接收缓冲区中有新数据到达
- 对端关闭连接(收到 FIN 包)
- 有新的连接到达(对于监听 socket)
3. 内核通知机制
以 epoll 为例,其工作流程:
// 1. 创建 epoll 实例
int epfd = epoll_create(1);
// 2. 注册感兴趣的 fd
struct epoll_event event;
event.events = EPOLLIN; // 监听读事件
event.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &event);
// 3. 等待事件发生
struct epoll_event events[MAX_EVENTS];
while (1) {
// 阻塞等待事件
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
// 处理就绪的事件
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
// 有数据可读
handle_read(events[i].data.fd);
}
}
}
4. 实际工作流程
让我们看一个具体的例子:
- 初始状态:
客户端 A、B、C 都已连接到服务器
- A 的 fd = 3
- B 的 fd = 4
- C 的 fd = 5
所有连接都在 epoll 实例中注册
- 数据到达过程:
时刻 1: 所有连接都没有数据
epoll_wait() -> 阻塞等待
时刻 2: 客户端 B 发送数据
- 数据到达内核缓冲区
- 内核标记 fd 4 为可读
- epoll_wait() 返回,events 数组包含 fd 4
时刻 3: 处理 fd 4 的数据
- 读取数据
- 处理完成后继续 epoll_wait()
5. 性能优势
假设有 1000 个连接,但只有 2 个连接有数据:
传统轮询方式:
- 需要遍历 1000 次
- 998 次检查都是无效的
epoll 方式:
- epoll_wait 直接返回 2
- 只需处理 2 个有数据的连接
- 避免了 998 次无效的系统调用
6. 内存影响
// 每个连接的数据结构
struct connection {
int fd;
void *buffer;
size_t buffer_size;
};
// 传统方式
struct connection conns[MAX_CONNECTIONS]; // 所有连接都需要分配资源
// epoll 方式
struct epoll_event events[MAX_EVENTS]; // 只需要处理活跃连接
这种机制使得系统资源使用更加高效,特别是在大量连接但活跃连接较少的场景下(这正是 Redis 的典型使用场景)。