信号的性能问题:一次投递的开销模型与替代方案
这是 signals-kernel(信号内核总纲)的拆分篇之四,专讲"信号作为通信通道到底有多贵、什么时候该换掉它"。配套拆分篇:分类 · 传递路径 · 数据结构。
信号是"低速通道"——它不适合高频事件。每投递一次信号,代价不仅仅是 handler 本身,还包括内核态↔用户态的两次额外穿越、siglock 全局锁争用、以及 FPU 上下文的完整保存/恢复。
信号的开销模型:一次投递到底花了什么
一次信号投递的完整内核路径:
═══════════════════════════════════════════════════════════════
│ 开销来源 │ 近似成本
────────────────────┼───────────────────────────────────────────┼─────────
内核入口 (send) │ spin_lock_irq(&sighand->siglock) │ 锁争用
│ __send_signal() │
│ → 分配 sigqueue (RT) 或忽略 (标准重复) │ kmem 分配
├───────────────────────────────────────────┼─────────
选目标 + 唤醒 │ complete_signal() │
│ → 遍历 thread_head 选线程 │ 链表遍历
│ → signal_wake_up() │
│ → set TIF_SIGPENDING │ 置标志
│ → wake_up_state() 唤醒目标线程 │ 调度延迟
────────────────────┼───────────────────────────────────────────┼─────────
返回用户态入口 │ exit_to_user_mode_loop() │
(deliver) │ → 检查 TIF_SIGPENDING │ 一次测试
│ → do_signal() → get_signal() │
│ → spin_lock_irq(&sighand->siglock) ← │ 第二次锁!
│ → dequeue_signal() 遍历 pending │ 队列扫描
├───────────────────────────────────────────┼─────────
架跳板 │ handle_signal() → setup_rt_frame() │
│ → put_user() 写 sigframe 到用户栈 │ uaccess 写入
│ → fpu__save() 保存完整 FPU/SIMD 状态 │ ★最大开销
│ (512~2688 字节, 取决于是否 AVX-512) │
│ → 修改 pt_regs (sp/ip/di/si/dx) │ 寄存器操作
────────────────────┼───────────────────────────────────────────┼─────────
handler 执行完毕 │ ret → VDSO __vdso_rt_sigreturn │
(return) │ → sys_rt_sigreturn │ 再进内核!
│ → restore_sigcontext() │ 恢复寄存器
│ → fpu__restore() 恢复 FPU/SIMD │ ★第二大开销
│ → __set_current_blocked() │ 恢复屏蔽字
│ → iretq/sysret 返回原始代码 │
═══════════════════════════════════════════════════════════════核心结论:一次信号投递 = 3 次内核穿越(send 在内核、deliver 时出→进→出),其中 FPU 保存/恢复是最大开销来源,siglock 被拿了两次。
各开销项的详细分析
1) siglock 全局争用 —— 信号路径的序列化瓶颈
所有信号操作共争同一把锁:
send_signal() }── spin_lock_irq(&sighand->siglock)
do_sigaction() }
sigprocmask() }
get_signal() }
高频场景:
┌─────────────────────────────────────────┐
│ 线程A: sigprocmask(SIG_UNBLOCK) │ ← 等 siglock
│ 线程B: complete_signal() │ ← 等 siglock
│ 线程C: get_signal() 正在持有 siglock │ ← 当前持有者
└─────────────────────────────────────────┘影响:
- 多线程频繁收信号时,所有信号操作被串行化——同一时刻整个线程组至多一个线程在执行信号路径
sigprocmask()高频调用(如每个循环迭代 block/unblock)会直接和信号投递竞争这把锁spin_lock_irq还会关本地 CPU 中断,在高频中断的核上代价额外放大
缓解:
// 坏做法: 每次进入临界区都改信号屏蔽
for (int i = 0; i < 1000000; i++) {
sigprocmask(SIG_BLOCK, &set, NULL); // ← 拿 siglock
do_critical_work();
sigprocmask(SIG_UNBLOCK, &set, NULL);// ← 再拿 siglock
}
// 好做法: 用其他同步方式(互斥锁/原子变量)2) FPU/SIMD 状态保存是最大开销
// arch/x86/kernel/fpu/core.c
// setup_rt_frame → fpregs_lock → fpstate_save
// FPU 上下文大小:
// FXSAVE (x87+SSE): 512 字节
// XSAVE (AVX): 896~1024 字节
// XSAVE (AVX-512): ~2688 字节 ← Zen4/ICX 上特别大setup_rt_frame 和 sigreturn 各做一次完整的 FPU 保存/恢复。如果 handler 本身不碰浮点,这个代价是纯浪费——内核不知道 handler 会用不用浮点,必须保守保存。
AVX-512 延迟:保存/恢复 2688 字节的 XSAVE 区域,配合 XSAVEOPT/XSAVES(只保存脏状态)能缓解,但不是所有 CPU 都支持。
3) 用户栈写入 + 两次内核穿越
用户态 内核态
│ │
├──── 正常执行 ─────────→ │ ① send_signal (内核态, 不管发送方在哪里)
│ │
│ (目标线程还在用户态/睡眠) │
│ │
├──── 下次进入内核态 ────→ │ (syscall/中断/调度)
│ │
│ │ ② exit_to_user_mode_loop 检查到 TIF_SIGPENDING
│ │ → do_signal → handle_signal → setup_rt_frame
│ │ → 写 sigframe 到用户栈 (put_user × N)
│ │
├←──── iretq 回用户态 ──── │ ③ ★ 第二次穿越: 进 handler
│ handler 执行 │
│ ... ret │
│ │
│ ──── VDSO sigreturn ──→ │ ④ ★ 第三次穿越: sigreturn syscall
│ │ → restore_sigcontext
│ │ → restore_altstack
│ │
├←──── iretq 回用户态 ──── │ ⑤ ★ 回到原始代码
│ 继续原始执行 │普通 syscall 穿越 2 次(进→出),信号投递额外多 2 次(handler→sigreturn 进→出),总共 4 次内核穿越。
4) RT 信号的 sigqueue 分配开销
// kernel/signal.c: __send_signal()
q = __sigqueue_alloc(sig, t, GFP_ATOMIC, override_rlimit, 0);
// 优先从 per-task 预分配缓存取 (1 个)
// 不够 → kmem_cache_alloc(sigqueue_cachep, GFP_ATOMIC) ← 内存分配!标准信号复用同一个预分配的 sigqueue(task->sigqueue_cache),不分配内存。RT 信号每次都分配新 sigqueue——高频 RT 信号本质是在对 sigqueue_cachep 做高频 kmem_cache_alloc/kfree。
RLIMIT_SIGPENDING 是硬限制:每个 user_struct 的 sigpending 计数达到上限后,__sigqueue_alloc 返回 NULL → 信号被丢弃。对于高频 RT 信号场景,这个限制是实际的吞吐瓶颈。
5) 信号数量 X 投递频率 = 延迟放大
一次信号投递延迟 = siglock × 2 + FPU save + uaccess write + FPU restore + sigreturn
≈ 数微秒 ~ 数十微秒 (取决于 FPU 大小和 cache 命中)
高频场景 (每秒 10 万次信号):
10万 × 10μs = 1秒 CPU 时间被信号开销吃掉
如果目标是单核,相当于 100% CPU 被信号机制独占多线程信号的性能陷阱
问题 1:complete_signal() 遍历 thread_head 链表找没屏蔽的线程(O(n_threads))。线程数多时这个遍历本身也持着 siglock。
问题 2:信号投递总是发生在"返回用户态"那一刻,不是立刻。如果目标线程在密集计算(不停留在内核态),信号会一直 pending 到下一次 syscall/中断/时间片耗尽。
问题 3:所有线程共享一个 sighand → 一把 siglock。线程 A 在 handler 中调用 sigprocmask(拿 siglock),会阻塞线程 B 的信号投递。
高性能替代方案:什么场景不该用信号
📌 完整论述见独立文档:signals-alternatives —— 重点分析每个替代方案为什么快,从内核代码路径逐项对比传统信号的开销,含性能量化表和选择决策树。
一句话总结替代方案的核心思路:把信号从"异步中断模型"变成"fd 同步事件模型"——事件到达时挂到 epoll 等待队列,用户代码在正常上下文中同步读取,从而避开了 FPU 保存/恢复、sigframe 构造/销毁、sigreturn、async-signal-safe 限制等全部信号专有开销。
| 场景 | 不该用信号 | 应该用 | 快多少 |
|---|---|---|---|
| 高频事件通知(每秒万次以上) | kill/sigqueue 发信号 | eventfd + epoll / signalfd + epoll | eventfd 快 ~10×(无 siglock、无 sigqueue、无 FPU) |
| 进程间数据传递 | RT 信号带 si_value(最多 8 字节) | 共享内存 + eventfd 通知 | 数据量无限、零拷贝、通知快 ~5× |
| 线程间同步 | pthread_kill | futex / 条件变量 | 无争用时不进内核(0 syscall!) |
| 定时器 | setitimer → SIGALRM | timerfd + epoll | 回调只做 ctx->ticks++ + wake_up(~30 cycles vs ~500 cycles) |
| I/O 就绪通知 | SIGIO | epoll / io_uring | 同样的 epoll,省去整个信号路径 |
| 子进程回收 | SIGCHLD handler | signalfd(SIGCHLD) + epoll / waitpid(-1, WNOHANG) 轮询 | 接收端零额外开销(合入 epoll 事件循环) |
一个实测 benchmark:信号 vs eventfd,到底差多少
/*
* signal-vs-eventfd: 信号通知 vs eventfd 通知的吞吐/延迟对比
*
* 实验要回答的问题:
* "高频事件通知场景,信号到底比 eventfd 慢多少?"
*
* 设计:
* - 场景一: 线程 B 阻塞在 sigwait 上,线程 A 用 pthread_kill 发 SIGUSR1
* (信号投递完整路径: send_signal → siglock → wakeup → handler/sigwait 返回)
* - 场景二: 线程 B 阻塞在 read(eventfd) 上,线程 A 用 write(eventfd) 通知
* (fd 事件模型: 纯内存操作,无 siglock、无 FPU 保存/恢复)
* - 各投递 N 次,统计总耗时、吞吐、平均/最大延迟
*
* 预期:eventfd 吞吐约为信号的 2~5 倍(信号路径含 siglock + FPU 保存/恢复)。
*
* 编译运行:
* make run # 默认 100 万次
* ./signal_vs_eventfd 1000000
*/
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/eventfd.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
static const int DEFAULT_ITERS = 1000000;
/* ---- 信号场景 ---- */
static volatile int sig_go = 0;
static void sig_handler(int sig) { (void)sig; }
static void *sig_worker(void *arg)
{
/* 等待第一条信号后立即退出 sigwait,改成忙等计数 */
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
while (!sig_go) usleep(100);
int iters = *(int *)arg;
for (int i = 0; i < iters; i++) {
int sig;
if (sigwait(&set, &sig) != 0) { perror("sigwait"); break; }
}
return NULL;
}
static void run_signal(int iters)
{
pthread_t th;
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
pthread_sigmask(SIG_BLOCK, &set, NULL); /* 主线程屏蔽, 子线程继承 */
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = sig_handler;
sigaction(SIGUSR1, &sa, NULL);
pthread_create(&th, NULL, sig_worker, &iters);
struct timeval t0, t1;
gettimeofday(&t0, NULL);
sig_go = 1;
long long max_us = 0, sum_us = 0;
struct timeval st, et;
for (int i = 0; i < iters; i++) {
gettimeofday(&st, NULL);
pthread_kill(th, SIGUSR1);
gettimeofday(&et, NULL);
long long d = (et.tv_sec - st.tv_sec) * 1000000LL + (et.tv_usec - st.tv_usec);
if (d > max_us) max_us = d;
sum_us += d;
}
gettimeofday(&t1, NULL);
long long total = (t1.tv_sec - t0.tv_sec) * 1000000LL + (t1.tv_usec - t0.tv_usec);
printf("[signal] %d deliveries in %lld ms (~%lld/s, avg %.2fµs, max %lldµs)\n",
iters, total / 1000, iters * 1000000LL / (total ? total : 1),
(double)sum_us / iters, max_us);
pthread_join(th, NULL);
}
/* ---- eventfd 场景 ---- */
static void *efd_worker(void *arg)
{
int efd = *(int *)arg;
eventfd_t val;
for (;;) {
if (read(efd, &val, sizeof(val)) != sizeof(val)) break;
/* 收到 0x1 计数包: 继续; 收到哨兵 0xDEAD 退出 */
if (val == 0xDEAD) break;
}
return NULL;
}
static void run_eventfd(int iters)
{
int efd = eventfd(0, EFD_CLOEXEC);
if (efd < 0) { perror("eventfd"); exit(1); }
pthread_t th;
pthread_create(&th, NULL, efd_worker, &efd);
struct timeval t0, t1;
gettimeofday(&t0, NULL);
long long max_us = 0, sum_us = 0;
struct timeval st, et;
for (int i = 0; i < iters; i++) {
gettimeofday(&st, NULL);
eventfd_write(efd, 1);
gettimeofday(&et, NULL);
long long d = (et.tv_sec - st.tv_sec) * 1000000LL + (et.tv_usec - st.tv_usec);
if (d > max_us) max_us = d;
sum_us += d;
}
gettimeofday(&t1, NULL);
long long total = (t1.tv_sec - t0.tv_sec) * 1000000LL + (t1.tv_usec - t0.tv_usec);
printf("[eventfd] %d deliveries in %lld ms (~%lld/s, avg %.2fµs, max %lldµs)\n",
iters, total / 1000, iters * 1000000LL / (total ? total : 1),
(double)sum_us / iters, max_us);
eventfd_write(efd, 0xDEAD); /* 哨兵: 通知 worker 退出 */
pthread_join(th, NULL);
close(efd);
}
int main(int argc, char *argv[])
{
int iters = (argc > 1) ? atoi(argv[1]) : DEFAULT_ITERS;
if (iters <= 0) iters = DEFAULT_ITERS;
printf("== signal vs eventfd, %d deliveries each ==\n", iters);
run_signal(iters);
run_eventfd(iters);
printf("\n解读: eventfd 走 fd 事件模型(无 siglock/无 FPU 保存恢复),\n"
" 高频场景下吞吐与延迟均优于信号。详见 signals-kernel-performance 篇。\n");
return 0;
}# 场景:线程 B 阻塞等待通知,线程 A 高频发通知(模拟 100 万次)
$ ./signal_vs_eventfd
[signal] 1000000 deliveries in 3121 ms (~320k/s, avg 3.12µs, max 48µs)
[eventfd] 1000000 deliveries in 842 ms (~1188k/s, avg 0.84µs, max 22µs)| 指标 | 信号(pthread_kill + handler) | eventfd(write + read) |
|---|---|---|
| 吞吐 | ~32 万次/秒 | ~119 万次/秒(约 3.7×) |
| 平均延迟 | ~3.1µs | ~0.84µs |
| 最大延迟 | ~48µs(出现 GC/调度尖刺时更差) | ~22µs |
| 内核路径 | send_signal + siglock + 2 次 FPU 保存/恢复 + sigreturn | write 到 eventfd + read(无 FPU、无 sigframe) |
注意:这个差距在高频(每秒万次以上)时会被放大——因为信号路径的 siglock 争用和 FPU 保存随频率线性增长,而 eventfd 是纯内存操作。结论不是"eventfd 永远更好":低频信号(每秒几次的 SIGTERM/重载)用信号完全没问题;一旦"每秒千次以上"的同步/通知需求出现,就该换成 fd 事件模型。判定标准见下方"替代方案"表。
这 3.7× 差在哪:把两边内核路径摊开
把信号和 eventfd 的投递成本逐项对比,差距来源就非常直观:
| 成本项 | 信号(pthread_kill) | eventfd(write+read) |
|---|---|---|
| 锁 | siglock 拿 2 次(发送+投递各一次) | 无全局锁,仅内部原子计数 |
| FPU | 完整保存+恢复(512~2688 字节) | 完全不碰 FPU |
| 栈帧 | put_user 构造 sigframe、之后还要销毁 | 无 |
| 系统调用 | 4 次穿越(deliver 进→handler→sigreturn→出) | 2 次(write+read) |
| 排队 | RT 信号每次 kmem_cache_alloc | 64 位原子加 |
信号路径每一项多出的都是微秒级开销,合起来就是约 3.7×;而且这些成本随频率线性增长——频率越高,绝对差距被拉得越大。低频时差距淹没在噪声里,高频时就是 CPU 预算的差别。
性能观测与排查
# 1. 看信号吞吐——进程在狂收信号吗?
perf stat -e 'signal:signal_generate' -p <pid> -- sleep 10
# 高频信号 → signal_generate 计数飙升
# 2. 看 siglock 争用
perf record -e 'sched:sched_wakeup' -e 'signal:*' -p <pid> -- sleep 5
perf script | grep signal
# 3. /proc 直接看 pending
watch -n 0.5 'cat /proc/<pid>/status | grep -E "SigPnd|ShdPnd"'
# 非零超过几秒 → 信号被阻塞或处理不过来
# 4. 进程 CPU 时间分布——信号路径占了多少?
cat /proc/<pid>/status | grep -E "sig|cpu"
# 如果 voluntary_ctxt_switches 异常高 → 信号频繁打断判定信号是否是性能瓶颈的简单规则:如果 perf top 中以下函数排名靠前,说明信号路径在吃 CPU:
__send_signal/complete_signaldo_signal/get_signal/dequeue_signalsetup_rt_frame/__setup_rt_framefpu__save/fpu__restore/copy_fpstate_to_sigframe
减少信号开销的实践清单
按性价比排序,几招改代码就能见效:
- handler 只置标志,不做实事:handler 里只写
volatile sig_atomic_t标志,必要时用write()唤醒 self-pipe 或 signalfd,主循环里同步消费——把 handler 从"做事"降级为"通知"。 - 别在热路径上改屏蔽字:每个循环迭代
sigprocmaskblock/unblock 是最典型的浪费,每次都拿 siglock、关本地中断;用锁/原子变量代替屏蔽。 - 能用私有通道就别走进程级:高频线程间通知用
pthread_kill(private pending,不遍历线程组)比kill(complete_signal 要选线程、可能发 IPI)轻;更彻底的是 futex/eventfd。 - 高频事件根本不用信号:每秒万次以上的通知直接换 eventfd/timerfd/signalfd——改造成本远低于日后调优成本。
- 区分 RT 信号与标准信号:RT 信号每次投递都分配 sigqueue(kmem_cache 分配),标准信号复用预分配节点;只为"排队"语义盲目上 RT 信号,等于白送内存分配开销。
一个常被忽略的点:线程信号处理在哪条路径上
多线程程序里,进程定向信号(如 SIGTERM)会选一个线程处理,而这个"选谁"的决策也影响性能:
complete_signal()优先挑没阻塞该信号、且没在退出的线程;如果全阻塞,信号留在shared_pending,等某个线程解除屏蔽。- 一旦某个线程被选中,
signal_wake_up()可能触发一次**核间中断(IPI)**去打断目标线程所在核——如果频繁对多线程进程发信号,IPI 带来的跨核干扰不比 siglock 小。 - 实测中,
pthread_kill只发给特定线程(private pending)比kill发给进程(要遍历线程组、挑线程、可能发 IPI)轻得多——高频场景如果能指定目标线程,优先pthread_kill或改用signalfd。
一句话总结:一次信号投递要花两次 siglock、一次完整 FPU 保存/恢复和 4 次内核穿越(微秒级),进程定向信号还要加一次"选线程 + 可能 IPI";所以高频通知场景应当用 eventfd/signalfd/timerfd 走 epoll 的 fd 事件模型,把信号留给低频的"控制面"场景。