写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_set, NULL);
注意我特意存了old_set,处理完再恢复。为什么要恢复而不直接用SIG_UNBLOCK?因为调用这段代码之前,可能别的地方已经屏蔽了SIGALRM,如果你用SIG_UNBLOCK直接从屏蔽字里删除SIGALRM,等于破坏了别人的设定。用SIG_SETMASK恢复旧状态才是最安全的。
这里有个设计习惯值得分享:在任何多线程或复杂控制流的代码里,block一个信号永远要用old_set保存旧状态,最后恢复。否则一旦你的模块被嵌套在其他模块里,信号屏蔽状态会被你弄得一团糟。
2.3 sigpending:查看被拦截下的信号
sigpending返回当前进程里已经被触发、但因为被屏蔽而无法投递的信号集合。说白了就是“在门口排队的信号”。为啥要关心这个?两个典型场景。
第一个场景是优雅退出:你想在程序退出的最后关头,检查一下是否有SIGTERM来过了,如果有就先把资源清理了再exit。第二个场景是信号合并检查:标准信号不支持排队,如果你屏蔽了SIGALRM,然后定时器连续触发了三次,解除屏蔽后进程只会收到一个SIGALRM。有时候这正是我们想要的(令牌补发一次就够了),但有时候这个“丢信号”的行为会引入bug,你需要用sigpending来判断有没有信号被积压。
实测代码长这样:
c复制sigset_t pending_set;
sigpending(&pending_set);
if (sigismember(&pending_set, SIGALRM)) {
// 说明在屏蔽期间,SIGALRM已经被触发了
// 解除屏蔽后处理它
}
有一点要记得,sigpending拿到的是进程级的pending信号,在线程场景下还涉及线程级别的pending,但那属于pthread_sigmask的范畴,这里先按下不表。
3. 令牌桶算法实例:手把手实现一个信号驱动的限流器
令牌桶的完整原理和公式在算法书上都有,我不再复述。这里直接讲怎么把它用信号和定时器落地,代码可以直接跑。
3.1 令牌桶的计算模型
先定四个参数:capacity是桶容量,代表允许的最大突发量;rate是令牌产生速率,单位是“令牌/秒”;tokens是当前可用令牌数;last_refill是上次补充令牌的时间点。
每次尝试获取令牌时,先按时间差计算应补充的令牌数:
code复制补充令牌数 = (当前时间 - 上次补充时间) × 每秒补充速率
当前令牌数 = min(当前令牌数 + 补充令牌数, 桶容量)
这里用单调时钟CLOCK_MONOTONIC,不能用自己的wall time,因为系统时间可能会被NTP或手动修改,一旦往前调,时间差变成负数,令牌就永远补不上了。这是一个实战中极易踩的坑。
3.2 信号处理函数的边界
前面提过,信号处理函数里尽量不做重活。所以我的设计是:SIGALRM的handler里只设置一个全局标志位,表示“该补令牌了”。主循环里发现标志位被置位,就调用refill函数去实际补充。代码如下:
c复制static volatile sig_atomic_t refill_flag = 0;
void alarm_handler(int sig) {
refill_flag = 1; // 只做标记,不发号施令
}
sig_atomic_t是C标准里专门为信号处理函数设计的类型,保证读写是原子的,不会被中途打断。注意volatile一定不能少,否则编译器可能把refill_flag优化到寄存器里,主循环永远读不到更新后的值。
3.3 完整实现与参数计算
我把核心结构体和逻辑写出来:
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
#include <errno.h>
typedef struct {
int capacity;
int tokens;
int rate;
struct timespec last_refill;
} token_bucket;
void bucket_init(token_bucket *b, int capacity, int rate) {
b->capacity = capacity;
b->tokens = capacity;
b->rate = rate;
clock_gettime(CLOCK_MONOTONIC, &b->last_refill);
}
void bucket_refill(token_bucket *b) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
long elapsed_ms = (now.tv_sec - b->last_refill.tv_sec) * 1000
+ (now.tv_nsec - b->last_refill.tv_nsec) / 1000000;
int add_tokens = (int)(elapsed_ms * b->rate / 1000);
if (add_tokens > 0) {
b->tokens += add_tokens;
if (b->tokens > b->capacity)
b->tokens = b->capacity;
b->last_refill = now;
}
}
int bucket_try_acquire(token_bucket *b) {
if (refill_flag) {
bucket_refill(b);
refill_flag = 0;
}
if (b->tokens > 0) {
b->tokens--;
return 1;
}
return 0;
}
定时器用setitimer来配置,每次触发间隔设为100ms,也就是每100ms产生一次SIGALRM事件。为什么不用1秒?因为令牌补充粒度越细,限流越平滑。假设rate是100,如果1秒补一次,那么这一秒内前100个请求一股脑全放行,下一秒又全部拒绝,曲线像锯齿。如果100ms补一次,每次只补10个,流量就被削平了。
c复制struct itimerval timer;
timer.it_value.tv_sec = 0;
timer.it_value.tv_usec = 100000; // 首次触发:100ms后
timer.it_interval.tv_sec = 0;
timer.it_interval.tv_usec = 100000; // 周期触发:每100ms
setitimer(ITIMER_REAL, &timer, NULL);
3.4 屏蔽信号集在临界区的应用
令牌桶的关键操作是tokens的增减,这个变量会被主流程和定时器信号同时操作吗?不会。我的设计里,信号handler只设置refill_flag,真正改tokens的bucket_refill是在主流程里调用的,所以tokens本身不存在并发访问问题。
但这个思路在线程场景下就要打个问号。如果主流程是个多线程服务,多个线程同时调bucket_try_acquire,tokens的加减就不是原子操作了。这时候要在bucket_try_acquire外面包一层互斥锁,可是信号处理函数里不能加锁,所以信号handler依然只置标志,加锁只发生在主流程的临界区。
还有另一个信号屏蔽场景:在修改令牌桶的管理参数,比如动态调整rate时,我不希望请求处理线程同时读写,这会造成数据不一致。做法就是用一个信号集把SIGALRM屏蔽掉,确保调整期间不会有信号打断关键逻辑。代码这样写:
c复制sigset_t block_set, old_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGALRM);
sigprocmask(SIG_BLOCK, &block_set, &old_set);
// 修改rate,此刻SIGALRM被挡住,refill不会被打断
bucket->rate = new_rate;
bucket->tokens = 0;
sigprocmask(SIG_SETMASK, &old_set, NULL);
post mask的那一瞬间,如果SIGALRM已经被触发,会被立即投递,refill_flag被置位,主循环下一次就会执行补充。这个流程非常顺滑,不用手动去维护的定时任务列表。
4. 多任务计时器:从setitimer的局限到POSIX定时的工程级方案
令牌桶其实只需要一个定时器就够了,规模再大点就没那么好办了。假设现在你管理的不止一个流量入口,而是十几个微服务网关,每个都有自己的限流参数,每个都需要独立定时器。这时候setitimer就顶不住了。
4.1 setitimer的核心限制
setitimer一个进程只能设置一个,重复调用setitimer会覆盖之前的设置。这是因为Linux内核里每个进程只有一个itimer结构体实例。你想要5个独立的周期任务,要么自己维护时间列表,用一个时钟驱动轮询所有任务,要么换定时器方案。
我自己最早用setitimer管理多个任务时,是写了一个“最小堆”数据结构,把所有任务的到期时间按大小排列,然后setitimer设置为堆顶最近的那个到期时间,到期后再取出下一个。这相当于自己是软件定时器框架,代码越写越多,边界条件一堆,尤其是任务取消和重新排序的时候很容易漏。
4.2 POSIX定时器timer_create的优势
POSIX标准从Linux 2.6之后就支持了timer_create系列接口。每个进程理论上可以创建任意多的定时器(受系统资源限制),每个定时器可以绑定不同的信号,通知机制也可以是线程函数或事件驱动。
核心接口是:
c复制int timer_create(clockid_t clockid, struct sigevent *sevp, timer_t *timerid);
int timer_settime(timer_t timerid, int flags, const struct itimerspec *new_value, struct itimerspec *old_value);
拿买闹钟来打比方:setitimer就像你只能带一个闹钟,想多任务就得自己盯着时刻表;POSIX timer_create就是你可以买很多个闹钟,每个闹钟可以设定不同的时间,甚至不同的铃声(不同信号)。这个体验差异在工程上是天壤之别。
看一个实际配置代码,为令牌桶A和令牌桶B分别创建定时器:
c复制timer_t timer_a, timer_b;
struct sigevent sev_a, sev_b;
struct itimerspec its_a, its_b;
memset(&sev_a, 0, sizeof(sev_a));
sev_a.sigev_notify = SIGEV_SIGNAL;
sev_a.sigev_signo = SIGUSR1; // 桶A用SIGUSR1
sev_a.sigev_value.sival_ptr = &bucket_a;
memset(&sev_b, 0, sizeof(sev_b));
sev_b.sigev_notify = SIGEV_SIGNAL;
sev_b.sigev_signo = SIGUSR2; // 桶B用SIGUSR2
sev_b.sigev_value.sival_ptr = &bucket_b;
timer_create(CLOCK_MONOTONIC, &sev_a, &timer_a);
timer_create(CLOCK_MONOTONIC, &sev_b, &timer_b);
its_a.it_value.tv_sec = 0;
its_a.it_value.tv_nsec = 100000000; // 100ms
its_a.it_interval.tv_sec = 0;
its_a.it_interval.tv_nsec = 100000000;
timer_settime(timer_a, 0, &its_a, NULL);
its_b.it_value.tv_sec = 0;
its_b.it_value.tv_nsec = 500000000; // 500ms
its_b.it_interval.tv_sec = 0;
its_b.it_interval.tv_nsec = 500000000;
timer_settime(timer_b, 0, &its_b, NULL);
这里只用两个信号SIGUSR1和SIGUSR2区分两个定时器,如果定时器数量超过可用信号数量,就得用sigev_value配合实时信号扩展,或者统一用一个信号,在信号处理函数里通过sival_ptr判断是哪个定时器触发的。
4.3 多任务计时器的调度颗粒度与精度
用计时器做多任务调度,精度是躲不开的坎。Linux的HZ决定了内核时钟中断的频率,常见的HZ=250表示一个tick是4ms,sleep、timer、timeout的精度下限都在这个量级。实测在默认内核配置下,setitimer与timer_create的事件触发精度都在毫秒级,你若想做到微秒级精密控制,需要切换到高精度定时器hrtimer,普通用户态程序一般用不到。
对令牌桶算法来说,100ms的定时周期完全够用。因为限流曲线平滑程度取决于补充频率,补充频率越高越平滑,但信号触发和上下文切换的开销也会增加。我自己的经验是,生产环境RPS在几千到几万的网关,定时器设50ms到200ms区间,CPU占用率几乎可以忽略。低于10ms就要谨慎评估,因为信号触发本身消耗的CPU和系统调用会开始显现。
多任务场景下还要注意:不同定时器触发的是不同信号,如果都用了标准信号(低于SIGRTMIN),那么同一个信号连续触发多次,内核只会合并成一次投递。比如SIGUSR1在100ms内被触发10次,但进程忙于主循环没来得及处理,等它调用sigwaitinfo时,可能只收到一个SIGUSR1,令牌补充量就会比预期少很多。要解决这个问题有三个方案:一是用实时信号SIGRTMIN+n,实时信号支持排队,信号不丢失;二是降低定时频率,让刷新率低于主循环处理能力;三是在handler里记录触发次数,而不只是标志位。
5. 常见问题与排查技巧实录
这一部分全部来自实际项目中踩过的坑,每一个都曾经让我排查到怀疑人生。
5.1 标准信号丢失的诡异限流偏差
现象:令牌桶设定的rate是1000,实际压测时发现通过率只有700左右,且波动很大。
排查:最开始怀疑是算法有bug,检查bucket_refill的时间计算,没问题。后来打日志发现SIGALRM触发的次数远小于预期——100ms一次的周期,持续10秒应该触发约100次,但实际只触发了约70次。原因就是标准信号合并。
SIGALRM是标准信号(编号14),同一个标准信号在相同进程内是“未决态合并”的。如果主循环正在忙,signal handler执行完后,信号没来得及被完全处理(或者flag被覆盖),定时器再次到期,这时候内核不会重新投递信号,而是保持pending状态。所以从结果上看就是信号被“丢掉”了。
解决方案:把SIGALRM换成实时信号,比如SIGRTMIN,然后通过sigwaitinfo或signalfd接收。这个改动很简单,把signal(SIGALRM, handler)换成signal(SIGRTMIN, handler),同时把屏蔽字的SIGALRM换成SIGRTMIN即可。实时信号在Linux下默认是队列式投递,只要系统内存足够就不会合并。
5.2 sigprocmask与pause之间隐藏的竞态
这个坑很经典,出现在“等待信号”的代码里。我早期的代码长这样:
c复制sigprocmask(SIG_BLOCK, &block_set, &old_set); // 屏蔽SIGALRM
// 做一些初始化...
pause(); // 等待信号
sigprocmask(SIG_SETMASK, &old_set, NULL); // 恢复
问题在于:如果你在sigprocmask之后、pause之前,SIGALRM已经触发了,那么pause会一直等下去,因为信号已经在未决状态了,而pause挂起后,它不会被自动投递。程序卡成僵尸。
标准解法是用sigsuspend代替pause,它会把信号屏蔽集原子地替换成目标集合,然后挂起等待信号,信号到达并触发handler之后,再原子恢复原来的屏蔽集。改后的代码:
c复制sigprocmask(SIG_BLOCK, &block_set, &old_set);
// 初始化....
sigsuspend(&old_set); // 原子等待SIGALRM
sigprocmask(SIG_SETMASK, &old_set, NULL);
这个原子性是用pause无法替代的。凡是涉及“先屏蔽、再等待”的场景,都要自觉换成sigsuspend,包括我后面写多任务计时器时也是这么改的。
5.3 信号处理函数中的不可重入操作
这条算是老生常谈,但我还是想说一个具体的翻车经历。用putchar在handler里打印调试信息,程序在正常流程下没问题,但一压测就段错误。因为中断的时候,主流程可能正握着stdio的锁,handler再次去拿这把锁,直接死锁。
这也是为什么我坚持handler里只做置flag或者sival_ptr的分发,再不干别的。可重入函数必须在man page的async-signal-safe列表里才可以用,write、read、open、close、sigaction等系统调用在这个列表里,printf、malloc、free、socket不是。实测下来,哪怕是一个简单的sprintf,在高频信号场景下都可能在malloc时触发死锁。
5.4 定时器精度受内核HZ影响
遇到一个线上偶发问题:定时器触发的间隔极不均匀,有时100ms,有时跳到200ms。排查了半天,最后发现是内核配置里HZ=100,最小tick就是10ms,再加上系统里其他进程抢占CPU,导致定时器触发的实际延迟在几毫秒到几十毫秒之间波动。
对令牌桶来说,这个波动完全可以接受,因为补充是累积式的——如果某次触发延迟了,下次计算的elapsed_ms就更大,补的令牌也更多。所以令牌桶对定时器抖动的鲁棒性非常好,这也是它优于固定计数限流的原因之一。但对严格计时的场景,比如“每30秒拍一次快照”,就得换成timerfdfd配合poll超时,或者干脆用多线程各自做time_sleep,把时序隔离到独立线程里。
5.5 常见问题速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 信号总触发次数远少于定时器周期数 | 标准信号未决合并 | 改用SIGRTMIN等实时信号 |
| 程序无故卡死在pause等待 | sigprocmask与pause之间存在竞态窗口 | 用sigsuspend替代pause |
| 信号处理函数中调用printf挂死 | 不可重入函数触发死锁 | handler内只置volatile sig_atomic_t标志位 |
| 定时器触发间隔抖动严重 | 内核HZ限制+调度抖动 | 调大定时周期或结合时钟计算差值补偿 |
| 设置屏蔽字后其他信号被误伤 | 缺少old_set保存与恢复 | 使用SIG_SETMASK恢复旧集合 |
6. 多任务计时器与令牌桶的集成建议
如果你已经跑通了单桶的令牌桶,下一步就是把它扩展成多任务。我建议用两个信号配合signalfd来做事件分发,而不是在每个handler里写一堆if-else判断。
signalfd是Linux的异步信号统一入口,可以把文件描述符和信号绑定,然后放进epoll里统一管理。好处非常明显:所有信号事件都变成fd可读状态,跟网络事件一样被epoll驱动,不需要signal handler,完全避开可重入性问题。但代价是代码复杂度会高一些,需要单独处理信号fd的读写。
我给出的代码演示里的双桶方式,在实际场景最多支持十几个定时器。超过这个数量,每个定时器单独分配一个标准信号就不现实了。工程上通常的做法是一个信号,区分参数(sival_ptr),或者干脆用一个独立的timerfd作为定时源,把令牌桶的补充逻辑放到一个管理线程里做。
最后给两个工程建议。第一,不论用signal还是signalfd,都需要统一处理pending信号,程序启动或者升级配置时,调用sigpending检查有没有积压的信号没有处理,不要让旧信号影响新配置。第二,所有信号处理相关的全局变量,尽量用volatile sig_atomic_t类型声明,并且在编译时加-pedantic,有助于获知可重入性隐患警告。这些经验都是从肉眼调bug和线上事故里长出来的,希望能帮你少走几个弯路。
