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

成元 / 2026-03-17 / 约 6714 字 / 预计阅读 14 分钟 / — 次浏览 < 教程, 笔记 > • [ 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_sec 和 tv_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 处理。若需监视大量描述符,可考虑使用 poll 或 epoll。

timeval 结构体

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

返回值

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

使用要点

  1. 初始化集合:每次调用 select() 前,必须用 FD_ZERO 和 FD_SET 重新设置感兴趣的描述符(因为 select() 会修改集合)。
  2. nfds 的设定:应设置为三个集合中最大描述符值 +1,而非 FD_SETSIZE。
  3. 信号中断:select() 可能被信号中断返回 -1,并设置 errno 为 EINTR。某些系统可能不会自动重启,需手动处理。
  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;    /* 实际发生的事件掩码(由内核返回) */
};

事件掩码

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

输入事件

掩码 可在 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 未指定)。

注意事项:

实际开发中,最常用的标志是:POLLIN、POLLOUT、POLLPRI、POLLRDHUP、POLLHUP 和 POLLERR。

返回值

返回值 含义
-1 出错,常见错误码 EINTR(被信号中断,不会自动重启)。
0 超时,没有任何文件描述符就绪。
>0 就绪的文件描述符数量。即数组 fds 中 revents 字段非 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_ctl 的 events 中添加 EPOLLET 标志。

使用 ET 的基本框架:

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

注意事项

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_code 与 si_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_EX 和 F_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); /* 删除异步通知 */ 
}