Linux开发学习记录(5):I/O多路复用技术与信号驱动

成元 2026-03-17 { 教程, 笔记 } [ Linux, C, C++, 并发编程 ]

I/O多路复用

介绍

I/O 多路复用允许我们同时检查多个文件描述符,看其中任意一个是否可执行 I/O 操作。其常用于并发编程。

常用的 I/O 多路复用操作有以下三种:

本人认为,小项目默认使用 poll,而在搭建于 Linux 系统的高并发的网络环境下,则推荐使用 epoll。

select

select() 系统调用允许程序监视多个文件描述符,等待其中一个或多个变为“就绪”状态(可读、可写或发生异常)。它在 I/O 多路复用中非常常用。

函数原型

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数说明

参数 描述
nfds 三个文件描述符集合中最大的文件描述符数值 加 1
这帮助内核提高效率,只检查小于该值的描述符。
readfds 指向 fd_set 类型的集合,用于监视这些文件描述符是否可读。
如果集合中有描述符可读,select() 返回。
可设为 NULL 表示不关心可读事件。
writefds 指向 fd_set 类型的集合,用于监视这些文件描述符是否可写。
可设为 NULL
exceptfds 指向 fd_set 类型的集合,用于监视这些文件描述符是否发生异常。
在 Linux 上,异常主要指:
• 伪终端从设备状态改变(信包模式)
• 流式套接字收到带外数据
可设为 NULL
timeout 指向 struct timeval 结构体,指定阻塞的超时时间。
NULL:无限期阻塞。
tv_sectv_usec 均为 0:非阻塞,立即返回(轮询)。
• 其他值:等待的最长时间。

文件描述符集合操作

三种描述符集合是用fd_set结构描述的,它是一个位掩码,通常通过以下宏操作:

// 清空集合
void FD_ZERO(fd_set *set);      
// 将 fd 加入集合
void FD_SET(int fd, fd_set *set); 
// 将 fd 从集合移除
void FD_CLR(int fd, fd_set *set); 
// 测试 fd 是否在集合中(调用 select 后用于判断就绪)
int  FD_ISSET(int fd, fd_set *set); 

注意fd_set 有大小限制,由常量 FD_SETSIZE 决定(Linux 上通常为 1024)。超出此范围的描述符无法通过标准 fd_set 处理。若需监视大量描述符,可考虑使用 pollepoll

timeval 结构体

struct timeval {
    long tv_sec;  // 秒
    long tv_usec; // 微秒
};

返回值

返回值 含义
-1 出错,常见错误码:
EBADF:集合中存在无效(比如未打开)的文件描述符。
EINTR:被信号处理例程中断(不会自动重启)。
0 超时,没有任何文件描述符就绪。此时所有集合将被清空。
>0 就绪的文件描述符总数。注意:如果一个描述符同时在多个集合中就绪,会被重复计数。之后应使用 FD_ISSET 遍历各集合,确定哪些描述符可操作。

使用要点

  1. 初始化集合:每次调用 select() 前,必须用 FD_ZEROFD_SET 重新设置感兴趣的描述符(因为 select() 会修改集合)。
  2. nfds 的设定:应设置为三个集合中最大描述符值 +1,而非 FD_SETSIZE
  3. 信号中断select() 可能被信号中断返回 -1,并设置 errnoEINTR。某些系统可能不会自动重启,需手动处理。
  4. 可移植性
    • 不同 UNIX 实现可能不修改 timeout 结构体,因此可移植代码不应依赖 select() 返回后 timeout 的值,并在每次使用select前将该结构体初始化
    • 在 Linux 上,若通过 personality() 设置了 STICKY_TIMEOUTS 位,select() 将不修改 timeout

poll

poll() 是另一种 I/O 多路复用系统调用,功能与 select() 类似,但在接口设计和文件描述符数量限制上有所改进。

函数原型

#include <poll.h>

int poll(struct pollfd[] fds, nfds_t nfds, int timeout);

参数

参数 描述
fds 指向 struct pollfd 数组的指针,每个元素描述一个要监视的文件描述符及其感兴趣的事件。
nfds 数组 fds 中的元素个数(即要监视的文件描述符数量)。
类型 nfds_t 通常为无符号整型。
timeout 超时时间,单位为毫秒(ms)。
-1:阻塞直到有文件描述符就绪或捕获到信号。
0:不阻塞,立即返回(轮询)。
>0:最多阻塞 timeout 毫秒。
pollfd 结构体
struct pollfd {
    int   fd;         /* 文件描述符,若为负值则忽略 events,revents 返回 0 */
    short events;     /* 感兴趣的事件掩码(由调用者设置) */
    short revents;    /* 实际发生的事件掩码(由内核返回) */
};

事件掩码

下表列出了 eventsrevents 中可能出现的位掩码。其中:

输入事件

掩码 可在 events 中设置 返回在 revents 描述
POLLIN 可读取普通数据(非高优先级)。
POLLRDNORM 等同于 POLLIN(Linux 中)。
POLLRDBAND 可读取优先级数据(Linux 中不使用)。
POLLPRI 可读取高优先级数据(如带外数据)。
POLLRDHUP 对端套接字关闭(Linux 专有,需定义 _GNU_SOURCE)。

输出事件

掩码 可在 events 中设置 返回在 revents 描述
POLLOUT 普通数据可写。
POLLWRNORM 等同于 POLLOUT(Linux 中)。
POLLWRBAND 优先级数据可写入(Linux 中不提供有效信息)。

返回信息

掩码 可在 events 中设置 返回在 revents 描述
POLLERR 发生错误(如设备错误)。
POLLHUP 出现挂断(如连接断开)。
POLLNVAL 文件描述符未打开(无效)。

其余信息

掩码 可在 events 中设置 返回在 revents 描述
POLLMSG Linux 中不使用(SUSv3 未指定)。

注意事项:

实际开发中,最常用的标志是:POLLINPOLLOUTPOLLPRIPOLLRDHUPPOLLHUPPOLLERR

返回值

返回值 含义
-1 出错,常见错误码 EINTR(被信号中断,不会自动重启)。
0 超时,没有任何文件描述符就绪。
>0 就绪的文件描述符数量。即数组 fdsrevents 字段非 0 的 pollfd 元素个数。注意:与 select() 不同,poll() 对同一个描述符即使 revents 中有多个事件位,也只计一次。

select() 的主要区别

epoll

介绍

epoll(event poll)是 Linux 特有的 I/O 事件通知机制,自内核 2.6 起引入。它用于监视多个文件描述符上的 I/O 就绪状态,克服了传统 select()poll() 在大量文件描述符下的性能瓶颈。

主要优点
核心数据结构

每个 epoll 实例在内核中对应一个打开的文件描述,并维护两个列表:

epoll 系统调用

创建 epoll 实例
#include <sys/epoll.h>
int epoll_create(int size);

Linux 2.6.27+ 新增 epoll_create1()

int epoll_create1(int flags);
修改兴趣列表
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev);
struct 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;
等待事件
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);

每个返回的 epoll_event 中:

事件掩码
掩码 输入 输出 描述
EPOLLIN 可读(普通数据)
EPOLLPRI 高优先级数据可读
EPOLLRDHUP 套接字对端关闭(Linux 2.6.17+)
EPOLLOUT 可写
EPOLLET 启用边缘触发
EPOLLONESHOT 单次通知后禁用
EPOLLERR 错误发生(自动监视)
EPOLLHUP 挂断(自动监视)

深入 epoll 语义(兴趣列表到底关联什么)

int fd1, fd2;
fd1 = open(path, O_RDONLY);
fd2 = dup(fd1);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev);
close(fd1);                // fd1 关闭,但打开文件描述仍被 fd2 引用
// 之后 epoll_wait() 仍可能通知 fd1(实际是同一个打开文件描述)

epoll 边缘触发

启用 ET:在 epoll_ctlevents 中添加 EPOLLET 标志。

使用 ET 的基本框架:

  1. 将所有监视的描述符设为非阻塞
  2. 循环处理:
    • 调用 epoll_wait 获取就绪列表。
    • 对每个就绪描述符,循环执行 I/O(如 read/write)直到返回 EAGAINEWOULDBLOCK

注意事项

I/O 信号驱动

介绍

信号驱动 I/O 是一种 I/O 事件通知机制:进程请求内核在文件描述符可执行 I/O 时发送信号,之后进程可执行其他任务,直到收到信号后再处理 I/O。这种方式与 select()/poll() 的主动检查不同,属于被动通知。

使用步骤

  1. 安装信号处理例程 为内核发送的通知信号安装一个信号处理例程。默认情况下,这个通知信号为 SIGIO。
  2. 设定文件描述符属主
    使用 fcntl(fd, F_SETOWN, pid) 指定接收信号的进程或进程组。 通常 pid 设为调用进程的 PID。
    fcntl(fd,F_SETOWN, pid);
    
  3. 启用非阻塞 I/O 通过设定 O_NONBLOCK 标志使能非阻塞 I/O。
  4. 启用信号驱动 I/O 通过打开 O_ASYNC 标志使能信号驱动 I/O。 这可以和上一步合并为一个操作,因为它们都需要用到 fcntl()的 F_SETFL 操作。
    flags = fcntl( fd, F_GETFL);/* Get current flags */
    fcntl( fd, F_SETFL, flags | O_ASYNC | O_NONBLOCK);
    
  5. 进程继续执行其他任务 调用进程现在可以执行其他的任务了。 当 I/O 操作就绪时,内核为进程发送一个信号,然后调用在第 1 步中安装好的信号处理例程。
  6. 在信号处理例程中执行 I/O 信号驱动 I/O 提供边缘触发通知,因此应尽可能多地执行 I/O(例如循环读/写)。 假设文件描述符是非阻塞式的,这表示需要在循环中执行 I/O 系统调用直到失败为止,此时错误码为 EAGAIN 或 EWOULDBLOCK。

何时发送“I/O 就绪”信号

文件类型 发送信号的条件
终端/伪终端 有新输入时(即使之前数据未读完);终端上出现文件结尾(EOF)时;伪终端从设备写就绪(2.4.19+)
管道/FIFO 读端:数据写入管道;写端关闭
写端:读操作增加空闲空间(可写入 PIPE_BUF 字节);读端关闭
套接字 数据报:新数据报到达;异步错误
流式:监听套接字新连接;connect() 完成;新输入;对端关闭;输出就绪;异步错误
inotify 监视的文件上有事件发生,文件描述符变为可读

优化信号驱动 I/O

信号驱动 I/O 的性能优势在于事件数量扩展而非文件描述符数量扩展。要充分利用,需执行:

  1. 使用实时信号代替 SIGIO
    通过 fcntl(fd, F_SETSIG, signum) 指定一个实时信号(如 SIGRTMIN)作为通知信号,从而支持信号排队,避免 SIGIO 的丢失问题。(需定义 _GNU_SOURCE
  2. 安装带 SA_SIGINFO 标志的信号处理例程
    struct sigaction sa;
    sa.sa_flags = SA_SIGINFO;
    sa.sa_sigaction = handler;
    sigaction(signum, &sa, NULL);
    

这样处理函数可以接收 siginfo_t 结构体,其中包含事件发生的文件描述符和事件类型。

siginfo_t 中的关键字段

si_codesi_band 的对应关系
si_code si_band 掩码 描述
POLL_IN POLLIN | POLLRDNORM 有输入或文件结尾
POLL_OUT POLLOUT | POLLWRNORM | POLLWRBAND 可输出
POLL_MSG POLLIN | POLLRDNORM | POLLMSG 有输出消息(未使用)
POLL_ERR POLLERR I/O 错误
POLL_PRI POLLPRI | POLLRDNORM 高优先级输入
POLL_HUP POLLHUP | POLLERR 挂断

信号队列溢出处理

实时信号队列有上限(可通过 /proc/sys/kernel/rtsig-max 等调整)。若队列溢出,内核将回退到发送标准 SIGIO(不排队,不带 siginfo_t)。程序应同时为 SIGIO 安装处理例程,并在收到 SIGIO 时切换到 select()/poll() 以获取剩余事件。

同步接收信号(避免信号处理例程)

可阻塞实时信号,然后使用 sigwaitinfo()sigtimedwait() 接收排队的信号,直接获得 siginfo_t 结构体。这种方式将事件处理变为同步,但效率仍高于 select()/poll()

多线程程序中的信号驱动 I/O

Linux 2.6.32 引入了 fcntl()F_SETOWN_EXF_GETOWN_EX 操作,允许指定接收信号的线程(而非仅限于进程或进程组)。

struct f_owner_ex {
    int   type;   /* 类型:F_OWNER_PID, F_OWNER_PGRP, F_OWNER_TID */
    pid_t pid;    /* 进程ID、进程组ID 或 线程ID */
};

注意事项

设备驱动的支持

为了支持I/O多路复用与信号驱动,设备驱动代码中要有对应的支持

多路复用

当应用程序调用 select 或 poll 函数来对驱动程序进行非阻塞访问的时候,驱动程序file_operations 结构体中的 poll 函数就会执行。所以驱动程序的编写者需要提供对应的 poll 函数,poll函数原型如下所示:

unsigned int (*poll) (struct file *filp, struct poll_table_struct *wait);

函数参数和返回值含义如下:

我们需要在驱动程序的 poll 函数中调用 poll_wait 函数,poll_wait 函数不会引起阻塞,只是将应用程序添加到 poll_table 中,poll_wait 函数原型如下:

void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p);

参数如下:

模板如下:

static unsigned int xxx_poll(struct file *filp, struct poll_table_struct *wait)
{
	unsigned int mask = 0;
	struct xxx_dev *dev = (struct xxx_dev *)filp->private_data;
	
	/* 告诉内核,如果返回的 mask 表示没有准备好,那么就将进程加入 dev->r_wait 所指向的等待列表进行等待 */
	poll_wait(filp, &dev->r_wait, wait); /* 将等待队列头添加到poll_table中 */
	
	/* 这里假设有一个按键的读取逻辑 */*
	if(atomic_read(&dev->releasekey)) { /* 按键按下 */
		mask = POLLIN | POLLRDNORM; /* 返回PLLIN */
	}
	
	return mask;
}

事件驱动

要求在驱动代码的 file_operations 结构体中提供对 fasync 函数的相关支持

模板如下

struct xxx_dev {  
	...... 
	struct fasync_struct *async_queue;  /* 异步相关结构体 */  
}; 
  
static int xxx_fasync(int fd, struct file *filp, int on) 
{ 
	struct xxx_dev *dev = (xxx_dev)filp->private_data; 

	if (fasync_helper(fd, filp, on, &dev->async_queue) < 0) 
	    return -EIO; 
	return 0; 
} 
 
static struct file_operations xxx_ops = { 
	...... 
	 .fasync = xxx_fasync, 
	...... 
};

当应用程序通过 fcntl(fd, F_SETFL, flags | FASYNC) 改变 fasync 标记的时候,驱动程序 file_operations 操作集中的 fasync 函数就会执行

此外,在驱动代码的 file_operations 结构体中的 release 函数中要释放资源

static int xxx_release(struct inode *inode, struct file *filp) 
{ 
	return xxx_fasync(-1, filp, 0); /* 删除异步通知 */ 
}