11. I/O事件处理:select/poll/epoll机制、非阻塞I/O、I/O事件驱动模型
各位同学,今天我们来聊聊I/O事件处理。这个话题,说白了就是“你的程序怎么知道数据来了”。
我刚开始做嵌入式开发时,写过一段很傻的代码——用一个死循环去轮询串口有没有数据。CPU占用率直接飙到100%,老板看了直摇头。后来我才明白,真正高效的做法,是让操作系统告诉我们“数据到了”,而不是我们自己去一遍遍问。
这就是I/O事件驱动模型的核心思想:不要主动去问,等着被通知。
11.1 阻塞I/O vs 非阻塞I/O
先搞清楚两个基本概念。
阻塞I/O:你调用read(),如果数据没到,线程就挂在那等。就像你去取快递,快递没到,你就在快递站门口一直站着,啥也不干。
非阻塞I/O:你调用read(),不管数据到没到,立即返回。数据到了就返回数据,没到就返回一个错误码(比如EAGAIN)。就像你打电话问快递到了没,到了就去拿,没到就挂电话继续干别的。
我在项目中遇到过一个问题:一个网关设备要同时处理4路串口数据。如果用阻塞I/O,每个串口开一个线程,4个线程互相等,资源浪费不说,还容易出死锁。后来改成非阻塞I/O + 事件驱动,一个线程搞定所有。
核心原则:处理多个I/O时,永远不要用阻塞模式。否则你会陷入“等一个,丢一堆”的尴尬局面。
11.2 select机制:老当益壮
select是最早的I/O多路复用机制。它的工作方式很简单:你把一堆文件描述符交给内核,说“你帮我盯着,哪个有动静了告诉我”。
原型长这样:
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
使用步骤:
- 把要监控的fd加入fd_set集合
- 调用select,内核检查这些fd是否有事件
- select返回后,遍历所有fd,看哪个还在集合里
- 处理有事件的fd
代码示例:
fd_set read_fds;
struct timeval tv;
int ret;
FD_ZERO(&read_fds);
FD_SET(sock_fd, &read_fds);
FD_SET(serial_fd, &read_fds);
tv.tv_sec = 5;
tv.tv_usec = 0;
ret = select(max_fd + 1, &read_fds, NULL, NULL, &tv);
if (ret == -1) {
// 出错处理
} else if (ret == 0) {
// 超时
} else {
if (FD_ISSET(sock_fd, &read_fds)) {
// 处理socket数据
}
if (FD_ISSET(serial_fd, &read_fds)) {
// 处理串口数据
}
}
select的坑:
- fd_set默认大小1024,超过这个数就崩了
- 每次调用都要把整个fd_set从用户态拷贝到内核态,开销大
- 返回后要遍历所有fd,O(n)复杂度
我曾经在一个项目中用select监控2000个连接,结果每次select都要卡几十毫秒。后来换成epoll,问题瞬间解决。
11.3 poll机制:改进版select
poll解决了select的1024限制问题。它用链表来存储fd,理论上没有上限。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // 文件描述符
short events; // 关注的事件
short revents; // 实际发生的事件
};
使用方式:
struct pollfd fds[2];
fds[0].fd = sock_fd;
fds[0].events = POLLIN;
fds[1].fd = serial_fd;
fds[1].events = POLLIN;
int ret = poll(fds, 2, 5000);
if (ret > 0) {
for (int i = 0; i < 2; i++) {
if (fds[i].revents & POLLIN) {
// 处理fds[i].fd的数据
}
}
}
说实话,poll和select本质上是同一类东西——都是“轮询”的思路。区别只是数据结构不同。但它们的性能瓶颈是一样的:每次调用都要遍历所有fd。
11.4 epoll机制:Linux下的王者
epoll是Linux特有的I/O事件通知机制。它和select/poll最大的区别是:epoll只返回有事件发生的fd,而不是让你去遍历所有fd。
epoll有三个核心函数:
| 函数 | 作用 |
|---|---|
| epoll_create() | 创建一个epoll实例,返回一个文件描述符 |
| epoll_ctl() | 向epoll实例注册/修改/删除要监控的fd |
| epoll_wait() | 等待事件发生,返回有事件的fd列表 |
使用示例:
int epfd = epoll_create(1);
struct epoll_event ev, events[64];
// 注册socket
ev.events = EPOLLIN;
ev.data.fd = sock_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ev);
// 注册串口
ev.data.fd = serial_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, serial_fd, &ev);
while (1) {
int nfds = epoll_wait(epfd, events, 64, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
int fd = events[i].data.fd;
// 处理这个fd的数据
}
}
}
我的经验:epoll有两种触发模式——水平触发(LT)和边缘触发(ET)。
- LT(默认):只要fd还有数据可读,每次epoll_wait都会通知你。不容易丢数据,但可能重复通知。
- ET:只有状态发生变化时才通知一次。必须一次性把数据读完,否则就丢了。效率更高,但编程难度大。
我个人建议:新手先用LT模式。等你对事件模型熟悉了,再挑战ET模式。我曾经在ET模式下没处理好读循环,导致丢包,排查了整整两天。
11.5 三种机制对比
| 特性 | select | poll | epoll |
|---|---|---|---|
| 最大fd数 | 1024 | 无上限 | 无上限 |
| 事件通知方式 | 遍历所有fd | 遍历所有fd | 只返回活跃fd |
| 数据拷贝 | 每次全量拷贝 | 每次全量拷贝 | 共享内存,零拷贝 |
| 适用场景 | 少量连接(<100) | 中等连接(<1000) | 大量连接(>1000) |
| 跨平台 | 几乎所有平台 | 几乎所有平台 | 仅Linux |
你想想看,如果你的嵌入式设备只有几个串口和几个socket,用select完全够用。但如果你在做物联网网关,要同时管理几千个设备连接,那就必须上epoll了。
11.6 I/O事件驱动模型架构
下面这张图展示了事件驱动模型的核心逻辑:
这个架构的核心流程是:
- 注册:把要监控的I/O源(socket、串口、定时器等)注册到多路复用机制中
- 等待:调用select/poll/epoll_wait,让内核帮忙盯着
- 通知:有事件发生时,内核通知事件分发器
- 分发:事件分发器根据事件类型,调用对应的回调函数
- 处理:业务逻辑处理数据
避坑指南:
- 事件处理函数中不要做耗时操作,否则会阻塞其他事件的处理
- 如果必须做耗时操作,用工作线程池来处理
- 注意事件处理的优先级——网络数据通常比定时器优先级高
11.7 实战中的选择建议
说了这么多,到底该用哪个?我根据实际项目经验给个建议:
- 资源受限的MCU(如STM32):用非阻塞I/O + 状态机,别用select/poll,因为很多RTOS不支持
- Linux嵌入式设备(连接数 < 100):用select,简单够用
- Linux嵌入式设备(连接数 100-1000):用poll,避免1024限制
- Linux嵌入式设备(连接数 > 1000):必须用epoll,否则性能扛不住
嗯,我记得有一次做一个智能网关项目,设备要同时管理500个传感器连接。一开始图省事用了select,结果CPU占用率居高不下。换成epoll后,CPU占用率从60%降到了5%。这个差距,你想想看有多大。
小技巧:在Linux上,可以用 cat /proc/sys/fs/epoll/max_user_watches 查看epoll能监控的最大fd数。默认值通常够用,但如果你的设备要管理上万个连接,记得调大这个值。
最后说一句:I/O事件驱动模型不是银弹。如果你的业务逻辑本身就很简单,用阻塞I/O加多线程反而更清晰。但如果你要处理大量并发I/O,那事件驱动就是你的不二之选。
公众号:蓝海资料掘金营,微信deep3321