21、协程与信号:如何处理信号对协程调度的影响
信号这东西,在嵌入式开发里就像个不速之客。你正好好跑着协程调度,突然一个SIGINT或者SIGALRM砸过来,整个调度器可能就乱套了。我早年做网络协议栈的时候,就吃过这个亏——协程跑着跑着被信号打断,上下文全乱了,查了两天才找到根因。
说白了,信号是内核给用户态进程的异步通知机制。而协程呢,是用户态自己搞的并发调度。这两者碰在一起,就像两个不守规矩的司机抢道——谁都不让谁,最后肯定出事故。
信号对协程调度的三大威胁
我个人习惯把信号对协程的影响归纳成三类,你想想看是不是这个理:
- 上下文破坏:信号处理函数执行时,会打断当前协程的执行流。如果信号处理函数里调用了非重入函数,协程的栈数据可能被污染。
- 调度器状态不一致:信号可能在调度器更新就绪队列的中途到达,导致链表指针错乱。我在项目中遇到过,协程A刚被移出队列,信号来了,处理函数又把A塞回去了——结果A被调度了两次。
- 死锁风险:如果信号处理函数里尝试获取调度器持有的锁,而调度器正被信号打断,那就直接死锁了。嗯,这里要注意,信号处理函数里绝对不能加锁。
核心原则:信号处理函数应当尽可能简单——要么设置一个标志位,要么往管道里写一个字节。绝对不要在信号处理函数里操作协程调度器的任何数据结构。
信号安全的协程调度设计
怎么解决这个问题?我这些年总结了一套方案,核心思路就八个字:延迟处理、信号屏蔽。
方案一:信号屏蔽区
在调度器的关键路径上,临时屏蔽信号。等操作完成再恢复。这招简单粗暴,但有效。
// 协程调度器的核心调度函数
void coroutine_scheduler_run(scheduler_t *sched) {
sigset_t old_mask, new_mask;
// 屏蔽所有信号,防止调度过程中被中断
sigfillset(&new_mask);
pthread_sigmask(SIG_BLOCK, &new_mask, &old_mask);
// 现在可以安全地操作调度器数据结构了
while (sched->current) {
coroutine_t *co = sched->current;
// 保存当前协程上下文
// 选择下一个要运行的协程
// 恢复目标协程上下文
// 关键操作完成,恢复信号掩码
pthread_sigmask(SIG_SETMASK, &old_mask, NULL);
// 在这里检查是否有信号需要处理
check_pending_signals(sched);
// 重新屏蔽信号,进入下一轮调度
pthread_sigmask(SIG_BLOCK, &new_mask, &old_mask);
}
// 最终恢复信号掩码
pthread_sigmask(SIG_SETMASK, &old_mask, NULL);
}
我的经验:信号屏蔽区要尽量短。我曾经把整个事件循环都包在屏蔽区里,结果信号延迟了上百毫秒才被处理,导致上层协议超时重传。屏蔽区只保护那些真正不能被打断的临界区就好。
方案二:信号管道(self-pipe trick)
这是我最喜欢的方式。信号来了不直接处理,而是往一个管道里写一个字节。调度器的事件循环里监听这个管道的读端,有数据就读出来,然后统一处理。
// 全局信号管道
static int signal_pipe[2];
void signal_handler_init(void) {
// 创建管道
pipe(signal_pipe);
// 设置非阻塞
fcntl(signal_pipe[0], F_SETFL, O_NONBLOCK);
fcntl(signal_pipe[1], F_SETFL, O_NONBLOCK);
}
// 信号处理函数——只做一件事:往管道写数据
void sigint_handler(int signo) {
char byte = (char)signo;
write(signal_pipe[1], &byte, 1);
}
// 协程调度器的事件循环
void scheduler_event_loop(scheduler_t *sched) {
// 将信号管道的读端加入epoll/poll/select
sched->add_fd(signal_pipe[0], EPOLLIN);
while (1) {
// 等待事件
int n = sched->wait_events(-1);
if (sched->is_fd_ready(signal_pipe[0])) {
// 有信号到达,读取并处理
char signo;
while (read(signal_pipe[0], &signo, 1) > 0) {
handle_signal_in_coroutine_context(sched, signo);
}
}
// 处理其他I/O事件和协程调度
sched->dispatch_ready_coroutines();
}
}
避坑指南:我曾经在信号处理函数里用write往管道写数据,但没处理EAGAIN错误。管道缓冲区满了怎么办?信号处理函数里不能阻塞,所以必须设置非阻塞模式,并且忽略EAGAIN。另外,write本身也不是完全异步信号安全的——但好在大多数Unix系统上,write到管道是安全的。如果你不放心,可以用self-pipe的变种:eventfd。
信号与协程切换的原子性问题
还有一个更隐蔽的问题:协程切换本身不是原子的。你想想看,协程A保存上下文到一半,信号来了,信号处理函数里调用了yield——这时候会发生什么?
嗯,协程A的上下文只保存了一半,调度器就开始切换了。等切回来的时候,栈指针可能指向了错误的位置,程序直接崩溃。
解决方案其实前面已经提到了:在协程切换的临界区屏蔽信号。但这里有个细节——屏蔽信号不能嵌套太深。我见过一个项目,屏蔽信号嵌套了5层,最后信号处理延迟到了秒级。
// 协程切换的原子版本
static inline void coroutine_switch(coroutine_t *from, coroutine_t *to) {
sigset_t old_mask;
// 只屏蔽关键信号(比如SIGALRM、SIGIO等可能触发调度的信号)
sigemptyset(&new_mask);
sigaddset(&new_mask, SIGALRM);
sigaddset(&new_mask, SIGIO);
sigaddset(&new_mask, SIGUSR1);
pthread_sigmask(SIG_BLOCK, &new_mask, &old_mask);
// 执行上下文切换(汇编实现)
swap_context(&from->context, to->context);
// 切换回来后恢复信号掩码
// 注意:这里恢复的是切换前的掩码,不是new_mask
pthread_sigmask(SIG_SETMASK, &old_mask, NULL);
}
个人建议:不要屏蔽所有信号,只屏蔽那些会触发协程调度的信号。比如SIGINT你希望立即响应,就不要屏蔽它。我一般会维护一个「调度相关信号」的列表,只屏蔽这些。
信号在协程间的传递
有时候我们需要把信号传递给特定的协程。比如一个协程在等待网络数据,你希望SIGALRM能唤醒它。怎么做?
我的做法是:在协程控制块里加一个pending_signals位图。信号处理函数只负责往管道写数据,调度器在事件循环里读取信号后,查找当前正在等待该信号的协程,把信号位设置上,然后把协程加入就绪队列。
typedef struct {
// ... 其他字段
uint64_t pending_signals; // 待处理信号位图
uint64_t wait_signals; // 等待的信号集合
int signal_triggered; // 是否因信号而被唤醒
} coroutine_t;
// 协程等待特定信号
void coroutine_wait_signal(coroutine_t *co, int signo) {
co->wait_signals |= (1ULL << signo);
co->state = COROUTINE_BLOCKED;
// 让出CPU
coroutine_yield();
// 被唤醒后,检查是否是因为信号
if (co->signal_triggered) {
// 处理信号
co->signal_triggered = 0;
}
}
// 调度器处理信号
void scheduler_deliver_signal(scheduler_t *sched, int signo) {
// 遍历所有协程,找到等待该信号的
for (int i = 0; i < sched->num_coroutines; i++) {
coroutine_t *co = &sched->coroutines[i];
if (co->wait_signals & (1ULL << signo)) {
co->pending_signals |= (1ULL << signo);
co->signal_triggered = 1;
co->wait_signals &= ~(1ULL << signo);
scheduler_make_ready(sched, co);
}
}
}
关键点:信号传递到协程这个机制,本质上是一种「异步通知的异步处理」。信号是内核异步通知给进程的,而协程是用户态异步处理的。两层异步叠加,设计上要格外小心——我见过一个bug,信号传递给了已经销毁的协程,导致野指针访问。
实战中的信号处理策略
说了这么多,总结一下我在实际项目中怎么做的:
- 信号处理函数极简:只做write到管道,或者设置volatile sig_atomic_t标志。绝不调用任何非重入函数。
- 调度器临界区屏蔽信号:只屏蔽那些会干扰调度的信号,不要一刀切屏蔽所有。
- 信号统一在事件循环中处理:通过self-pipe或eventfd,把信号事件化,和I/O事件统一调度。
- 协程控制块记录信号状态:支持协程级别的信号等待和传递。
- 测试信号与协程的交互:用SIGALRM定时触发,验证调度器在高频信号下是否稳定。我一般会跑48小时的压力测试。
最后提醒一句:不要试图在信号处理函数里做协程切换。绝对不要。信号处理函数的执行上下文和普通函数不一样,栈空间有限,而且可能正在执行非重入的系统调用。强行切换协程,后果自负。
核心思想总结:信号是异步的,协程调度也是异步的。两个异步系统交互,必须通过一个「同步点」来解耦——这个同步点就是事件循环。信号处理函数只负责通知,不负责处理;调度器在安全的时候统一处理信号。这样既保证了实时性,又避免了竞态条件。
好了,关于协程与信号的处理,我就讲这么多。记住一句话:别在信号处理函数里碰协程调度器。把这个原则刻在脑子里,能省去你无数调试的夜晚。