用 POSIX Signal 唤醒 epoll 事件循环:原理、逐行解析与实践

在这里插入图片描述

本文面向已经掌握 C 语言、线程和文件描述符基础,但对 POSIX Signal 与事件轮询协作机制还不熟悉的开发者。

核心结论:这里的 signal 不是业务数据,也不是 I/O 事件本身,而是一个“唤醒通知”。它用于中断阻塞中的 epoll_pwait(),让轮询线程及时重新检查停止标志、任务队列或监听集合。

@[toc]

1. 先建立整体认识

一个事件轮询线程通常会长时间阻塞:

1
n = epoll_wait(epfd, events, maxevents, -1);

最后一个参数为 -1 时,线程会一直等待,直到某个文件描述符就绪。问题是,另一个线程可能需要它立即醒来,例如:

  1. 请求事件循环停止;
  2. 投递了新的异步任务;
  3. 修改了共享状态;
  4. 要求重新计算下一次超时时间;
  5. 调整了监听集合,但当前没有就绪事件。

因此,事件循环需要一条独立于普通 I/O 的“敲门通道”。这段实现选择 POSIX Signal 作为敲门机制。

1
2
3
4
5
6
7
8
9
控制线程                                 轮询线程
│ │
│ 更新共享状态或任务队列 │
│ pthread_kill(target, wake_signo) │
├────────────── Signal ─────────────────>│
│ │ 空处理函数返回
│ │ epoll_pwait() 返回 -1
│ │ errno == EINTR
│ │ 重新检查状态并继续处理

Signal 只表示“有事情需要重新检查”,真正的数据应放在共享状态、受保护的任务队列或无锁队列中。

2. Signal 到底是什么

POSIX Signal 是操作系统向进程或线程投递的异步通知。一个信号包含的核心信息是信号编号,例如 SIGUSR1。收到信号后,程序可以:

  • 执行默认动作;
  • 忽略该信号;
  • 调用应用安装的处理函数。

需要区分两个概念:

概念 作用域 作用
信号处置方式(disposition) 进程级 决定信号到达后是默认处理、忽略,还是调用处理函数
信号屏蔽字(signal mask) 线程级 决定当前线程暂时阻塞哪些信号

因此,多线程程序中所有线程共享同一个 sigaction() 配置,但每个线程拥有独立的信号屏蔽字。

2.1 阻塞不等于丢弃

信号被阻塞时不会立即执行处理函数,而是进入 pending 状态。以后解除阻塞时,pending 信号才会被递送。

标准信号通常不排队。同一种标准信号在阻塞期间连续到达多次,通常只保留一个 pending 标记。因此,Signal 适合表达“需要唤醒”,不适合表达“累计发生了 N 次任务”。

2.2 为什么默认选择 SIGUSR1

SIGUSR1SIGUSR2 是留给应用自定义用途的标准信号。调用者传入 0 时选择 SIGUSR1,只是提供一个默认值。

SIGUSR1 的默认动作是终止进程,所以必须先安装处理函数,再允许其他线程发送该信号。如果应用的其他模块已经占用 SIGUSR1,应传入一个没有冲突的信号编号。

3. 这套唤醒机制如何工作

完整机制分成五步:

  1. 使用 sigaction() 安装一个空处理函数;
  2. 在线程正常执行期间阻塞唤醒信号;
  3. 调用 epoll_pwait() 时,在保留其他屏蔽状态的前提下临时解除唤醒信号;
  4. 其他线程使用 pthread_kill() 定向发送信号;
  5. epoll_pwait() 被信号处理函数中断,以 EINTR 返回。

可以把它类比为软件中断:

1
2
3
4
5
6
signo              软件中断号
sigaction() 安装中断入口
pthread_sigmask() 正常阶段关闭该中断
epoll_pwait() 等待阶段原子地仅开放该中断
pthread_kill() 向指定线程触发中断
EINTR 阻塞等待被唤醒后的返回状态

4. 为什么安装“空处理函数”,而不是直接忽略

典型处理函数如下:

1
2
3
4
5
static void
wake_signal_handler(int signo)
{
(void)signo;
}

这个函数不处理业务,只负责让信号完成一次实际递送。

它与下面的写法不是同一回事:

1
act.sa_handler = SIG_IGN;

SIG_IGN 表示让内核忽略信号,而空处理函数表示捕获信号、进入处理函数,然后立即返回。这里需要后者,因为唤醒依赖“等待调用被信号处理函数中断”这一行为。

空处理函数还符合信号处理函数的安全原则:不加锁、不分配内存、不执行格式化输出,也不访问复杂共享对象。

5. 为什么要先阻塞,再在等待期间解除阻塞

初看可能会觉得:既然希望 Signal 唤醒线程,直接保持信号不阻塞即可。这样做会引入时序问题。

5.1 不阻塞时的问题

Signal 可能在轮询线程执行普通代码时到达:

1
2
3
检查任务队列为空
收到 Signal,处理函数执行并返回
进入 epoll_wait() 无限等待

通知已经消费,但线程随后才进入休眠,结果可能一直睡下去。这就是典型的“丢失唤醒”竞态。

5.2 epoll_pwait() 的原子屏蔽字切换

epoll_pwait() 可以在进入等待的同时,原子地把线程信号屏蔽字替换为指定集合;函数返回后,再恢复原屏蔽字。

下面的调用:

1
n = epoll_pwait(epfd, events, maxevents, timeout, &wait_mask);

在语义上近似于:

1
2
3
pthread_sigmask(SIG_SETMASK, &wait_mask, &old_mask);
n = epoll_wait(epfd, events, maxevents, timeout);
pthread_sigmask(SIG_SETMASK, &old_mask, NULL);

这里有一个容易忽略的关键点:wait_mask 是等待期间的完整信号屏蔽字,不是“需要额外解除的信号集合”。如果直接执行:

1
sigemptyset(&wait_mask);

那么 epoll_pwait() 等待期间会临时解除所有可屏蔽信号,而不只是专用唤醒信号。只有在程序明确希望等待期间开放全部信号时,才应传入空集合。

更稳妥的通用写法是复制当前线程屏蔽字,再只删除专用唤醒信号:

1
2
3
4
5
6
7
8
9
sigset_t wait_mask;
int err = pthread_sigmask(SIG_SETMASK, NULL, &wait_mask);
if (err != 0)
return err;

if (sigdelset(&wait_mask, wake_signo) == -1)
return errno;

n = epoll_pwait(epfd, events, maxevents, timeout, &wait_mask);

这样,轮询线程原本阻塞的其他信号在等待期间仍保持阻塞,只有 wake_signo 被临时开放。

关键区别是 epoll_pwait() 将“替换屏蔽字”和“开始等待”作为一个原子操作完成,中间没有可被另一个线程插入的竞态窗口。

如果唤醒信号在进入等待前已经到达,由于它当时被阻塞,会保持 pending。epoll_pwait() 原子开放该信号后,pending 信号会立即递送,等待调用随即返回。

6. io_poll_init() 逐段解析

下面按照初始化顺序解释代码。为保持内容独立,条件编译宏和辅助接口仅按其通用职责描述。

6.1 检查必需参数

1
2
assert(poll);
assert(ctx);

assert() 用于捕获程序设计错误:调用者必须提供有效的轮询对象和上下文。

它通常只适合调试期契约检查。定义 NDEBUG 后,断言可能被编译器移除,因此它不能替代面向外部输入的运行时校验。

6.2 选择默认唤醒信号

1
2
if (!signo)
signo = SIGUSR1;

含义是:

  • 调用者传入非零值:使用调用者指定的信号;
  • 调用者传入 0:使用默认的 SIGUSR1

随后保存到对象中:

1
poll->signo = signo;

以后执行唤醒操作时,需要读取这个编号并发送给目标线程。

6.3 保存首个失败原因

1
int errsv = 0;

初始化过程包含多个系统调用和线程库调用。失败后还要执行资源回滚,而回滚函数可能改变 errno 或返回新的错误。

因此代码先保存真正的失败原因,完成清理后再恢复:

1
2
errno = errsv;
return NULL;

6.4 绑定服务接口、上下文和轮询接口

1
2
3
poll->svc = (struct io_svc)IO_SVC_INIT(&io_poll_svc_vtbl);
poll->ctx = ctx;
poll->poll_vptr = &io_poll_poll_vtbl;

从命名和常见对象模型推断,这三行分别用于:

  • 初始化服务基类,并绑定服务操作表;
  • 记录对象所属的运行上下文;
  • 绑定轮询接口的虚函数表。

这部分属于代码结构层面的推断。精确职责需要结合 IO_SVC_INIT、两个 vtbl 定义以及上下文接口确认。

6.5 构造 sigaction 配置

原始逻辑等价于:

1
2
3
4
struct sigaction act;
act.sa_handler = &wake_signal_handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;

更稳妥的通用写法是先清零:

1
2
3
4
struct sigaction act = { 0 };
act.sa_handler = &wake_signal_handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 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_SIGINFOSA_NODEFERSA_RESTART

在 Linux 上,epoll_wait()epoll_pwait() 被信号处理函数中断后不会因为 SA_RESTART 自动重启,而是以 EINTR 返回。因此,这条唤醒路径应显式处理 errno == EINTR

6.6 安装处理函数并保存旧配置

1
2
3
4
if (sigaction(poll->signo, &act, &poll->oact) == -1) {
errsv = errno;
goto error_sigaction;
}

参数含义:

1
2
3
4
5
sigaction(
poll->signo, /* 要配置的信号 */
&act, /* 新的处置方式 */
&poll->oact /* 保存旧的处置方式 */
);

成功后,poll->oact 保存初始化之前的信号配置,销毁对象或后续初始化失败时可以恢复。

必须注意:信号处置方式是进程级资源。一个线程安装处理函数,会影响整个进程对该信号的处理方式。

6.7 构造只包含唤醒信号的集合

1
2
3
sigset_t set;
sigemptyset(&set);
sigaddset(&set, poll->signo);

执行完成后:

1
set = { poll->signo }

各调用作用:

调用 作用
sigset_t set 定义一个信号集合对象
sigemptyset(&set) 清空集合
sigaddset(&set, signo) 将指定信号加入集合

6.8 在线程正常运行期间阻塞唤醒信号

多线程程序使用:

1
errsv = pthread_sigmask(SIG_BLOCK, &set, &poll->oset);

单线程程序可以使用:

1
2
if (sigprocmask(SIG_BLOCK, &set, &poll->oset) == -1)
errsv = errno;

SIG_BLOCK 表示把 set 中的信号加入当前线程的屏蔽字。poll->oset 保存修改前的完整屏蔽字,便于以后恢复。

两组接口的错误模型不同:

接口 成功 失败
sigprocmask() 返回 0 返回 -1,错误写入 errno
pthread_sigmask() 返回 0 直接返回错误码

因此不能统一写成读取 errno

新线程会继承创建者当时的信号屏蔽字。实际工程中应明确:哪个线程执行初始化、哪个线程进入轮询、轮询线程何时被创建。否则可能出现“处理函数已经安装,但目标线程并未阻塞该信号”的配置偏差。

6.9 将 epoll 文件描述符初始化为无效值

1
poll->epfd = -1;

-1 表示当前没有有效 epoll 实例。这样,关闭函数可以安全判断资源是否已经创建:

1
2
if (poll->epfd == -1)
return 0;

6.10 初始化互斥锁

1
errsv = pthread_mutex_init(&poll->mtx, NULL);

NULL 表示使用默认互斥锁属性。

结合结构成员推断,该锁通常用于保护监听树、监听计数以及轮询和控制路径之间的共享状态。精确保护范围仍应以所有加锁点为准,而不能只根据成员位置判断。

与其他线程函数一样,pthread_mutex_init() 失败时直接返回错误码。

6.11 初始化监听项索引和计数

1
2
rbtree_init(&poll->tree, &io_fd_cmp);
poll->nwatch = 0;

从命名推断:

  • tree 是按文件描述符或监听对象排序的红黑树;
  • io_fd_cmp 是键比较函数;
  • nwatch 记录当前监听项数量。

红黑树提供对数复杂度的插入、删除和查找,适合管理动态变化的监听对象。

6.12 创建 epoll 实例

1
2
3
4
if (io_poll_open(poll) == -1) {
errsv = errno;
goto error_open;
}

典型实现为:

1
2
3
4
5
6
7
8
9
static int
io_poll_open(io_poll_t *poll)
{
if (io_poll_close(poll) == -1)
return -1;

poll->epfd = epoll_create1(EPOLL_CLOEXEC);
return poll->epfd == -1 ? -1 : 0;
}

这里完成三件事:

  1. 关闭可能残留的旧实例;
  2. 创建新的 epoll 内核对象;
  3. 把返回的文件描述符保存到 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
2
初始化:处理函数 → 屏蔽字 → 互斥锁 → epoll → 上下文注册
回滚: 互斥锁 ← 屏蔽字 ← 处理函数

对应代码:

1
2
3
4
5
6
7
8
9
error_open:
pthread_mutex_destroy(&poll->mtx);
error_init_mtx:
pthread_sigmask(SIG_SETMASK, &poll->oset, NULL);
error_sigmask:
sigaction(poll->signo, &poll->oact, NULL);
error_sigaction:
errno = errsv;
return NULL;

这种 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
2
3
4
5
6
7
8
9
int
io_poll_wake(pthread_t thread, int signo)
{
int err = pthread_kill(thread, signo);
if (err != 0)
return err;

return 0;
}

更完整的控制路径应先更新共享状态,再发送信号:

1
2
3
4
5
pthread_mutex_lock(&poll->mtx);
poll->stop_requested = 1;
pthread_mutex_unlock(&poll->mtx);

err = pthread_kill(poll_thread, poll->signo);

顺序很重要:

1
先发布状态,再发送唤醒通知

如果先发送 Signal,轮询线程可能先醒来、检查到旧状态,然后再次进入等待。

若共享状态使用 C11 原子变量,则应根据数据依赖选择适当的 release/acquire 内存序;Signal 本身不应被当作完整的跨线程内存同步协议。

9. 轮询函数应如何处理 EINTR

事件等待代码不应把所有 -1 都视为致命错误:

1
2
3
4
5
6
7
8
9
int n = epoll_pwait(epfd, events, maxevents, timeout, &wait_mask);
if (n == -1) {
if (errno == EINTR) {
/* 被 Signal 唤醒,重新检查停止标志、任务队列和超时。 */
return 0;
}

return -1;
}

一个常见循环如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sigset_t wait_mask;
int err = pthread_sigmask(SIG_SETMASK, NULL, &wait_mask);
if (err != 0)
return err;

if (sigdelset(&wait_mask, wake_signo) == -1)
return -1;

for (;;) {
if (stop_requested())
break;

drain_pending_tasks();

int n = epoll_pwait(epfd, events, maxevents, next_timeout(), &wait_mask);
if (n == -1) {
if (errno == EINTR)
continue;
return -1;
}

dispatch_ready_events(events, n);
}

EINTR 在这里是正常控制流,不是异常故障。

11. 常见误区与风险

12.1 把 Signal 当作任务队列

标准信号不会为每次投递保留一个独立计数。连续发送多次同类信号可能合并成一次 pending 状态。

正确设计是:

1
2
任务队列保存任务
Signal 只负责叫醒消费者

11.2 在处理函数中加锁或打印

异步 Signal 可能打断线程正在执行的任意代码。如果处理函数再次获取同一把锁,可能死锁;调用非异步信号安全函数,也可能破坏库内部状态。

本场景的处理函数保持为空,是刻意的安全设计。

11.3 忽略处理函数的进程级影响

sigaction() 改变整个进程的信号处置方式。如果多个模块独立占用同一个信号,后初始化者会覆盖前一个配置。

多个轮询对象还可能产生恢复顺序问题:对象 A、B 依次保存旧配置,若销毁顺序不是严格逆序,恢复结果可能不符合预期。

应由进程级信号管理模块统一分配和维护专用信号,或改用文件描述符型唤醒机制。

11.4 在错误线程上修改屏蔽字

pthread_sigmask() 只修改调用线程。初始化线程、轮询线程和创建线程之间的关系必须清晰。

常用做法是:

  1. 在创建工作线程前,由主线程阻塞专用信号;
  2. 工作线程继承该屏蔽字;
  3. 只有在 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
2
3
4
5
6
7
空处理函数              让 Signal 可被实际捕获
线程信号屏蔽字 限制 Signal 的递送时机
pending 状态 保存提前到达的唤醒提示
epoll_pwait() 原子替换屏蔽字并进入等待
pthread_kill() 定向唤醒指定轮询线程
EINTR 通知事件循环重新检查状态
共享状态或任务队列 保存真正需要处理的内容

理解这套机制的关键不是“Signal 处理了什么”,而是:

Signal 什么也不处理,它只负责打断休眠;事件循环醒来后,再根据可靠的共享状态决定下一步动作。

参考资料

以下资料均来自 Linux man-pages 项目,访问日期:2026-07-16。

  1. signal(7): overview of signals
  2. sigaction(2): examine and change a signal action
  3. pthread_sigmask(3): examine and change mask of blocked signals
  4. sigprocmask(2): examine and change blocked signals
  5. epoll_wait(2): wait for an I/O event on an epoll file descriptor
  6. pthread_kill(3): send a signal to a thread
  7. epoll_create(2): open an epoll file descriptor