
在前两篇文章中,我们已经学习了:如何通过ioctl查询输入设备信息;如何使用阻塞 / 非阻塞方式读取输入事件;但是在真实项目中,几乎不会直接使用while(1)+read轮询。原因很简单:
当系统中有多个文件描述符需要监听时,简单的阻塞或非阻塞 read 已经不够用了。
1 为什么需要IO多路复用?
假设有如下需求:
- 同时监听键盘输入
- 同时监听触摸屏
- 同时监听串口
- 同时监听网络socket
这些都属于"文件描述符",在Linux中一切皆文件,输入设备、串口、网络连接本质上都是可读写的文件对象。问题在于:如何在一个线程中同时感知它们的状态变化?
① 如果使用阻塞read
cs
read(fd1);
read(fd2);
read(fd3);
程序执行到read(fd1)时,如果当前没有键盘输入数据,进程就会进入睡眠状态。此时即便串口或网络socket已经有数据到达,程序也完全不知道,因为它被阻塞在第一个read上。换句话说:
阻塞read只能等待一个文件描述符,无法同时感知多个数据源。
如果想解决这个问题,可能会想到创建多个线程,每个线程分别阻塞在不同的read上。虽然可以实现功能,但会带来新的问题:
- 线程切换开销
- 资源消耗增加
- 程序结构复杂
- 线程同步问题
对于嵌入式系统或资源受限设备,这种方式并不优雅。
② 如果使用非阻塞read
cs
while(1)
{
read(fd1);
read(fd2);
read(fd3);
}
非阻塞模式下,如果没有数据,read会立即返回 -1,因此程序不会被挂起。看起来好像可以同时"检查"多个设备。但这种方式存在严重问题:
- CPU 空转:如果当前没有任何设备产生数据,程序会不停循环调用read,形成高频率的系统调用,占用大量CPU时间。系统在做无意义的查询工作,效率极低。
- 响应延迟不可控:如果在循环中加入
usleep()降低CPU占用,那么又会增加响应延迟,导致事件处理不及时。 - 代码不优雅、不可扩展:当监听的文件描述符数量从3个变成30个、300个时,轮询方式会让代码变得臃肿,并且性能迅速下降。
本质上,阻塞read是"只能等一个",非阻塞read是"自己不停问",而我们真正想要的是:
在没有数据时让进程休眠,有数据时立即被唤醒,而且可以同时监听多个文件描述符。
这正是IO多路复用机制解决的问题。
Linux 提供select、poll、epoll等接口,其核心思想是:
- 由内核统一监视多个文件描述符
- 当任意一个文件准备好时
- 内核唤醒进程
- 应用层再去精确读取数据
这种机制兼具:
- 阻塞方式的低CPU占用
- 非阻塞方式的多路监听能力
因此,IO多路复用 是介于"阻塞等待"和"主动轮询"之间的一种高效折中方案,也是现代Linux网络编程和设备监听的基础。
2 什么是IO多路复用?
在实际开发中,程序往往需要同时监听多个文件描述符 ,例如键盘输入、触摸屏设备、串口数据以及网络socket等。如果对每个文件描述符依次调用阻塞式read(),程序只能阻塞在其中一个设备上,无法做到并发监听;如果采用非阻塞方式轮询多个read(),则会导致CPU空转、效率低下,代码结构也不够优雅。
为了解决这个问题,Linux 提出了 IO 多路复用(I/O Multiplexing)机制。其核心思想是:
使用一个线程同时监听多个文件描述符,当其中任意一个文件描述符就绪(可读、可写或发生异常)时,由内核统一通知应用程序,然后再进行实际的读写操作。这样既避免了阻塞等待单一设备的问题,也避免了无意义的轮询,提高了程序效率。
| 接口 | 特点 |
| select | 最早的,多平台支持 |
| poll | 改进版select |
| epoll | 高性能服务器使用 |
|---|
在Linux中,常见的IO多路复用机制有三种:select、poll和epoll。其中,select是最早出现、兼容性最好的方式;poll是对select的改进版本;而epoll则主要用于高并发服务器场景。本节将重点介绍select和poll的基本原理与使用方法。
3 poll工作原理
poll是Linux提供的一种IO多路复用机制,用于同时监听多个文件描述符的状态变化。
函数原型:
cs
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
fds:需要监听的文件描述符数组nfds:数组中元素的个数timeout:超时时间(单位:毫秒)-1:一直等待0:立即返回(非阻塞)>0:等待指定毫秒数
返回值:
>0:有文件描述符准备好=0:超时<0:发生错误
3.1 poll的核心结构
cs
struct pollfd {
int fd; // 监听的文件描述符
short events; // 关心的事件
short revents; // 实际发生的事件(由内核填写)
};
使用时,应用层通过events告诉内核自己关心什么事件,内核在返回时把实际发生的事件写入revents。
例如监听输入设备时:
cs
fds[0].events = POLLIN;
表示:监听该文件描述符是否有数据可读。
常见事件类型包括:
POLLIN: 有数据可读POLLOUT:可以写数据POLLERR:发生错误POLLHUP:设备断开
当poll()返回后,如果:
cs
fds[0].revents & POLLIN
成立,就说明该设备已经准备好,可以安全调用read()读取数据。
3.2 poll读取流程
poll的工作过程可以理解为:
bash
应用层调用 poll()
↓
内核遍历检查所有 fd 的状态
↓
如果没有任何 fd 就绪 → 进程进入睡眠
如果有 fd 就绪 → 立即返回
↓
应用层再调用 read() 读取数据
这是一种:"先通知,再读写"的事件驱动模型。 与非阻塞轮询相比,poll不会让CPU空转;与阻塞read()相比,它又能同时监听多个文件描述符。因此在需要监听多个输入设备(如键盘、触摸屏、串口、socket)时,poll是一种++结构清晰、效率较高++的解决方案。
4 select工作原理
select是Linux系统中经典的I/O多路复用函数,它的底层工作逻辑可以概括为:
- 程序向内核传递需要监听的fd集合(读/写/异常)、最大fd值和超时时间;
- 内核阻塞等待,直到:
- 至少一个监听的fd触发了对应事件(可读/可写/异常);
- 超时时间到达(即使没有事件触发);
- 被信号中断。
- 内核修改fd集合,只保留触发事件的fd,然后返回给用户态;
- 程序遍历修改后的fd集合,判断哪些fd触发了事件,再做对应处理。
函数原型:
cs
int select(int maxfd, fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
| 参数 | 含义 |
|---|---|
maxfd |
监听的最大文件描述符值+1(内核会遍历0~maxfd-1的所有fd,需准确设置以减少遍历开销) |
readfds |
待监听可读事件的fd集合(NULL表示不监听) 可读场景:fd有数据可读、连接建立成功(如 socket accept)、对方关闭连接 |
writefds |
待监听可写事件的fd集合(NULL 表示不监听) 可写场景:fd缓冲区有空闲空间可写数据 |
exceptfds |
待监听异常事件的fd集合(NULL 表示不监听) 异常场景:fd发生带外数据(OOB)等 |
timeout |
超时时间(struct timeval类型),控制select阻塞行为: -NULL:永久阻塞,直到有事件触发; -时间值为0:非阻塞,立即返回; - 时间值 > 0:阻塞指定时长,超时后返回 0 |
返回值说明:
- 成功:返回触发事件的fd总数(读 + 写 + 异常);
- 0:超时时间到达,无任何fd触发事件;
- -1:出错(如被信号中断、参数错误),同时设置
errno。
4.1 fd_set集合操作宏
fd_set是系统定义的fd集合类型(本质是位图/整数数组),无法直接操作,必须通过以下宏管理:
| 宏 | 作用 |
|---|---|
FD_ZERO(fd_set *set) |
清空fd集合(初始化,必须先调用,否则集合内数据随机) |
FD_SET(int fd, fd_set *set) |
将指定fd加入集合(标记需要监听的fd) |
FD_CLR(int fd, fd_set *set) |
将指定fd从集合中移除 |
FD_ISSET(int fd, fd_set *set) |
检查fd是否在集合中(select 返回后,判断该fd是否触发事件),返回非0 表示触发 |
关键注意点: select返回后,readfds/writefds/exceptfds会被内核修改------只保留触发事件的fd ,因此每次调用select前,都需要重新初始化并设置fd集合(否则会丢失需要监听的fd)。
4.2 select 的局限性
- fd 数量限制 :
fd_set的大小由系统宏FD_SETSIZE定义(通常是1024),无法监听超过1024个 fd; - 效率低:每次调用select,内核需要遍历0~maxfd-1的所有fd,fd越多遍历越慢;
- 重复拷贝:每次调用select,都需要把fd集合从用户态拷贝到内核态,开销大;
- 需要重新设置集合:select返回后集合被修改,每次调用前必须重新初始化,代码冗余。
5 poll与select的区别
| 对比项 | select | poll |
|---|---|---|
| 文件数量限制 | 1024 | 无固定限制 |
| 参数形式 | 位图 | 数组 |
| 效率 | 较低 | 更好 |
| 可移植性 | 好 | 好 |
在嵌入式开发中:
推荐使用 poll
6 为什么要配合O_NONBLOCK?
在多路复用模式下:
cs
fd = open(dev, O_RDWR | O_NONBLOCK);
原因:
- poll/select只负责"通知"
- 真正读取数据还是read
- 如果read阻塞会影响逻辑
因此通常:
多路复用 + 非阻塞模式
7 完整流程
输入设备产生事件
↓
内核缓存事件
↓
poll/select 被唤醒
↓
应用层 read 读取所有数据
8 poll方式读取输入数据完整代码
cs
#include <linux/input.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <poll.h>
/*
* 使用方式:
* ./03_input_read_poll /dev/input/event0
*
* 功能:
* 使用 poll 方式读取输入设备数据
*/
int main(int argc, char **argv)
{
int fd;
int err;
int len;
int ret;
struct input_id id;
unsigned int evbit[2];
struct input_event event;
struct pollfd fds[1]; // 监听一个文件
nfds_t nfds = 1; // 文件数量
if(argc != 2)
{
printf("Usage: %s <dev>\n", argv[0]);
return -1;
}
// 非阻塞打开
fd = open(argv[1], O_RDWR | O_NONBLOCK);
if(fd < 0)
{
printf("open error\n");
return -1;
}
// 查询设备ID
err = ioctl(fd, EVIOCGID, &id);
if(err == 0)
{
printf("bustype = 0x%x\n", id.bustype);
printf("vendor = 0x%x\n", id.vendor);
printf("product = 0x%x\n", id.product);
printf("version = 0x%x\n", id.version);
}
while(1)
{
// 设置监听对象
fds[0].fd = fd;
fds[0].events = POLLIN; // 监听可读
fds[0].revents = 0;
// 等待事件,超时50秒
ret = poll(fds, nfds, 50000);
if(ret > 0)
{
if(fds[0].revents & POLLIN)
{
// 读取所有事件
while(read(fd, &event, sizeof(event)) == sizeof(event))
{
printf("type=0x%x code=0x%x value=0x%x\n",
event.type, event.code, event.value);
}
}
}
else if(ret == 0)
{
printf("time out\n");
}
else
{
printf("poll error\n");
}
}
return 0;
}
9 select方式读取输入数据完整代码
cs
#include <linux/input.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/time.h>
/*
* 使用方式:
* ./04_input_read_select /dev/input/event0
*
* 功能:
* 使用 select 方式读取输入设备数据
*/
int main(int argc, char **argv)
{
int fd;
int retval;
struct input_event event;
fd_set rfds;
struct timeval tv;
if(argc != 2)
{
printf("Usage: %s <dev>\n", argv[0]);
return -1;
}
// 非阻塞打开
fd = open(argv[1], O_RDWR | O_NONBLOCK);
if(fd < 0)
{
printf("open error\n");
return -1;
}
while(1)
{
FD_ZERO(&rfds); // 清空集合
FD_SET(fd, &rfds); // 添加监听fd
tv.tv_sec = 5; // 5秒超时
tv.tv_usec = 0;
// 调用select
retval = select(fd+1, &rfds, NULL, NULL, &tv);
if(retval > 0)
{
while(read(fd, &event, sizeof(event)) == sizeof(event))
{
printf("type=0x%x code=0x%x value=0x%x\n",
event.type, event.code, event.value);
}
}
else if(retval == 0)
{
printf("time out\n");
}
else
{
printf("select error\n");
}
}
return 0;
}