IO多路复用使用
- IO多路复用是一种同时监控多个文件描述符(文件、套接字等)的机制,当某些文件描述符变为可读、可写或有错误时,程序能够及时得到通知并进行相应的处理。具体见
Linux网络IO模型
- 主要看介绍
select
和epoll
,重点看epoll
select
原理和特点
- 早期
IO
多路复用机制- 核心
- 通过一个固定大小的文件描述符集合(
fd_set
),将需要监控的文件描述符加入集合中- 内核检查
- 如果文件描述符变为可用,返回就绪的描述符
- 如果没有就绪则阻塞或等到超时
- 特点
- 跨平台支持
- 集合大小有限,
linux
默认1024
,无法处理高并发- 效率低:每次调用都需要将文件描述符集合从用户态拷贝到内核态,随着监控的文件描述符数量增多,开销会显著增加
- 水平触发:如果文件描述符就绪但未被处理,
select
会在后续调用中再次返回它
流程
创建
fd_set
变量循环准备:调用
select
函数
FD_ZERO()
:清空fd_set
。FD_SET()
:将所有需要监视的 FD(如监听套接字、已连接的客户端套接字)加入到相应的fd_set
中。- 确定
nfds
参数:这是所有被监视 FD 中的最大值加 1。- 设置
timeout
(可选)。处理返回
- 检查
select()
的返回值:错误、超时或有FD
就绪。- 如果返回值大于
0
,表示有FD
就绪。识别就绪 FD:使用
FD_ISSET()
遍历所有之前加入fd_set
的FD
,判断它们是否仍在select()
返回后的fd_se
t 中(即是否就绪)
I/O
操作:对就绪的 FD 执行相应的 I/O 操作(如accept()
,read()
,write()
)
实操
了解即可,重点关注
epoll
cpp
#ifndef SERVER_H
#define SERVER_H
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <sys/select.h> // select
#include <vector> // stl vector
#include <algorithm>
class Server
{
public:
Server(int port);
~Server();
void init();
void handel();
private:
// 服务端套接字
int server_fd;
// 不使用struct sockaddr,绑定时需要强转
// 实际使用sockaddr_in或sockaddr_in6来区分ip类型
struct sockaddr_in address;
// 主文件描述符集合,保存所有活动的连接
fd_set master_fds;
// 临时文件描述符集合,用于select调用
fd_set read_fds;
// 最大的文件描述符值,用于select调用
int max_fd;
// 客户端文件描述符列表
std::vector<int> client_fds;
// 消息处理方法
void echoMsg(int client_fd);
std::string readMsg(int client_fd);
void writeMsg(int client_fd, const std::string msg);
// 处理新连接
void handleNewConnection();
// 处理客户端消息
void handleClientMessage(int client_fd);
// 移除客户端连接
void removeClient(int client_fd);
};
#endif
epoll
原理和特点
select
和poll
的不足
- 效率随 FD 数量增加而下降,每次都要把所有文件描述符集合完整地拷贝到内核空间
- 可处理的连接太小
- 重复的数据拷贝fd集合
epoll
通过数据结果和机制解决了上述问题
- 基于事件驱动
- 不需要在每次调用时传递所有要监控的 FD,它维护了一个内核级的事件表(通常基于红黑树实现),用于存储用户关心的
FD
及其事件- 当某个
FD
的状态发生变化(例如,数据可读、可写),内核会主动通过回调机制将这个 FD 加入到一个就绪链表中epoll_wait
调用只需要检查这个就绪链表是否为空- 高效的就绪 FD 获取
epoll_wait
的核心操作是检查就绪链表并将就绪事件返回给用户(拷贝回用户缓冲区)。这个操作的时间复杂度是 O(K),其中 K 是就绪的 FD 数量。与被监控的总 FD 数量 N 无关,这使得epoll
在处理大量连接但只有少数活跃连接时效率极高。- 可继续优化
- 可以使用其他技术优化,如
epoll
可以使用mmap
实现映射就绪链表,而非每次都拷贝- 本身没有处理上限,受限于系统配置
- 避免了多线程线程带来的上下文切换和CPU资源占用
底层原理详解
- 核心数据结构 (
eventpoll
):
- 内核为每个
epoll
实例维护一个struct eventpoll
对象。- 红黑树 (
rbr
): 高效地存储和查找所有被监控的fd
对应的epitem
。插入、删除、查找的时间复杂度都是 O(log N),其中 N 是被监控的 FD 总数。epoll_ctl
操作主要作用于此。- 就绪链表 (
rdllist
): 一个双向链表,存储已经就绪的fd
对应的epitem
。epoll_wait
主要从此链表获取结果。添加和移除操作是 O(1)。- 等待队列 (
wq
): 当epoll_wait
发现就绪链表为空时,调用进程会在此等待队列上睡眠。- 锁 (
lock
,mtx
): 保护eventpoll
结构内部数据(如红黑树、就绪链表)在多线程环境下的并发访问。- 回调机制 (关键):
epoll_ctl(ADD)
时,不仅仅是将fd
加入红黑树,更重要的是,它会将一个回调函数(ep_poll_callback
)与该fd
关联起来。这是通过将epoll
实例注册到fd
对应struct file
的poll
等待队列上实现的。- 当底层设备驱动程序(如网络驱动)检测到
fd
的状态变化(如网卡收到数据包),它会唤醒在该fd
的等待队列上等待的进程/回调。ep_poll_callback
被触发执行。它检查发生的事件类型是否与用户在epoll_ctl
时指定的events
匹配。- 如果匹配,该回调函数会将与
fd
关联的epitem
添加到eventpoll
对象的就绪链表 (rdllist
) 中。- 唤醒
epoll_wait
:如果就绪链表在添加epitem
之前是空的,ep_poll_callback
还会唤醒在eventpoll
自身等待队列wq
上睡眠的进程(即正在调用epoll_wait
的进程)。- 边缘触发
- 水平触发 (LT - Level Triggered) (默认): 只要文件描述符处于可读/可写状态,
epoll_wait
就会持续通知。例如,如果socket的接收缓冲区有数据,每次调用epoll_wait
都会返回该socket
可读,直到数据被完全读完。- 边缘触发 (ET - Edge Triggered): 当文件描述符从未就绪状态变到就绪状态时,
epoll_wait
才会通知一次。之后,即使数据没读完,也不会再通知,直到有新的数据到达。
ET
模式在处理大量连接时效率更高,因为它减少了epoll_wait
被重复唤醒的次数- 使用
ET
模式时,如果需要读完所有数据,只能自己判断是否是读取完成(通常使用循环读取直到返回EAGAIN
)。具体见下方readMsg
函数
流程
创建
epoll
实例epoll_create(int size) / epoll_create1(int flags)
- 创建一个
epoll
实例,并返回一个指向该实例的文件描述符(epfd
)。这个 epfd 后续将用于epoll_ctl
和epoll_wait
调用。poll_create(size)
: size 参数在早期内核中用于提示内核数据结构的大小,但现在已被忽略,只需传入一个大于 0 的正数即可。epoll_create1(flags)
: 是推荐使用。flags
参数可以设置EPOLL_CLOEXEC
,使得epfd
在执行exec
系列调用时自动关闭,避免文件描述符泄漏。- 内核创建一个
eventpoll
结构体实例。这个结构体内部包含了用于存储被监控 FD 的红黑树 (rbr) 和用于存放就绪 FD 的双向链表 (rdllist),以及相关的锁和等待队列 (wq)。注册
epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
- 向
epfd
指向的epoll
实例注册、修改或删除需要监控的文件描述符 fd 及其关心的事件。op
: 操作类型:EPOLL_CTL_ADD
: 注册新的 fd 到 epfd 上。EPOLL_CTL_MOD
: 修改已经注册的 fd 的监听事件。EPOLL_CTL_DEL
: 从 epfd 中移除 fd,不再监听。event
: 定义了需要监听的事件类型和行为cstruct epoll_event { uint32_t events; epoll_data_t data; }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;
events
: 位掩码,指定关心的事件,如EPOLLIN
(可读),EPOLLOUT
(可写),EPOLLPRI
(高优先级数据可读),EPOLLERR
(错误发生),EPOLLHUP
(对端挂断),EPOLLET
(设置为边缘触发模式)。data
: 用户数据,当epoll_wait
返回此fd
的事件时,会将这个 data 一并返回。通常用来存储与 fd 相关的信息,如指向连接对象的指针或fd
本身。等待
epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
等待
epfd
上注册的fd
发生事件。
events
: 用户提供的struct epoll_event
数组,用于接收就绪事件。内核会将就绪 FD 的信息(包括 epoll_ctl 时设置的data
)填充到这个数组中
timeout
: 等待的超时时间(毫秒)。
- -1: 阻塞等待,直到有事件发生。
- 0: 立即返回,非阻塞检查。
>0
: 最多等待timeout
毫秒。返回值:成功时返回就绪的
FD
数量(大于0
);超时返回0
;出错返回-1
并设置errno
。
- 当某个被监控的 fd 对应的设备驱动检测到状态变化(如数据到达),会调用其等待队列上的回调函数。
- 之前通过
epoll_ctl(ADD)
注册的ep_poll_callback
被执行。这个回调函数检查事件是否是用户关心的,如果是,则将对应的epitem
添加到eventpoll
的就绪链表 (rdllist)。- 如果
rdllist
从空变为非空,则唤醒在eventpoll
等待队列wq
上睡眠的进程/线程。
实操
cpp
#ifndef SERVER_H
#define SERVER_H
#include <sys/socket.h>
#include <sys/epoll.h> // epoll
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <cstring>
#include <iostream>
#include <vector>
#include <unordered_map>
#include <fcntl.h>
#include <errno.h>
class Server
{
public:
Server(int port);
~Server();
void init();
void run();
std::string readMsg(int client_fd);
void writeMsg(int client_fd, const std::string &msg);
private:
int server_fd;
int epoll_fd;
struct sockaddr_in address;
// epoll事件数组,在epoll_wait那里使用
std::vector<struct epoll_event> events;
// 设置Socket非阻塞(必须,否则程序阻塞【如write、read】会导致epoll_wait通知不能及时处理)
void setNonBlocking(int fd);
// 添加到epoll监听,使用ET模式(默认是LT)
void addToEpoll(int fd, bool et_mode = true);
// 从epoll中移除(连接断开时移除)
void removeFromEpoll(int fd);
// 处理新连接
void handleAccept();
// 处理客户端数据
void handleClient(int client_fd);
// 最大事件数
static const int MAX_EVENTS = 1024;
};
#endif