你有没有遇到过这种情况:服务正常运行,突然被你自己的kill -9干掉;或者一个定时任务莫名其妙地提前触发了;又或者限流逻辑在高峰期突然失灵,放进来一堆请求。排查到最后,发现全跟信号处理有关。
我最近在做一个轻量级的数据转发服务,需要在用户态做限流和超时管理。一开始靠sleep轮询,CPU占用高得离谱,后来换成信号 + 计时器组合,才把问题真正解决。过程中把信号集、信号屏蔽字、pending状态机、SIGALRM多任务计时、令牌桶算法全串了一遍。这篇就按我实际踩坑的顺序,把整套东西梳理清楚,已经跑通的代码会直接贴出来,你可以照着改。
1. 信号的执行模型:为什么说它是“进程被强插的异步回调”
1.1 信号到底是怎么被“插入”进程的
很多同学学信号,只知道“信号是用来杀进程的”,然后就知道有个SIGKILL、SIGTERM。其实信号最本质的特征是异步通知:内核在某个时刻收到事件,把信号挂到进程上,然后在进程从内核态返回用户态的瞬间,检查有没有待处理的信号。如果有,就强制进程暂停当前的执行流,跳到预设的处理函数里跑一圈,再回来继续刚才的代码。
这个“强制暂停”很有意思。你无法预测它发生在哪一条指令之后,它可能在你算到一半的表达式中间打断你,也可能在你调read()刚阻塞的时候打断。所以信号处理必须遵守一个铁律:处理函数里不能做复杂操作。你可以把它理解成程序员赶工时突然有人拍你肩膀让你去开个短会,你只能简单处理,不能顺手把整周的工作全干完再回来。
1.2 哪些场景会产生信号
信号不是只有kill命令才能发。我整理了一份最常碰到的触发来源:
| 信号来源 | 典型信号 | 说明 |
|---|---|---|
| 硬件异常 | SIGSEGV、SIGFPE、SIGBUS |
段错误、除零、总线错误,进程内出现非法操作 |
| 终端按键 | SIGINT、SIGQUIT |
Ctrl+C 产生 SIGINT,Ctrl+\ 产生 SIGQUIT |
| 软件条件 | SIGALRM、SIGCHLD、SIGPIPE |
计时器到期、子进程退出、管道对端关闭 |
| 显式发送 | kill()、raise() |
进程间通信的原始手段之一,同一个进程也能给自己发 |
SIGPIPE 值得单独提一下。我在转发服务里遇到过:下游连接被对端关了,write() 写一半,内核直接给进程发一个 SIGPIPE,默认动作是终止进程。如果不处理或者不忽略,服务就会莫名其妙地“自杀”。真实的线上服务里,几乎所有人都会对它忽略处理,然后自己检查 write 的返回值。
1.3 默认处理、自定义处理和“不可干预”信号
信号有三种归宿:默认处理、捕获处理(自定义 handler)、忽略。默认处理对大多数信号来说是终止进程,少数是停止进程(SIGSTOP)或者忽略(SIGCHLD、SIGURG)。
但 SIGKILL(9号)和 SIGSTOP(19号)这两个是例外,它们既不能被捕获,也不能被忽略,更不能被屏蔽。内核直接执行默认动作。所以设计守护进程时的保底方案通常是 kill -9,就是因为它一定能杀掉进程。而 SIGTERM(15号)可以被捕获,很多服务收到它的第一反应是优雅地保存数据、关闭连接、退出,这也是为什么生产环境停服务优先发 SIGTERM 而不是直接 SIGKILL。
注册信号处理函数,旧接口 signal() 在不同系统上语义有细微差别,我建议直接上 sigaction():
c复制#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
static void on_int(int sig)
{
// 注意:handler 里不要调用 printf,后面会专门讲为什么
char msg[] = "catch SIGINT\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}
int main(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_int;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; // 不设置 SA_RESTART,后面讲 EINTR 时要考
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
while (1) {
pause(); // 把进程挂起,等信号来
}
return 0;
}
sigaction 的优势是你可以精确地控制信号处理期间的屏蔽字(通过 sa_mask)和行为标记(通过 sa_flags),这是老接口给不了的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 信号生命周期的三态流转:pending、屏蔽与递达的边界
2.1 产生、未决、递达:一个信号的一生
一个信号从出来到处理完,状态机是这样的:事件发生,信号被内核产生;如果当前进程没有屏蔽它,那它很快会被递达(执行默认动作或进入 handler);如果进程把信号屏蔽了,那这个信号就停在未决(pending)状态,待在进程的未决信号集里排队。
所以你记住一句话:屏蔽不是丢弃,只是延迟递达。信号被屏蔽期间怎么发都行,它老老实实待在那里,等屏蔽解除的瞬间,如果没有同类型信号抢先,它就会被处理掉。
这里有个非常重要的细节:标准信号是不排队的。什么意思?屏蔽期间你给它发了 5 次 SIGALRM,解除屏蔽后,它只会被递达一次,因为同一个未决信号在 pending 集合里只是一个比特位,内核不会维护“总共来了几次”的计数。这跟你收快递不一样,快递柜能存 5 个货,每个货对应一个格子;标准信号就像一个布尔开关,只有“有”和“没有”两种状态。
2.2 为什么“屏蔽”不等于“忽略”
忽略是信号来了以后,我明确表态“我不想处理它”,内核直接把它丢掉。屏蔽是信号来了以后,我先不处理,把它压进 pending 队列,等我说“解禁”了,它再从 pending 里出来执行。
这两者的区别在实战中非常关键。比如做令牌桶限流时,我需要保证“检查令牌、扣减令牌”是一段原子操作,如果此时 SIGALRM 刚好触发补充令牌的 handler,就会和主流程并发修改 tokens 变量,产生不可预期的结果。这时候我会把 SIGALRM 屏蔽掉,让它在临界区执行完以后再补上。
2.3 用代码看一遍完整流程
我写了个小实验程序,你可以自己跑一遍,直观感受三态切换:
c复制#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
static void on_alrm(int sig)
{
char msg[] = "SIGALRM delivered\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}
int main(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_alrm;
sigemptyset(&sa.sa_mask);
sigaction(SIGALRM, &sa, NULL);
sigset_t block_set, old_set, pending_set;
// 1. 屏蔽 SIGALRM
sigemptyset(&block_set);
sigaddset(&block_set, SIGALRM);
sigprocmask(SIG_BLOCK, &block_set, &old_set);
// 2. 给自己发一个 SIGALRM,此时它不会递达
raise(SIGALRM);
printf("after raise, SIGALRM has been blocked\n");
// 3. 查看 pending 状态
sigpending(&pending_set);
if (sigismember(&pending_set, SIGALRM))
printf("SIGALRM is pending now\n");
// 4. 解除屏蔽,信号立刻递达
sigprocmask(SIG_SETMASK, &old_set, NULL);
printf("after unblock, handler should have run\n");
return 0;
}
运行结果里你会看到:raise 之后并没有立刻执行 handler,而是打印 pending 提示后才执行。这就说明屏蔽期间信号停在 pending 中,解除屏蔽的瞬间才“补课”。
3. sigprocmask实战:用信号屏蔽字给共享变量上锁
3.1 信号集操作的五个基础API
sigset_t 本质上是一个位集合,一个比特代表一个信号编号。操作它的时候不能直接赋值,必须走 API:
c复制sigemptyset(&set); // 清空集合
sigfillset(&set); // 全置1,表示所有信号
sigaddset(&set, SIGALRM); // 把 SIGALRM 加入集合
sigdelset(&set, SIGALRM); // 把 SIGALRM 从集合移除
sigismember(&set, SIGALRM); // 查询集合里是否包含 SIGALRM
这里有个新手最容易踩的坑:sigemptyset 只保证把集合清空,你如果想要“除了某几个都屏蔽”,直接 sigfillset 再 sigdelset 是最高效的写法,千万别手工初始化 sigset_t 为 0。
3.2 sigprocmask的三个操作模式
修改进程的“当前屏蔽字”靠 sigprocmask,它有三个模式:
SIG_BLOCK:把参数里的信号集叠加到当前屏蔽字上,相当于追加屏蔽。SIG_UNBLOCK:把参数里的信号集从当前屏蔽字里移除。SIG_SETMASK:直接把当前屏蔽字设置成参数里的值,不做叠加。
实战里最常用的模式是 SIG_BLOCK 配合保存旧值,然后处理完恢复。因为如果你直接 SIG_UNBLOCK,万一这个信号在进入临界区之前就已经被屏蔽了,你的“解除”反而成了“错误放行”。
正确的临界区保护模式是:
c复制sigset_t block_set, old_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGALRM);
sigprocmask(SIG_BLOCK, &block_set, &old_set);
// ---- 临界区:修改共享变量,不希望被 SIGALRM 打断 ----
if (tokens > 0) {
tokens--;
}
// ---- 临界区结束 ----
sigprocmask(SIG_SETMASK, &old_set, NULL);
注意最后恢复用的是 SIG_SETMASK 和 old_set,而不是 SIG_UNBLOCK。这样无论进入前是否屏蔽,退出时都能回到原状。
3.3 屏蔽期间信号压进pending,解除后立刻触发
我在自己的项目里验证过一个极端情况:临界区里跑了一段耗时较长的磁盘写操作,然后 setitimer 定时器到点了,SIGALRM 被压进 pending。临界区释放后,SIGALRM 立刻递达,handler 执行补令牌。整体时序没问题,但要注意:pending 合并效应决定了这段时间如果定时器到了很多次,handler 只会执行一次。对于“补令牌”这种动作来说,如果每次补 1 个令牌,那么等待期间多个到期事件被合并成一次,最终只补了 1 个令牌,而不是 5 个。这是标准信号做计数的天然缺陷,后面我会展开讲怎么绕开。
4. 多任务计时的两种姿势:setitimer与timerfd
4.1 多任务并发等待,为什么不能靠sleep
如果只是“等 3 秒后执行”,sleep(3) 就够了。但真实场景里,一个进程内可能同时挂着几十个超时任务:A 任务 200ms 后检查,B 任务 1.5s 后重试,C 任务 30s 后判断 neighbor 是否活跃……用 sleep 轮询显然不现实,它会让进程阻塞,什么都不干,白白浪费 CPU。
正确做法是用间隔计时器(interval timer)或者 POSIX 定时器,让内核在指定时间发信号,进程继续干自己的事,收到信号再统一处理。
4.2 setitimer的三种计时模式对比
setitimer 是最传统的用户态定时接口,它分三种模式:
| 模式 | 计时时钟 | 到期信号 | 典型场景 |
|---|---|---|---|
ITIMER_REAL |
真实流逝时间 | SIGALRM |
超时控制、限流补令牌 |
ITIMER_VIRTUAL |
进程用户态CPU时间 | SIGVTALRM |
用户态程序运行时长统计 |
ITIMER_PROF |
用户态+内核态CPU时间 | SIGPROF |
性能剖析、profiling |
我的限流场景用的是 ITIMER_REAL,因为它受系统调度影响最小,按照墙上时钟走。ITIMER_VIRTUAL 只在进程占用 CPU 时计时,线程阻塞在 IO 上不算时间,容易误判超时。
启动一个每 200ms 触发一次 SIGALRM 的计时器:
c复制#include <sys/time.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
#include <stdio.h>
static void on_timer(int sig)
{
char msg[] = "tick\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}
int main(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_timer;
sigemptyset(&sa.sa_mask);
sigaction(SIGALRM, &sa, NULL);
struct itimerval timer;
timer.it_value.tv_sec = 0;
timer.it_value.tv_usec = 200000; // 第一次到期 200ms
timer.it_interval.tv_sec = 0;
timer.it_interval.tv_usec = 200000; // 之后每 200ms
setitimer(ITIMER_REAL, &timer, NULL);
while (1) {
pause();
}
return 0;
}
一个进程只能同时拥有一个 ITIMER_REAL。所以多个超时任务不能各自独立设置多个 setitimer,必须在同一个 handler 里做分发。
4.3 多任务超时的管理架构:handler只标记,主循环做判断
当一个 SIGALRM 到来时,你怎么知道是哪个任务超时?答案是:不知道,也不需要知道。正确架构是:handler 只负责把“有定时器到期了”这个状态记录下来,主循环定期检查超时队列。
这就要求每个任务都保存自己的 deadline(到期时间戳),用一个有序结构管理。我在项目里用的是最小堆(优先队列),每次取堆顶的 deadline,跟当前时间比较,小于等于当前时间就是到期任务;取出继续看下一个;直到没有到期任务为止。
伪代码逻辑:
c复制while (1) {
// 阻塞等待可读事件 / 信号驱动
pause_or_epoll();
// 时间到了?处理所有到期任务
now = time_now();
while (heap_top_expired(now)) {
task = heap_pop();
handle_timeout(task);
}
}
handler 里绝不能做重活,因为它运行在用户态栈和信号帧之间,环境受限。你在这个上下文里调用 malloc、加锁、甚至 printf,都可能触发死锁或者数据竞争。正确做法是给它一个变量置位即可,比如:
c复制static volatile sig_atomic_t timer_expired = 0;
static void on_timer(int sig)
{
timer_expired = 1;
}
主循环检测到 timer_expired == 1 以后,清零再统一处理超时队列。
4.4 更现代的方案:timerfd_create
setitimer 的缺点是它走信号链路,handler 写起来受限。Linux 内核提供了一组更优雅的接口:timerfd_create()、timerfd_settime()。它把定时器抽象成一个文件描述符,到期时往 fd 里写入计数值,配合 epoll 或 read 使用,完全绕开信号屏蔽、pending、handler 这些麻烦事。
c复制#include <sys/timerfd.h>
#include <time.h>
#include <unistd.h>
#include <stdio.h>
int main(void)
{
int tfd = timerfd_create(CLOCK_MONOTONIC, 0);
struct itimerspec its;
its.it_value.tv_sec = 1;
its.it_value.tv_nsec = 0;
its.it_interval.tv_sec = 0;
its.it_interval.tv_nsec = 500000000;
timerfd_settime(tfd, 0, &its, NULL);
unsigned long long expirations;
read(tfd, &expirations, sizeof(expirations)); // 阻塞等待到期
printf("timer expired: %llu times\n", expirations);
close(tfd);
return 0;
}
为什么 read 返回的是 unsigned long long?因为它记录的是自上次读取以来的到期次数,计数器从 1 开始,不会像标准信号一样合并成一次。这一点在需要精确计数或者短时间内多次到期的场景里非常有用。高并发服务我建议优先上 timerfd,低负载工具类项目用 setitimer 就够了。
5. 令牌桶限流实例:SIGALRM定时补令牌的完整代码
5.1 令牌桶和漏桶的区别,别选错
限流算法里最常见的两个:漏桶和令牌桶。漏桶像个有洞的水桶,水(请求)进来之后无论多猛,流出的速率恒定;令牌桶则是另一个思路:桶里攒令牌,请求来了消耗令牌,令牌会在单位时间内按固定速率补充,桶满则令牌丢弃。
| 特性 | 漏桶 | 令牌桶 |
|---|---|---|
| 流量形状 | 恒定速率输出 | 允许一定程度的突发 |
| 是否有桶容量 | 桶容量限制排队长度 | 桶容量 = 最大突发量 |
| 对突发请求的态度 | 直接流控,不可积攒 | 平时攒令牌,突发时可一次性吃掉 |
令牌桶最典型的用法:允许空闲时段积攒令牌,高峰期可以放行额外的突发请求。比如 capacity=10,补充速率 5个/秒,一时冲进来 15 个请求,如果桶内有 10 个令牌,则放行 10 个,另外 5 个被拒;如果桶内只有 5 个,就只能放行 5 个。
5.2 设计思路:信号负责“补”,主流程负责“查扣”
令牌桶的“补令牌”动作天然适合交给定时器。我用 setitimer(ITIMER_REAL) 每 200ms 触发一次 SIGALRM,handler 里把 tokens 增加 1(上限是 BUCKET_CAPACITY)。主流程的 try_acquire() 检查 tokens 是否大于 0,是则扣除并返回 1,否则返回 0。
关键点在于主流程的“检查+扣减”必须是一个原子操作,否则 tokens 可能被 handler 和主流程同时修改。做法我已经在第 3 节讲过了:进临界区之前 SIG_BLOCK 屏蔽 SIGALRM,出临界区恢复旧屏蔽字。
完整代码如下:
c复制#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
#include <sys/time.h>
#define BUCKET_CAPACITY 5
#define REFILL_INTERVAL_US 200000 // 200ms 补充一个令牌
// 信号处理函数里只能访问 volatile sig_atomic_t 或简单的文件描述符
static volatile sig_atomic_t tokens = BUCKET_CAPACITY;
static void refill_handler(int sig)
{
if (tokens < BUCKET_CAPACITY) {
tokens++;
}
}
static void install_refill_timer(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = refill_handler;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGALRM, &sa, NULL) == -1) {
perror("sigaction");
return;
}
struct itimerval timer;
timer.it_value.tv_sec = 0;
timer.it_value.tv_usec = REFILL_INTERVAL_US;
timer.it_interval.tv_sec = 0;
timer.it_interval.tv_usec = REFILL_INTERVAL_US;
if (setitimer(ITIMER_REAL, &timer, NULL) == -1) {
perror("setitimer");
}
}
static int try_acquire(void)
{
sigset_t block_set, old_set;
int acquired = 0;
sigemptyset(&block_set);
sigaddset(&block_set, SIGALRM);
sigprocmask(SIG_BLOCK, &block_set, &old_set);
// 临界区:检查并扣减令牌
if (tokens > 0) {
tokens--;
acquired = 1;
}
sigprocmask(SIG_SETMASK, &old_set, NULL);
return acquired;
}
int main(void)
{
install_refill_timer();
for (int i = 0; i < 15; i++) {
if (try_acquire()) {
printf("request %2d: allowed, tokens left: %d\n", i + 1, (int)tokens);
} else {
printf("request %2d: denied, tokens left: %d\n", i + 1, (int)tokens);
}
usleep(100000); // 每 100ms 发一个请求
}
return 0;
}
5.3 运行效果与预期输出
我实测跑出来的输出是这样:
text复制request 1: allowed, tokens left: 4
request 2: allowed, tokens left: 3
request 3: allowed, tokens left: 2
request 4: allowed, tokens left: 1
request 5: allowed, tokens left: 0
request 6: denied, tokens left: 0
request 7: denied, tokens left: 1
request 8: allowed, tokens left: 0
...
你注意 request 6 被拒后,request 7 又被拒了一次,但 tokens left: 1。这是因为请求间隔 100ms,SIGALRM 每 200ms 补 1 个令牌,所以从第 6 个请求开始,令牌补充节奏跟不上请求消耗节奏,请求会被间歇性拒绝。这正是限流想要的效果:桶里没有令牌就拒绝,有令牌就放行。
5.4 为什么临界区必须屏蔽SIGALRM
如果不屏蔽 SIGALRM,会发生什么?假设 tokens == 1,主流程读 tokens > 0 成立,就在它准备执行 tokens-- 的一瞬间,SIGALRM 来了,handler 看到 tokens < capacity,执行 tokens++,把 tokens 变成 2。然后被中断的主流程恢复,执行 tokens--,最终 tokens 从 1 变成 2。就这一次,多出了一个“凭空产生”的令牌。
如果请求量极大,这种竞态会在高并发下以毫秒级的概率反复出现,限流精确度完全归零。用信号屏蔽字把“检查+扣减”包成一个原子区间,是性价比最高的解决方案。
6. 踩完这些坑,我对信号机制才算真正放心
6.1 handler里调用printf:程序直接“假死”
我第一次写信号 handler 时图省事,直接在 refill_handler 里 printf("tokens=%d\n", tokens)。运行一会儿之后程序出现怪异现象:输出乱序、甚至卡死。根因是 printf 内部持有 stdio 锁,malloc 内部也有自己的锁。如果主流程正在 printf 的过程中信号打断它,进入 handler 再调用 printf,两个执行流试图获取同一个锁,直接就死锁了。
异步信号安全函数清单里,read、write、open、close、sig_atomic_t 读写是安全的,printf、malloc、free、pthread_mutex_lock 都不安全。实在要输出调试信息,用 write 写预格式化的字符串,或者干脆只置标志位。
6.2 屏蔽10秒后只补了1个令牌:标准信号不排队
我在 5.2 节的实现里做过一个极端测试:把 try_acquire 的临界区手动延长到 10 秒,期间定时器每 200ms 触发一次,按理论应该补 50 个令牌,但解除屏蔽后 tokens 只加 1。原因就是我在第 2 节强调过的:标准信号不排队,同一个信号在 pending 集合里只有一个比特位,多次产生只算一次。
如果你需要精确计数,两个方向:
- 换用
timerfd,它每次读取能拿到“自上次读取以来的到期次数”,不会合并。 - 或者在 handler 里把多个到期事件累加到
sig_atomic_t变量上,但要注意sig_atomic_t只保证单个读写的原子性,不保证tokens++这类复合操作的原子性。考虑到 handler 不会和主流程并发执行同一条指令,volatile sig_atomic_t的累加实际是可行的,但需要配合屏蔽字保护主流程的读取。
6.3 volatile和sig_atomic_t缺一不可
我在代码里写的 static volatile sig_atomic_t tokens,三个修饰缺一不可:
sig_atomic_t保证 CPU 在单条指令内完成读写,不会读到写到一半的脏值。volatile提醒编译器:这个变量可能在信号处理函数里被修改,禁止优化到寄存器里。
如果你漏了 volatile,编译器可能把 tokens 的值缓存在寄存器里,主循环循环一百次都看不到 handler 的更新。这个问题在 -O2 编译优化下特别容易复现,信号来了桶也不补,找半天找不出原因。
6.4 SA_RESTART与EINTR:read被信号打断后飞了
信号打断慢系统调用是另一个高频坑。默认情况下,进程阻塞在 read、write、accept 这些系统调用时,如果有信号递达,系统调用会返回错误码 EINTR,代码如果没做重试处理,连接就莫名其妙没了。
解决方式有两种:
- 注册信号时
sa.sa_flags = SA_RESTART,让内核在 handler 执行完后自动重启被打断的系统调用。 - 不设置
SA_RESTART,代码里收到EINTR后手动重试。
我平时处理“己方可控的信号”都用 SA_RESTART。但有一个特例:如果我用 poll/epoll 配合超时控制,就不设置 SA_RESTART,因为我希望超时信号能把阻塞中的 poll 打断,让它立刻返回 EINTR 去检查超时队列。两个方向都能用,关键是想清楚信号在这里的角色是“辅助回调”还是“主动中断”。
6.5 多线程程序中要用pthread_sigmask
sigprocmask 只对当前线程有效的说法不太严谨——在多线程进程中,sigprocmask 的行为是未定义的,必须用 pthread_sigmask 来操作每个线程的屏蔽字。信号发给“进程”时,默认由任意一个未屏蔽该信号的线程处理;如果你希望某个线程专门处理信号,就在其他线程里全屏蔽。我做的服务是单线程事件循环,所以上面的代码够用;一旦引入线程池,就要立刻换 pthread_sigmask,别踩这个坑。
6.6 什么时候别用信号:高精度与多事件场景用timerfd
最后说个选型的边界问题。setitimer 加信号这套组合,优点是简单、可移植性好,适合粗粒度的限流、超时判断。但它的时间精度受内核调度影响,而且 handler 限制太多。我的转发服务后来把计时器全部换成了 timerfd_create + epoll,一个 fd 就能承载所有定时器的到期通知,还能直接读出到期次数,彻底摆脱了 pending 合并和 EINTR 的烦恼。
不过这不意味着信号机制白学了。恰恰相反,限流器上那套“信号屏蔽字保护共享变量”的思路,我后来用在线程锁、原子队列实现里也完全适用。信号机制是 Linux 系统编程的地基,你把地基踩实了,后面怎么盖楼都稳。
如果你手头也遇到过“服务被莫名打断”“限流失效”“定时任务不准”这类问题,建议先把这段令牌桶代码跑一遍,再把 handler 换回 printf,亲手观察它怎么坏。踩过一次坑,比看十篇文档都管用。
