信号处理是C++程序绕不开的话题。无论是让程序优雅响应Ctrl+C退出,还是捕获段错误打印诊断日志,都离不开对POSIX信号机制的理解。但很多写C++的同学把信号当成普通函数调用来对待,结果在信号处理函数里做了一些不该做的操作,程序随机崩溃却查不到原因。这篇文章就来系统讲清楚C++中处理信号的正确姿势,以及信号与C++异常之间那层微妙又危险的关系。

一、POSIX信号基础:signal与sigaction的选择
在Linux和类Unix系统上,注册信号处理函数主要有两个接口:早期的signal和后来的sigaction。两者的核心区别在于对信号语义的控制能力。signal的行为在不同平台上并不完全一致,比如收到信号后处理函数会不会被自动重置、系统调用会不会被自动重启,这些细节历史上各家UNIX实现各不相同。POSIX后来规范了sigaction,它通过struct sigaction结构体把所有可配置项显式暴露出来,行为完全可预期,因此在生产代码中应当优先使用sigaction。
下面是一个用sigaction注册SIGINT处理函数的典型写法:
#include <csignal>
#include <cstdio>
void onSigint(int signo) {
// 信号处理函数中只能做异步信号安全的操作
const char msg[] = "caught SIGINT\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}
int main() {
struct sigaction sa;
sa.sa_handler = onSigint;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // 被信号打断的系统调用自动重启
if (sigaction(SIGINT, &sa, nullptr) != 0) {
perror("sigaction");
return 1;
}
printf("press Ctrl+C to test\n");
while (true) {
pause(); // 等待信号到来
}
return 0;
}
几个值得注意的细节:SA_RESTART标志能让被信号打断的read、write等慢速系统调用自动重启,避免上层代码莫名其妙收到EINTR错误。而sa_mask指定了处理函数执行期间需要额外屏蔽的信号集合,防止处理逻辑被其他信号再次打断。如果使用signal注册,这些控制都无法精确指定,只能听天由命。
另外还有两个特殊设置常被用到:SIG_IGN表示忽略信号,SIG_DFL表示恢复默认行为。比如父进程fork之后、exec之前,通常会手动屏蔽或重置信号处理,避免把父进程的处理器状态带入新程序。
二、信号处理函数的异步安全限制
信号可能在任意时刻到达,也就是说处理函数会在任意两条指令之间被插入执行,打断的可能是标准库函数执行到一半的内部状态。这就引出异步信号安全(async-signal-safe)的概念:POSIX只保证一小部分函数可以在信号处理函数中安全调用,包括write、_exit、sigaction、kill等,而printf、malloc、free这些常见函数统统不在安全名单里。
道理不难理解。假设主线程正在malloc内部修改堆的空闲链表,修改到一半时信号到来,处理函数里又调用了一次malloc,堆结构被两个执行流交叉读写,很容易直接破坏。同理,C++的std::cout、流插入运算符、任何可能加锁或分配内存的操作,都不应该出现在信号处理函数中。实践中推荐的模式是:处理函数只写一个volatile sig_atomic_t标志变量,业务逻辑放在主循环里轮询这个标志。
#include <csignal>
#include <cstdio>
#include <unistd.h>
volatile sig_atomic_t g_stop = 0;
void onSigterm(int) {
g_stop = 1; // 原子整数赋值是安全的
}
int main() {
struct sigaction sa{};
sa.sa_handler = onSigterm;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGTERM, &sa, nullptr);
sigaction(SIGINT, &sa, nullptr);
while (!g_stop) {
// 正常业务逻辑,可以放心使用任何库函数
do_work();
sleep(1);
}
printf("graceful shutdown\n");
return 0;
}
这里volatile阻止编译器把变量缓存在寄存器里,sig_atomic_t保证读写操作不可分割。注意C++11之后,用std::atomic<int>配合std::atomic_signal_fence也是可行方案,但在纯信号场景下传统写法更简单直接。
三、信号处理函数中能抛C++异常吗
这是最容易踩坑的一点,答案很明确:不能。C++标准规定,从信号处理函数向外抛出异常是未定义行为。原因在于信号处理不是普通的函数调用,它没有独立的调用栈帧,异常展开机制依赖栈展开找到最近的catch块,而信号到来时打断的代码可能正处于任何状态,栈展开过程本身可能调用不可重入的运行时函数,结果就是程序崩溃或状态损坏。
来看一段错误示范:
#include <csignal>
#include <cstdio>
void badHandler(int) {
// 错误做法:在信号处理函数中抛异常
throw std::runtime_error("signal received");
}
int main() {
try {
std::signal(SIGSEGV, badHandler);
int* p = nullptr;
*p = 42; // 触发SIGSEGV
} catch (const std::exception& e) {
// 实际上大概率到不了这里,程序可能直接abort
std::printf("caught: %s\n", e.what());
}
return 0;
}
这段代码在glibc环境下往往表现为直接abort或者死循环,因为SIGSEGV返回后故障指令会重新执行,再次触发信号。那如果确实希望用异常的思路统一处理信号呢?正确的做法是把信号事件同步化:处理函数只设置标志或写入管道,由专门的线程把事件转换成正常的函数调用甚至异常抛出。
四、用sigwait或管道把信号转回正常执行流
多线程程序中还有一条更优雅的路线。Linux规定信号只会递交给某一个线程,通常的做法是在主线程启动前屏蔽目标信号,这样所有工作线程都会继承屏蔽字,信号永远无法实际递交,然后开一个专用线程调用sigwait同步等待。此时信号处理完全发生在普通线程的普通函数调用中,前面说的所有异步安全限制都不复存在,你可以自由使用C++异常、日志库、任何你想用的东西。
#include <csignal>
#include <pthread.h>
#include <cstdio>
int main() {
// 在创建任何线程之前屏蔽目标信号
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTERM);
pthread_sigmask(SIG_BLOCK, &set, nullptr);
// 专用信号处理线程
pthread_t tid;
pthread_create(&tid, nullptr, [](void*) -> void* {
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTERM);
int signo = 0;
while (true) {
sigwait(&set, &signo); // 同步等待,不涉及异步处理
printf("signal %d received, do cleanup\n", signo);
if (signo == SIGINT || signo == SIGTERM) {
// 这里可以安全抛异常、调用任意库函数
break;
}
}
return nullptr;
}, nullptr);
// 启动业务线程
start_worker_threads();
pthread_join(tid, nullptr);
printf("process exit\n");
return 0;
}
这种模式是很多服务框架(比如nginx、各类RPC框架)的标准做法:主线程或专用线程同步收信号,触发优雅停机流程,先停止接收新请求,等待存量任务处理完,再释放资源退出。整个流程都在正常代码路径上执行,可以充分利用RAII、智能指针和异常机制。
另一种自管道技巧(self-pipe)则适合单线程事件循环场景:处理函数里只调用write向管道写一个字节,事件循环的poll或epoll监听管道读端,收到数据后回到主循环处理。libevent等库内部就是这么实现信号事件集成的,思路本质上是把异步信号翻译成普通的IO事件。
五、实战建议与常见误区总结
结合上面的分析,可以归纳出几条工程实践原则。第一,注册处理函数一律用sigaction而不是signal,显式设置SA_RESTART和屏蔽字。第二,处理函数体越短越好,理想状态是只写一个标志变量或一次write调用,绝不调用printf、malloc、STL容器操作。第三,处理SIGSEGV、SIGBUS这类硬件故障信号时要格外谨慎,这类信号意味着程序状态已经损坏,即使转成标志也可能来不及处理,更现实的目标是用backtrace配合sigaltstack(备用信号栈)打印崩溃现场供事后分析。
还有几个常见误区值得点名。有人喜欢在处理函数里调用exit做清理,注意exit并非异步信号安全,正确选择是_exit或者回到主循环后正常退出。有人在多线程程序里不屏蔽信号就直接注册处理函数,结果同一个信号随机递交给不同线程,日志混乱不堪。还有人试图在处理函数里加互斥锁,如果信号恰好打断了一个已持有该锁的线程,处理函数会立刻死锁,这属于典型的自锁陷阱。
最后强调一点,C++标准委员会一直有意把信号处理排除在标准异常体系之外,C++23也依然如此。信号是操作系统层面的异步机制,C++异常是语言层面的同步控制流,两者的执行模型根本不同。把两者隔离开,用标志、sigwait或管道做桥接,才是既符合标准又健壮可靠的设计。理解了这一点,你在写任何需要响应信号的C++程序时都会心中有数。
C++信号处理POSIX信号signal handler修改时间:2026-09-06 10:58:49