写Linux信号和令牌桶这个组合,其实是我多年前折腾一个高并发网关时落下的心结。当时RPS(每秒请求数)总是飙到阈值以上,业务方又要求不能粗暴拒绝,只能在流量入口做平滑限流。我第一个想到的就是经典的令牌桶算法,但怎么高效地触发“令牌补充”这个动作,却卡了很久——轮询太浪费CPU,sleep又控制不准节奏,最后绕了一圈回来,发现Linux的信号机制加上定时器才是最优解。
把这个思路彻底吃透之后,我对sigprocmask、sigpending、POSIX定时器这些原本“背过就忘”的概念,突然有了真正的手感。这篇文章就围绕“信号加令牌桶算法实例”这个项目,把信号集、信号屏蔽字、pending挂起态、多任务计时器怎么串起来,一整套讲清楚。写出来的代码可以改改直接用,后面附的问题排查记录,都是我实际踩过的坑。
1. 整体设计思路:为什么信号适合做令牌桶的控制核心
令牌桶算法的逻辑本身不复杂:桶里有令牌就放行一个请求,没令牌就拒绝或者排队,同时以固定速率往桶里补充令牌。难点从来不在算法本身,而在“什么时候补充”和“能不能不抢主流程的CPU”。
我当时试过三种方案。第一种是开一个独立线程,死循环while(1) + sleep(time)往桶里加token。这个方法能跑,但线程数量一多,调度抖动非常明显,尤其容器环境里时间片不稳定,限流曲线会很难看。第二种是每次请求到达时,用当前时间减去上次补充时间,算出应该补多少token,也就是懒更新。这种方式纯靠CPU算时间差,不用额外触发,精度尚可,但如果请求频率很低,桶里的token会闲置很久,突发流量一来容易打穿限流。
第三种就是本文要展开的:用定时器周期性触发SIGALRM信号,在信号处理函数里把令牌补上。这个方案有几个天然优势。信号是异步通知机制,内核在定时器到期时直接给进程发信号,不需要进程主动轮询,CPU几乎零开销。而且信号处理函数是打断式的,它可以在极端情况下立即执行,这比线程调度的响应时间更确定。再加上信号集和屏蔽字,我可以在临界区把信号暂时挡住,保证关键路径不被打断。
讲到这必须强调一个关键点:信号处理函数里面不能做任何非异步信号安全的操作。像malloc、printf、pthread_mutex_lock这类统统不能出现,否则轻则死锁,重则程序直接崩。那令牌桶的补充逻辑怎么放进handler里?我的做法是,信号处理函数只负责设置一个volatile sig_atomic_t标志位,真正的补充计算放到主循环里,在safe point再去执行。这样既享受了信号的即时通知,又避开了重入风险。
所以整体架构就很清晰了:主流程是处理请求的业务代码,里面用sigprocmask保护敏感区域;定时器负责周期性地发送信号;信号处理函数做一个最轻量的标记动作;真正的令牌追加逻辑在业务代码的非临界区执行。这套设计的核心思想是:信号负责“喊一嗓子”,活还是让主流程干。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 信号集、信号屏蔽字与pending状态:三个最容易被搞混的概念
很多人在学信号的时候,sigemptyset、sigaddset、sigprocmask、sigpending这几个函数背得滚瓜烂熟,但一到实战就不知道什么时候用哪个。归根结底,是对三个概念的关系没理顺:信号集是一张“名单”,信号屏蔽字是“临时黑名单”,pending是“被拦住但还没处理的信号”。
2.1 sigset_t到底保存了什么
sigset_t本质上是个位图,每一位对应一个信号编号。比如bit1对应SIGHUP,bit2对应SIGINT。POSIX标准规定不能用int直接操作它,必须用sigemptyset、sigfillset、sigaddset、sigdelset、sigismember这五个工具函数。
我用一个实际场景来解释:假设你要阻塞SIGINT和SIGTERM这两个信号,那么初始化代码就是这样:
c复制sigset_t set;
sigemptyset(&set); // 先清空,确保没有历史垃圾数据
sigaddset(&set, SIGINT); // 把SIGINT加入名单
sigaddset(&set, SIGTERM); // 把SIGTERM加入名单
很多人会漏掉sigemptyset这一步,这是一个非常隐蔽的坑。如果set是个未初始化的局部变量,里面可能有随机数据,sigaddset结果就会不可预期。你设了SIGINT屏蔽,结果连SIGCHLD也被莫名屏蔽了,排查半天找不到原因,最后发现就是没清空位图。
2.2 sigprocmask:屏蔽字是进程级的行为约束
sigprocmask是操作信号屏蔽字的唯一标准接口,它在三个动作里任选一个:SIG_BLOCK把set里的信号加入屏蔽集合,SIG_UNBLOCK把set里的信号从屏蔽集合移除,SIG_SETMASK直接整体替换屏蔽集合。
我用得最多的是第一和第三种组合。看一段代码:
c复制sigset_t new_set, old_set;
sigemptyset(&new_set);
sigaddset(&new_set, SIGALRM);
// 把SIGALRM加入屏蔽字,同时保存旧状态
sigprocmask(SIG_BLOCK, &new_set, &old_set);
// 这段代码运行时不会被SIGALRM打断
// 做你的临界区操作...
// 恢复之前的屏蔽状态
sigprocmask(SIG_SETMASK, &old
