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