1. Linux信号机制基础概念
信号是Linux系统中进程间通信的一种基本机制,它允许进程或内核向另一个进程发送异步通知。当某个事件发生时(比如用户按下Ctrl+C),内核会向相关进程发送信号,进程可以捕获并处理这些信号,也可以选择忽略或执行默认动作。
信号机制的核心特点包括:
- 异步性:信号可以在任何时候发送给进程,进程无法预知信号到达的具体时间
- 轻量级:相比其他IPC机制,信号的开销非常小
- 有限数量:Linux支持的标准信号只有31个(1-31),还有一组实时信号(32-64)
在Linux中,每个进程都有一个信号处理表,定义了当接收到特定信号时应采取的动作。可能的动作包括:
- 忽略信号(SIG_IGN)
- 执行默认动作(SIG_DFL)
- 调用自定义的信号处理函数
注意:SIGKILL(9)和SIGSTOP(19)这两个信号不能被捕获、阻塞或忽略,这是为了确保系统管理员始终有办法终止失控的进程。
2. 信号集与信号屏蔽字
2.1 信号集的数据结构
Linux使用sigset_t类型来表示信号集,这是一个位掩码结构,每个位对应一个信号。在glibc的实现中,sigset_t通常是一个32位或64位的无符号整数,足以容纳所有标准信号和实时信号。
常用的信号集操作函数包括:
- sigemptyset():初始化一个空信号集
- sigfillset():初始化包含所有信号的信号集
- sigaddset():向信号集中添加一个信号
- sigdelset():从信号集中删除一个信号
- sigismember():测试某个信号是否在信号集中
2.2 信号屏蔽字(阻塞信号集)
信号屏蔽字(signal mask)决定了当前哪些信号被阻塞。当一个信号被阻塞时,如果它被发送给进程,该信号将保持未决状态,直到解除阻塞。
设置信号屏蔽字的常用方法:
- 使用sigprocmask()函数
- 在信号处理函数中使用pthread_sigmask()(多线程环境下)
- 通过sigaction()设置SA_NODEFER标志
c复制#include <signal.h>
int main() {
sigset_t new_mask, old_mask;
// 初始化新的信号屏蔽字
sigemptyset(&new_mask);
sigaddset(&new_mask, SIGINT);
// 设置信号屏蔽字,并保存旧的
sigprocmask(SIG_BLOCK, &new_mask, &old_mask);
// 在这段代码中,SIGINT将被阻塞
// ...
// 恢复旧的信号屏蔽字
sigprocmask(SIG_SETMASK, &old_mask, NULL);
return 0;
}
提示:在多线程程序中,应该使用pthread_sigmask()而不是sigprocmask(),因为信号屏蔽字是线程级别的属性。
3. 未决信号集的工作原理
3.1 未决信号集的定义
未决信号集(pending signal set)是内核维护的一个数据结构,记录了已经发送给进程但尚未被处理的信号。当信号被阻塞时,它会被添加到未决信号集中;当信号被解除阻塞时,内核会检查未决信号集并递送这些信号。
可以使用sigpending()函数获取当前进程的未决信号集:
c复制#include <signal.h>
#include <stdio.h>
int main() {
sigset_t pending;
// 阻塞SIGINT
sigset_t new_mask;
sigemptyset(&new_mask);
sigaddset(&new_mask, SIGINT);
sigprocmask(SIG_BLOCK, &new_mask, NULL);
// 发送SIGINT给自己
raise(SIGINT);
// 获取未决信号集
sigpending(&pending);
if (sigismember(&pending, SIGINT)) {
printf("SIGINT is pending\n");
} else {
printf("SIGINT is not pending\n");
}
return 0;
}
3.2 未决信号的生命周期
- 信号产生:由内核、其他进程或进程自身产生
- 信号递送:如果信号未被阻塞,内核立即将其递送给目标进程
- 信号阻塞:如果信号被阻塞,内核将其标记为未决
- 解除阻塞:当信号被解除阻塞时,内核检查未决信号集并递送信号
- 信号处理:进程执行信号处理程序或默认动作
注意:对于标准信号(1-31),如果同一个信号在未决期间被多次发送,内核只会保留一个实例。而对于实时信号(34-64),相同信号的不同实例都会被保留并按顺序递送。
4. 信号处理的高级话题
4.1 信号处理函数的可重入性
信号处理函数必须是可重入的(reentrant),因为信号可能在程序执行的任何时刻到达。这意味着信号处理函数中只能调用异步信号安全的函数(async-signal-safe functions)。
常见的非安全操作包括:
- 调用malloc()或free()
- 使用标准I/O函数(如printf)
- 修改全局变量(除非是sig_atomic_t类型)
4.2 信号处理与系统调用
当进程在执行系统调用时收到信号,系统调用可能会被中断并返回EINTR错误。现代Linux系统提供了SA_RESTART标志,可以在信号处理完成后自动重启被中断的系统调用。
c复制struct sigaction sa;
sa.sa_handler = handler_func;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // 自动重启被中断的系统调用
sigaction(SIGINT, &sa, NULL);
4.3 实时信号的处理
实时信号(SIGRTMIN到SIGRTMAX)相比标准信号有以下优势:
- 支持排队:相同信号的多个实例会被保留
- 携带附加信息:可以通过siginfo_t结构传递更多数据
- 优先级:数值小的信号优先级高
使用实时信号的示例:
c复制#include <signal.h>
#include <stdio.h>
#include <string.h>
void rt_handler(int sig, siginfo_t *info, void *context) {
printf("Received RT signal %d with value %d\n", sig, info->si_value.sival_int);
}
int main() {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = rt_handler;
sa.sa_flags = SA_SIGINFO;
sigemptyset(&sa.sa_mask);
sigaction(SIGRTMIN, &sa, NULL);
// 发送带有数据的实时信号
union sigval value;
value.sival_int = 1234;
sigqueue(getpid(), SIGRTMIN, value);
return 0;
}
5. 实际应用中的注意事项
5.1 避免信号丢失
由于标准信号不排队,在信号处理函数执行期间到达的相同信号可能会丢失。解决方法包括:
- 在信号处理函数中阻塞相同信号
- 使用实时信号代替标准信号
- 使用自管道技巧(self-pipe trick)
5.2 正确处理EINTR
所有可能被信号中断的系统调用都应该检查EINTR错误并适当处理:
c复制int ret;
do {
ret = read(fd, buf, count);
} while (ret == -1 && errno == EINTR);
5.3 多线程环境下的信号处理
在多线程程序中:
- 信号处理是进程范围的,但信号屏蔽字是线程范围的
- 信号可以被定向到特定线程(通过pthread_kill())
- 主线程通常负责设置信号处理函数,其他线程负责阻塞/解除阻塞信号
5.4 信号与竞态条件
信号处理可能引入竞态条件,特别是在修改全局变量时。解决方法包括:
- 使用sig_atomic_t类型的变量
- 使用互斥锁(但要注意死锁风险)
- 避免在信号处理函数和主程序之间共享数据
6. 性能优化与调试技巧
6.1 减少信号处理的开销
信号处理会引入上下文切换开销,优化方法包括:
- 合并信号处理:将多个信号的处理合并到一个函数中
- 使用signalfd():将信号转换为文件描述符事件
- 减少信号处理函数的执行时间
6.2 使用signalfd
signalfd是Linux特有的机制,允许通过文件描述符接收信号:
c复制#include <sys/signalfd.h>
#include <signal.h>
int main() {
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
// 阻塞这些信号,防止传统处理
sigprocmask(SIG_BLOCK, &mask, NULL);
// 创建signalfd
int sfd = signalfd(-1, &mask, 0);
// 读取信号
struct signalfd_siginfo fdsi;
read(sfd, &fdsi, sizeof(fdsi));
printf("Received signal %d\n", fdsi.ssi_signo);
close(sfd);
return 0;
}
6.3 调试信号问题
调试信号相关问题时可以:
- 使用strace跟踪信号传递
- 在信号处理函数中记录调试信息(使用write而不是printf)
- 检查/proc/[pid]/status中的信号相关信息
- 使用GDB的"handle"命令控制信号处理
7. 实际案例:实现可靠的信号处理
下面是一个综合示例,展示了如何实现可靠的信号处理机制:
c复制#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
volatile sig_atomic_t flag = 0;
void handler(int sig) {
flag = 1; // 只设置标志,不做复杂操作
}
int main() {
struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
// 阻塞SIGINT,确保在关键代码段不被中断
sigset_t block_mask, old_mask;
sigemptyset(&block_mask);
sigaddset(&block_mask, SIGINT);
while (1) {
// 关键代码段开始前阻塞信号
sigprocmask(SIG_BLOCK, &block_mask, &old_mask);
// 执行关键操作
printf("Working...\n");
sleep(1);
// 关键代码段结束后检查信号标志
if (flag) {
printf("Signal received, cleaning up...\n");
flag = 0;
}
// 恢复信号屏蔽字
sigprocmask(SIG_SETMASK, &old_mask, NULL);
// 非关键代码段可以响应信号
printf("Non-critical work...\n");
sleep(1);
}
return 0;
}
这个示例展示了几个重要技巧:
- 使用volatile sig_atomic_t类型的标志变量
- 保持信号处理函数尽可能简单
- 在关键代码段阻塞信号
- 使用SA_RESTART标志自动重启系统调用
- 正确处理信号屏蔽字的保存和恢复