用 POSIX Signal 唤醒 epoll 事件循环:原理、逐行解析与实践
本文面向已经掌握 C 语言、线程和文件描述符基础,但对 POSIX Signal 与事件轮询协作机制还不熟悉的开发者。
核心结论:这里的
signal不是业务数据,也不是 I/O 事件本身,而是一个“唤醒通知”。它用于中断阻塞中的epoll_pwait(),让轮询线程及时重新检查停止标志、任务队列或监听集合。
@[toc]
1. 先建立整体认识
一个事件轮询线程通常会长时间阻塞:
1 | n = epoll_wait(epfd, events, maxevents, -1); |
最后一个参数为 -1 时,线程会一直等待,直到某个文件描述符就绪。问题是,另一个线程可能需要它立即醒来,例如:
- 请求事件循环停止;
- 投递了新的异步任务;
- 修改了共享状态;
- 要求重新计算下一次超时时间;
- 调整了监听集合,但当前没有就绪事件。
因此,事件循环需要一条独立于普通 I/O 的“敲门通道”。这段实现选择 POSIX Signal 作为敲门机制。
1 | 控制线程 轮询线程 |
Signal 只表示“有事情需要重新检查”,真正的数据应放在共享状态、受保护的任务队列或无锁队列中。
2. Signal 到底是什么
POSIX Signal 是操作系统向进程或线程投递的异步通知。一个信号包含的核心信息是信号编号,例如 SIGUSR1。收到信号后,程序可以:
- 执行默认动作;
- 忽略该信号;
- 调用应用安装的处理函数。
需要区分两个概念:
| 概念 | 作用域 | 作用 |
|---|---|---|
| 信号处置方式(disposition) | 进程级 | 决定信号到达后是默认处理、忽略,还是调用处理函数 |
| 信号屏蔽字(signal mask) | 线程级 | 决定当前线程暂时阻塞哪些信号 |
因此,多线程程序中所有线程共享同一个 sigaction() 配置,但每个线程拥有独立的信号屏蔽字。
2.1 阻塞不等于丢弃
信号被阻塞时不会立即执行处理函数,而是进入 pending 状态。以后解除阻塞时,pending 信号才会被递送。
标准信号通常不排队。同一种标准信号在阻塞期间连续到达多次,通常只保留一个 pending 标记。因此,Signal 适合表达“需要唤醒”,不适合表达“累计发生了 N 次任务”。
2.2 为什么默认选择 SIGUSR1
SIGUSR1 和 SIGUSR2 是留给应用自定义用途的标准信号。调用者传入 0 时选择 SIGUSR1,只是提供一个默认值。
但 SIGUSR1 的默认动作是终止进程,所以必须先安装处理函数,再允许其他线程发送该信号。如果应用的其他模块已经占用 SIGUSR1,应传入一个没有冲突的信号编号。
3. 这套唤醒机制如何工作
完整机制分成五步:
- 使用
sigaction()安装一个空处理函数; - 在线程正常执行期间阻塞唤醒信号;
- 调用
epoll_pwait()时,在保留其他屏蔽状态的前提下临时解除唤醒信号; - 其他线程使用
pthread_kill()定向发送信号; epoll_pwait()被信号处理函数中断,以EINTR返回。
可以把它类比为软件中断:
1 | signo 软件中断号 |
4. 为什么安装“空处理函数”,而不是直接忽略
典型处理函数如下:
1 | static void |
这个函数不处理业务,只负责让信号完成一次实际递送。
它与下面的写法不是同一回事:
1 | act.sa_handler = SIG_IGN; |
SIG_IGN 表示让内核忽略信号,而空处理函数表示捕获信号、进入处理函数,然后立即返回。这里需要后者,因为唤醒依赖“等待调用被信号处理函数中断”这一行为。
空处理函数还符合信号处理函数的安全原则:不加锁、不分配内存、不执行格式化输出,也不访问复杂共享对象。
5. 为什么要先阻塞,再在等待期间解除阻塞
初看可能会觉得:既然希望 Signal 唤醒线程,直接保持信号不阻塞即可。这样做会引入时序问题。
5.1 不阻塞时的问题
Signal 可能在轮询线程执行普通代码时到达:
1 | 检查任务队列为空 |
通知已经消费,但线程随后才进入休眠,结果可能一直睡下去。这就是典型的“丢失唤醒”竞态。
5.2 epoll_pwait() 的原子屏蔽字切换
epoll_pwait() 可以在进入等待的同时,原子地把线程信号屏蔽字替换为指定集合;函数返回后,再恢复原屏蔽字。
下面的调用:
1 | n = epoll_pwait(epfd, events, maxevents, timeout, &wait_mask); |
在语义上近似于:
1 | pthread_sigmask(SIG_SETMASK, &wait_mask, &old_mask); |
这里有一个容易忽略的关键点:wait_mask 是等待期间的完整信号屏蔽字,不是“需要额外解除的信号集合”。如果直接执行:
1 | sigemptyset(&wait_mask); |
那么 epoll_pwait() 等待期间会临时解除所有可屏蔽信号,而不只是专用唤醒信号。只有在程序明确希望等待期间开放全部信号时,才应传入空集合。
更稳妥的通用写法是复制当前线程屏蔽字,再只删除专用唤醒信号:
1 | sigset_t wait_mask; |
这样,轮询线程原本阻塞的其他信号在等待期间仍保持阻塞,只有 wake_signo 被临时开放。
关键区别是 epoll_pwait() 将“替换屏蔽字”和“开始等待”作为一个原子操作完成,中间没有可被另一个线程插入的竞态窗口。
如果唤醒信号在进入等待前已经到达,由于它当时被阻塞,会保持 pending。epoll_pwait() 原子开放该信号后,pending 信号会立即递送,等待调用随即返回。
6. io_poll_init() 逐段解析
下面按照初始化顺序解释代码。为保持内容独立,条件编译宏和辅助接口仅按其通用职责描述。
6.1 检查必需参数
1 | assert(poll); |
assert() 用于捕获程序设计错误:调用者必须提供有效的轮询对象和上下文。
它通常只适合调试期契约检查。定义 NDEBUG 后,断言可能被编译器移除,因此它不能替代面向外部输入的运行时校验。
6.2 选择默认唤醒信号
1 | if (!signo) |
含义是:
- 调用者传入非零值:使用调用者指定的信号;
- 调用者传入
0:使用默认的SIGUSR1。
随后保存到对象中:
1 | poll->signo = signo; |
以后执行唤醒操作时,需要读取这个编号并发送给目标线程。
6.3 保存首个失败原因
1 | int errsv = 0; |
初始化过程包含多个系统调用和线程库调用。失败后还要执行资源回滚,而回滚函数可能改变 errno 或返回新的错误。
因此代码先保存真正的失败原因,完成清理后再恢复:
1 | errno = errsv; |
6.4 绑定服务接口、上下文和轮询接口
1 | poll->svc = (struct io_svc)IO_SVC_INIT(&io_poll_svc_vtbl); |
从命名和常见对象模型推断,这三行分别用于:
- 初始化服务基类,并绑定服务操作表;
- 记录对象所属的运行上下文;
- 绑定轮询接口的虚函数表。
这部分属于代码结构层面的推断。精确职责需要结合 IO_SVC_INIT、两个 vtbl 定义以及上下文接口确认。
6.5 构造 sigaction 配置
原始逻辑等价于:
1 | struct sigaction act; |
更稳妥的通用写法是先清零:
1 | struct sigaction act = { 0 }; |
各成员作用如下。
act.sa_handler
1 | act.sa_handler = &wake_signal_handler; |
指定单参数信号处理函数。Signal 到达目标线程时,内核安排该线程进入这个函数。
建议避免使用 sig_ign 之类容易与 SIG_IGN 混淆的名称。wake_signal_handler 更准确地表达“捕获但不执行工作”。
sigemptyset(&act.sa_mask)
1 | sigemptyset(&act.sa_mask); |
清空“处理函数执行期间需要额外阻塞的信号集合”。
即使该集合为空,当前正在处理的信号通常仍会在处理函数执行期间自动阻塞,除非启用 SA_NODEFER。
act.sa_flags = 0
1 | act.sa_flags = 0; |
不启用额外选项,例如 SA_SIGINFO、SA_NODEFER 或 SA_RESTART。
在 Linux 上,epoll_wait() 和 epoll_pwait() 被信号处理函数中断后不会因为 SA_RESTART 自动重启,而是以 EINTR 返回。因此,这条唤醒路径应显式处理 errno == EINTR。
6.6 安装处理函数并保存旧配置
1 | if (sigaction(poll->signo, &act, &poll->oact) == -1) { |
参数含义:
1 | sigaction( |
成功后,poll->oact 保存初始化之前的信号配置,销毁对象或后续初始化失败时可以恢复。
必须注意:信号处置方式是进程级资源。一个线程安装处理函数,会影响整个进程对该信号的处理方式。
6.7 构造只包含唤醒信号的集合
1 | sigset_t set; |
执行完成后:
1 | set = { poll->signo } |
各调用作用:
| 调用 | 作用 |
|---|---|
sigset_t set |
定义一个信号集合对象 |
sigemptyset(&set) |
清空集合 |
sigaddset(&set, signo) |
将指定信号加入集合 |
6.8 在线程正常运行期间阻塞唤醒信号
多线程程序使用:
1 | errsv = pthread_sigmask(SIG_BLOCK, &set, &poll->oset); |
单线程程序可以使用:
1 | if (sigprocmask(SIG_BLOCK, &set, &poll->oset) == -1) |
SIG_BLOCK 表示把 set 中的信号加入当前线程的屏蔽字。poll->oset 保存修改前的完整屏蔽字,便于以后恢复。
两组接口的错误模型不同:
| 接口 | 成功 | 失败 |
|---|---|---|
sigprocmask() |
返回 0 |
返回 -1,错误写入 errno |
pthread_sigmask() |
返回 0 |
直接返回错误码 |
因此不能统一写成读取 errno。
新线程会继承创建者当时的信号屏蔽字。实际工程中应明确:哪个线程执行初始化、哪个线程进入轮询、轮询线程何时被创建。否则可能出现“处理函数已经安装,但目标线程并未阻塞该信号”的配置偏差。
6.9 将 epoll 文件描述符初始化为无效值
1 | poll->epfd = -1; |
-1 表示当前没有有效 epoll 实例。这样,关闭函数可以安全判断资源是否已经创建:
1 | if (poll->epfd == -1) |
6.10 初始化互斥锁
1 | errsv = pthread_mutex_init(&poll->mtx, NULL); |
NULL 表示使用默认互斥锁属性。
结合结构成员推断,该锁通常用于保护监听树、监听计数以及轮询和控制路径之间的共享状态。精确保护范围仍应以所有加锁点为准,而不能只根据成员位置判断。
与其他线程函数一样,pthread_mutex_init() 失败时直接返回错误码。
6.11 初始化监听项索引和计数
1 | rbtree_init(&poll->tree, &io_fd_cmp); |
从命名推断:
tree是按文件描述符或监听对象排序的红黑树;io_fd_cmp是键比较函数;nwatch记录当前监听项数量。
红黑树提供对数复杂度的插入、删除和查找,适合管理动态变化的监听对象。
6.12 创建 epoll 实例
1 | if (io_poll_open(poll) == -1) { |
典型实现为:
1 | static int |
这里完成三件事:
- 关闭可能残留的旧实例;
- 创建新的 epoll 内核对象;
- 把返回的文件描述符保存到
poll->epfd。
EPOLL_CLOEXEC 会为新文件描述符设置 close-on-exec 标志,防止执行新程序映像时意外继承该描述符。
6.13 注册到运行上下文
1 | io_ctx_insert(poll->ctx, &poll->svc); |
从接口命名推断,这一步把轮询服务注册到上下文的服务集合中,以便上下文统一管理生命周期、停止流程或进程派生后的资源恢复。
精确行为需要查看 io_ctx_insert() 和服务操作表定义。
6.14 成功返回
1 | return poll; |
返回初始化完成的对象。此时至少已经完成:
- 信号处理函数安装;
- 当前线程信号屏蔽字调整;
- 互斥锁初始化;
- 监听索引初始化;
- epoll 实例创建;
- 上下文注册。
7. 错误回滚为什么使用 goto
初始化函数按顺序获得多个资源,失败后必须按相反顺序释放:
1 | 初始化:处理函数 → 屏蔽字 → 互斥锁 → epoll → 上下文注册 |
对应代码:
1 | error_open: |
这种 goto 不是任意跳转,而是 C 语言中常见的单出口资源回滚模式。每个标签只释放此前已经成功获得的资源,避免重复清理和多层嵌套。
7.1 恢复原信号屏蔽字
1 | pthread_sigmask(SIG_SETMASK, &poll->oset, NULL); |
SIG_SETMASK 不是只解除一个信号,而是把当前线程的整个屏蔽字恢复为初始化前保存的值。
7.2 恢复原信号处置方式
1 | sigaction(poll->signo, &poll->oact, NULL); |
把进程对该信号的处理方式恢复为初始化之前的状态。
7.3 恢复真正的错误码
1 | errno = errsv; |
确保调用者观察到初始化失败原因,而不是某个清理操作产生的次级状态。
8. 唤醒函数应如何设计
初始化只建立信号机制,真正唤醒通常还需要一个类似函数:
1 | int |
更完整的控制路径应先更新共享状态,再发送信号:
1 | pthread_mutex_lock(&poll->mtx); |
顺序很重要:
1 | 先发布状态,再发送唤醒通知 |
如果先发送 Signal,轮询线程可能先醒来、检查到旧状态,然后再次进入等待。
若共享状态使用 C11 原子变量,则应根据数据依赖选择适当的 release/acquire 内存序;Signal 本身不应被当作完整的跨线程内存同步协议。
9. 轮询函数应如何处理 EINTR
事件等待代码不应把所有 -1 都视为致命错误:
1 | int n = epoll_pwait(epfd, events, maxevents, timeout, &wait_mask); |
一个常见循环如下:
1 | sigset_t wait_mask; |
EINTR 在这里是正常控制流,不是异常故障。
11. 常见误区与风险
12.1 把 Signal 当作任务队列
标准信号不会为每次投递保留一个独立计数。连续发送多次同类信号可能合并成一次 pending 状态。
正确设计是:
1 | 任务队列保存任务 |
11.2 在处理函数中加锁或打印
异步 Signal 可能打断线程正在执行的任意代码。如果处理函数再次获取同一把锁,可能死锁;调用非异步信号安全函数,也可能破坏库内部状态。
本场景的处理函数保持为空,是刻意的安全设计。
11.3 忽略处理函数的进程级影响
sigaction() 改变整个进程的信号处置方式。如果多个模块独立占用同一个信号,后初始化者会覆盖前一个配置。
多个轮询对象还可能产生恢复顺序问题:对象 A、B 依次保存旧配置,若销毁顺序不是严格逆序,恢复结果可能不符合预期。
应由进程级信号管理模块统一分配和维护专用信号,或改用文件描述符型唤醒机制。
11.4 在错误线程上修改屏蔽字
pthread_sigmask() 只修改调用线程。初始化线程、轮询线程和创建线程之间的关系必须清晰。
常用做法是:
- 在创建工作线程前,由主线程阻塞专用信号;
- 工作线程继承该屏蔽字;
- 只有在
epoll_pwait()的等待窗口内临时解除专用唤醒信号,同时保留其他信号的屏蔽状态。
11.5 误认为 pthread_kill() 会终止线程
函数名中的 kill 容易误导。它的作用是向同一进程中的指定 POSIX 线程发送一个信号。最终行为由该信号的进程级处置方式决定。
在本机制中,已安装空处理函数,所以目标线程只是被中断和唤醒。
11.6 使用已经结束生命周期的线程标识
向已经退出的线程发送信号属于高风险行为。必须保证目标 pthread_t 仍然有效,并在退出、销毁和唤醒之间建立明确的生命周期同步。
12. 什么时候考虑其他唤醒机制
Linux 事件循环还常用以下机制:
| 机制 | 优点 | 局限 | 适用场景 |
|---|---|---|---|
Signal + epoll_pwait() |
可定向中断指定线程;不需要额外写入监听描述符 | 处置方式为进程级;屏蔽字管理复杂;标准信号不计数 | 已有 Signal 调度模型,或需要定向打断等待 |
eventfd |
本身就是文件描述符;可直接加入 epoll;可累计计数 | Linux 专用;需要读写描述符 | 进程内事件循环唤醒、任务通知 |
| pipe | 可移植性较好;可携带少量字节 | 需要处理容量、非阻塞和排空 | 跨平台自管事件循环 |
signalfd |
把 Signal 转化为可读文件描述符;统一进入 epoll | Linux 专用;仍需管理信号屏蔽 | 需要同步读取信号信息 |
对于新设计,如果没有必须使用异步 Signal 的约束,eventfd 往往更容易封装,因为它不会修改进程级信号处理函数,并且天然属于文件描述符事件模型。
13. 最终理解
这段初始化代码搭建的不是一个“信号业务处理器”,而是事件循环的线程唤醒设施:
1 | 空处理函数 让 Signal 可被实际捕获 |
理解这套机制的关键不是“Signal 处理了什么”,而是:
Signal 什么也不处理,它只负责打断休眠;事件循环醒来后,再根据可靠的共享状态决定下一步动作。
参考资料
以下资料均来自 Linux man-pages 项目,访问日期:2026-07-16。
- signal(7): overview of signals
- sigaction(2): examine and change a signal action
- pthread_sigmask(3): examine and change mask of blocked signals
- sigprocmask(2): examine and change blocked signals
- epoll_wait(2): wait for an I/O event on an epoll file descriptor
- pthread_kill(3): send a signal to a thread
- epoll_create(2): open an epoll file descriptor










