信号的产生机制(来源全解析)
文章目录
信号产生
键盘组合键
在终端环境中,某些 键盘组合键 会触发信号。
例如:
| 组合键 | 信号 |
|---|---|
Ctrl + C |
SIGINT |
Ctrl + \ |
SIGQUIT |
Ctrl + Z |
SIGTSTP |
| 这些信号通常用于控制前台进程。 |
Ctrl+C 的工作机制
当用户在终端按下:
Ctrl + C
执行流程如下:
键盘输入
↓
终端驱动识别按键
↓
操作系统内核解释为 SIGINT
↓
内核向前台进程发送 SIGINT
↓
进程处理该信号
其中:
SIGINT = 信号编号 2
SIGINT 的默认行为
在 Linux 中,可以通过手册查看信号说明:
man 7 signal
SIGINT 的默认行为是:
终止进程(Terminate Process)
因此:
当程序运行时按 Ctrl+C,进程会直接结束。
信号处理方式
进程收到信号后,可以采用三种处理策略:
默认处理(Default Action)
使用系统定义行为,例如:
- 终止进程
- 停止进程
- 忽略信号
忽略信号(Ignore)
进程可以选择忽略信号,例如:
signal(SIGINT, SIG_IGN);
捕捉信号(Signal Catch)
进程可以定义 信号处理函数(Signal Handler)。
例如:
#include <signal.h>
#include <iostream>
void handler(int signo)
{
std::cout << "捕捉到信号: " << signo << std::endl;
}
注册信号处理函数:
signal(SIGINT, handler);
signal 函数原型
Linux 提供的信号注册接口:
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数说明:
| 参数 | 含义 |
|---|---|
signum |
信号编号 |
handler |
信号处理函数 |
| 其中: |
void handler(int signo)
是一个 函数指针类型,表示信号处理回调函数。
信号处理函数的执行机制
需要注意:
调用 signal() 时:
只是注册信号处理函数,并不会立即执行。
执行流程如下:
程序启动
↓
注册 signal handler
↓
程序继续执行
↓
收到对应信号
↓
内核触发信号处理函数
因此:
信号处理函数本质上是一种 回调函数(callback)。
示例:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "进程捕捉到信号: " << signo << std::endl;
}
int main()
{
signal(SIGINT, handler);
while(true)
{
std::cout << "我是一个进程 PID=" << getpid() << std::endl;
sleep(1);
}
return 0;
}
运行程序后:
Ctrl+C
输出:
进程捕捉到信号: 2
说明:
SIGINT 已被程序捕获。
为什么 Ctrl+C 不再终止程序
因为:
我们通过
signal(SIGINT, handler);
修改了 SIGINT 的默认行为。
默认行为:
terminate
现在变为:
执行 handler()
因此进程不会退出。
如果希望处理后退出,可以:
exit(0);
SIGKILL 无法被捕捉
即使程序捕获了大量信号,仍然存在一个例外:
SIGKILL (9)
特点:
| 特性 | 说明 |
|---|---|
| 不可捕捉 | Cannot be caught |
| 不可忽略 | Cannot be ignored |
| 不可阻塞 | Cannot be blocked |
| 因此: |
kill -9 PID
一定可以终止进程。
这是操作系统提供的 强制终止机制,用于防止恶意程序无法被杀死。
键盘信号的产生机制
键盘本质上是一个硬件输入设备。当用户按下特定组合键时,系统执行如下流程:
键盘输入
↓
终端驱动程序检测按键
↓
操作系统解释为特定信号
↓
向当前前台进程发送信号
↓
目标进程在适当时机处理该信号
因此:
键盘组合键实际上是 触发操作系统发送信号的一种方式。
前台进程(Foreground Process)
在 Linux 终端环境中,同一时刻只能有一个前台进程与终端交互。
默认情况下:
bash shell
是前台进程。
当用户执行某个程序,例如:
./my_signal
执行流程如下:
bash shell
↓
启动新进程 my_signal
↓
my_signal 成为前台进程
↓
bash 退到后台等待
此时:
- 键盘输入信号会发送给 my_signal
- 而不是 bash
可以通过命令查看进程关系:
ps ax | grep my_signal
例如:
PID PPID
1391 3815
其中:
1391为程序进程 PID3815为父进程 bash
系统调用
除了键盘输入外,还可以通过 系统调用(System Call) 发送信号。
这是程序向进程发送信号的主要方式。
常见系统调用包括:
| 系统调用 | 作用 |
|---|---|
kill() |
向指定进程发送信号 |
raise() |
向当前进程发送信号 |
alarm() |
定时产生信号 |
| 本节重点介绍: |
kill()
kill 系统调用
kill() 系统调用用于向指定进程发送信号。
函数原型:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
参数说明:
| 参数 | 含义 |
|---|---|
pid |
目标进程 PID |
sig |
要发送的信号编号 |
| 返回值: |
| 返回值 | 含义 |
|---|---|
| 0 | 成功 |
| -1 | 失败,并设置 errno |
信号发送的权限模型
需要明确一个关键原则:
信号发送的能力属于操作系统内核。
用户程序并不能直接修改进程 PCB。
因此:
用户程序
↓
系统调用
↓
操作系统内核
↓
修改目标进程 PCB 信号位图
也就是说:
真正发送信号的是内核,而不是用户程序。
用户程序只是通过系统调用请求内核执行该操作。
系统调用的设计目的
操作系统通常遵循以下原则:
内核功能必须通过 系统调用接口 向用户空间提供服务。
原因包括:
- 安全性(Security)
- 权限控制(Permission Control)
- 系统稳定性(System Stability)
因此:
即使操作系统具备发送信号的能力,也必须通过系统调用向用户开放。
示例程序:通过 kill() 发送信号
可以实现一个简单程序,通过命令行向目标进程发送信号。
示例:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
using namespace std;
void usage(const char* proc)
{
cout << "Usage: " << proc << " <PID> <SIGNAL>" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
usage(argv[0]);
exit(1);
}
pid_t pid = atoi(argv[1]);
int signo = atoi(argv[2]);
int ret = kill(pid, signo);
if(ret == 0)
cout << "Signal sent successfully" << endl;
else
perror("kill");
return 0;
}
运行示例:
./my_process 1391 2
表示:
向 PID=1391 的进程发送 SIGINT 信号
信号产生方式总结
Linux 中常见的信号产生方式包括:
| 方式 | 示例 |
|---|---|
| 键盘输入 | Ctrl+C |
| 系统调用 | kill() |
| 进程自身产生 | raise() |
| 定时器 | alarm() |
| 硬件异常 | 除零错误 |
| 软件异常 | 非法内存访问 |
小结
Linux 信号机制的关键结论:
- 信号的接收对象是进程
- 信号状态存储在 PCB(task_struct)
- 信号发送本质是 修改 PCB 中的信号位图
- 用户程序必须通过 系统调用 发送信号
- 键盘组合键(如 Ctrl+C)也是信号产生方式
Ctrl+C对应信号:
SIGINT (2)
- 信号处理方式包括:
- 默认处理
- 自定义捕捉
- 忽略信号
signal()用于注册信号处理函数- 信号处理函数是 回调函数
SIGKILL无法被捕获或忽略,是系统提供的强制终止信号。
信号默认处理行为
在 Linux/Unix 系统中,当 进程收到信号(signal)时,系统会按照该信号的 默认处理动作(default action) 进行处理。
从整体上看:
大多数信号的默认处理行为是终止进程(Terminate)。
但不同信号虽然默认行为可能相同,其 语义(meaning)是不同的。
换句话说:
- 信号的意义并不是由处理动作决定的
- 信号的意义由它所表示的事件决定
例如:
| 信号 | 含义 | 默认动作 |
|---|---|---|
| SIGINT | 用户按 Ctrl+C | 终止进程 |
| SIGQUIT | Ctrl+|终止并生成 core | |
| SIGFPE | 算术异常(如除零) | 终止进程 |
| SIGSEGV | 非法内存访问 | 终止进程 |
| 虽然这些信号 默认处理结果都是终止进程,但: |
不同信号表示不同类型的异常事件或系统状态。
因此:
信号的价值在于描述不同的系统事件,而不是区分不同的处理动作。
触发条件
硬件条件触发
信号可以通过多种方式产生,例如:
用户产生
用户在终端输入:
Ctrl + C
Ctrl + \
终端驱动程序会向前台进程发送信号:
- Ctrl+C → SIGINT
- Ctrl+\ → SIGQUIT
软件方式产生
进程可以通过系统调用主动发送信号,例如:
kill(pid, signo)
raise(signo)
kill()可以向任意进程发送信号raise()给当前进程发送信号
硬件异常产生
信号 不一定需要用户显式发送,有些信号会 由操作系统自动产生。
例如:
int a = 10;
a = a / 0;
执行该代码时会产生 除零异常。
程序运行结果通常是:
Floating point exception (core dumped)
其本质原因是:
CPU 在执行除零运算时产生了 硬件异常(Hardware Exception)
操作系统随后会将该异常 转换为信号 并发送给当前进程。
在除零情况下:
SIGFPE (signal 8)
除零异常产生信号的底层机制

整个过程可以从 硬件 → 操作系统 → 进程 三个层次理解。
CPU 执行算术运算
程序代码:
a = 10 / 0
CPU 在执行该指令时:
- 从寄存器读取操作数
- 执行除法运算
CPU 检测到异常
CPU 内部有一种特殊寄存器:
状态寄存器(Status Register / Flag Register)
其中包含各种 标志位(Flag):
例如:
| 标志位 | 含义 |
|---|---|
| ZF | 零标志 |
| CF | 进位标志 |
| OF | 溢出标志 |
当执行 10 / 0 时: |
|
| CPU 会检测到 算术异常,并设置相应的异常标志。 | |
| 这就属于: |
CPU 硬件级异常(Hardware Exception)
操作系统检测到异常
CPU 产生异常后会触发 异常中断(Exception Trap)。
此时:
- CPU 从用户态进入内核态
- 操作系统获得控制权
操作系统会判断: - 当前异常类型
- 当前执行的进程
操作系统转换为信号
操作系统会将硬件异常 映射为信号:
例如:
除零异常 → SIGFPE (8)
随后:
操作系统向当前进程发送该信号。
为什么捕捉 SIGFPE 后信号会不断出现
如果你捕捉了 SIGFPE,程序会出现一个现象:
信号处理函数不断被调用
原因是:
- CPU 指令仍然停留在 除零指令
- 处理函数返回后
- 程序继续执行该非法指令
于是:
再次触发异常
→ 再次发送 SIGFPE
→ 再次调用 handler
因此就形成 无限触发信号的循环。
CPU 寄存器属于进程上下文
在计算机系统中:
- CPU 寄存器在硬件上 只有一份
- 但寄存器中的数据 属于当前运行进程的执行上下文
所谓 进程上下文(Process Context) 包括: - 通用寄存器
- 程序计数器(PC)
- 栈指针(SP)
- 状态寄存器(Flags Register)
当操作系统发生 进程切换(Context Switch) 时:
- 保存当前进程寄存器内容
- 恢复下一个进程寄存器内容
因此:
CPU 寄存器的值实际上是进程上下文的一部分。
除零异常的状态不会被用户进程修复
当 CPU 执行除零运算时:
10 / 0
CPU 会产生 算术异常(Arithmetic Exception),并在 状态寄存器(Status Register) 中设置异常标志位。
例如:
- Overflow Flag
- Divide Error Flag
这些标志位: - 由 CPU 硬件维护
- 用户程序 无法直接修改
因此:
用户进程无法修复 CPU 的异常状态。
信号不会必然导致进程终止
在 Linux/Unix 系统中:
进程收到信号并不一定会终止。
原因是:
每个信号都有三种处理方式:
- 默认处理(Default Action)
- 忽略信号(Ignore)
- 自定义处理函数(User-defined handler)
例如:
signal(SIGFPE, handler);
当进程收到 SIGFPE 时:
- 系统不会执行默认终止动作
- 而是调用用户注册的
handler函数
因此:
通过自定义信号处理函数,可以改变信号的默认处理行为,从而避免进程终止。
进程未退出仍然可能继续执行
如果信号处理函数执行完毕后:
- 进程 没有退出
- 也 没有修复异常状态
那么该进程仍然是可调度的。
换句话说:
只要进程没有终止,它仍然会被操作系统调度继续运行。
信号机制是 Linux 中用于 通知进程发生特定事件的一种异步机制。
其特点包括:
- 信号表示系统中的某种 事件或异常
- 信号可以由 用户、进程或操作系统自动产生
- 硬件异常(如除零)会被操作系统转换为信号
- 进程收到信号后可以
- 执行默认动作
- 忽略信号
- 自定义处理函数
例如:
除零异常
→ CPU 检测异常
→ 操作系统捕获异常
→ 转换为 SIGFPE
→ 发送给当前进程
→ 进程执行默认终止动作
为什么 SIGFPE 会不断触发
当发生除零异常时,系统执行流程如下:
- CPU 执行非法算术运算
- CPU 触发硬件异常
- 操作系统检测异常
- 操作系统向进程发送
SIGFPE
如果进程:
- 捕获了
SIGFPE - 但没有终止
- 也没有修复异常
那么程序状态仍然存在问题。
当进程再次被调度时:
- 操作系统恢复该进程的寄存器上下文
- 状态寄存器中的异常标志仍然存在
- 操作系统再次检测到异常状态
- 再次向进程发送
SIGFPE
于是形成循环:
异常状态存在
↓
操作系统发送 SIGFPE
↓
信号处理函数执行
↓
进程继续运行
↓
再次调度
↓
异常状态仍然存在
↓
再次发送 SIGFPE
因此就会出现:
信号处理函数被反复调用的现象。
可以总结为以下几个关键点:
- 信号不会必然导致进程终止
如果进程注册了信号处理函数,默认终止行为会被替换。 - CPU 寄存器属于进程上下文
在进程切换时会被保存和恢复。 - 硬件异常状态由 CPU 维护
用户进程无法直接修改状态寄存器。 - 异常状态未被修复时
每次进程恢复执行时操作系统都会重新检测到异常。
因此:
如果异常状态未被修复,而进程又没有退出,操作系统可能会反复向该进程发送相同的信号。
语言层面的异常本质是系统层面的硬件异常
在 C/C++ 程序中,一些常见的运行时错误,例如:
- 除零运算
- 空指针解引用
- 数组越界
- 野指针访问
在语言层面通常被认为是 程序错误。
但从操作系统角度来看:
这些错误本质上都是 硬件异常(Hardware Exception)。
当 CPU 或相关硬件检测到异常时:
- 硬件触发异常
- 操作系统捕获异常
- 操作系统将异常转换为 信号(signal)
- 将信号发送给当前进程
- 进程按照信号的默认动作或自定义动作处理
因此:
很多程序崩溃的根本原因是进程收到了某种信号。
野指针导致程序崩溃的原因
考虑如下代码:
int *p = NULL;
*p = 100;
分析:
int *p = NULL;
该语句只是定义了一个指针变量 p,并将其值设为 0,不会产生错误。
但:
*p = 100;
表示对地址 0 进行写操作。
也就是:
访问地址 0
空指针访问为什么会触发异常
在 Linux 中,进程访问内存的过程如下:
程序
↓
虚拟地址
↓
页表
↓
MMU
↓
物理内存
每个进程都有:
- 虚拟地址空间
- 页表(Page Table)
虚拟地址必须通过 页表映射 才能访问物理内存。
指针本质是虚拟地址
例如:
int *p
指针变量 p 中保存的值,本质上是一个 虚拟地址。
当执行:
*p = 100;
系统执行过程为:
- 取出
p中的值(0) - 将 0 当作虚拟地址
- 通过 MMU(Memory Management Unit) 进行地址转换
- 查页表
为什么访问 0 地址会出错
在现代操作系统中:
虚拟地址 0 一般不会映射到任何物理内存。
这是为了防止:
- 空指针访问
- 程序错误
因此:
当 MMU 尝试进行地址转换时:
虚拟地址 0
↓
查页表
↓
没有合法映射
MMU 会触发:
内存访问异常(Memory Fault)
MMU 异常如何变成信号
当 MMU 检测到非法内存访问时:
- MMU 产生 硬件异常
- CPU 进入 异常处理流程
- 操作系统内核获得控制权
- 操作系统判断异常类型
如果是非法内存访问:
操作系统会向进程发送信号:
SIGSEGV
信号编号:
11
含义:
Segmentation Fault
段错误
SIGSEGV 的默认处理行为
查询:
man 7 signal
可以看到:
| 信号 | 名称 | 默认动作 | 含义 |
|---|---|---|---|
| 11 | SIGSEGV | Terminate + core | 非法内存访问 |
| 因此: | |||
当进程收到 SIGSEGV 时: |
默认行为是 终止进程并生成 core dump。
于是程序就会崩溃。
野指针访问的完整过程如下:
程序执行 *p = 100
↓
p = 0(空指针)
↓
访问虚拟地址 0
↓
MMU 查页表
↓
发现地址非法
↓
MMU 产生硬件异常
↓
CPU 进入异常处理
↓
操作系统捕获异常
↓
操作系统向进程发送 SIGSEGV (11)
↓
进程执行默认动作
↓
进程终止
从系统层面来看:
- C/C++ 程序中的很多运行时错误本质上是硬件异常。
- 硬件异常会被操作系统捕获,并转换为 信号(Signal)。
- 常见对应关系:
| 程序错误 | 信号 |
|---|---|
| 除零 | SIGFPE (8) |
| 非法内存访问 | SIGSEGV (11) |
| 非法指令 | SIGILL |
- 进程收到信号后:
- 默认行为通常是 终止进程
因此程序表现为 崩溃(crash)。
所有程序崩溃,本质上都是进程收到了某个信号。
最常见三个:
SIGSEGV 段错误
SIGABRT abort()
SIGFPE 算术异常
程序异常与信号的关系
在 C/C++ 程序运行过程中,如果发生某些 非法操作,例如:
- 除零运算
- 空指针或野指针访问
- 非法内存访问
程序通常会直接 崩溃终止。
从操作系统的角度来看,其本质原因是:
程序的非法操作会触发 硬件异常(Hardware Exception),操作系统检测到该异常后,会将其转换为 信号(Signal) 并发送给对应进程。
硬件异常触发信号的基本过程
当程序执行非法操作时,系统内部会经历如下过程:
- 程序执行异常指令
例如:
10 / 0(除零运算)*p = 100且p == NULL(空指针访问)
这些操作属于 非法或未定义行为。
- 硬件检测异常
CPU 或相关硬件组件会检测到异常,例如:
- CPU 运算单元检测到算术异常(除零)
- **MMU(Memory Management Unit)**检测到非法内存访问
此时硬件会触发 异常(Exception)。
- 操作系统接管异常
当硬件产生异常后:
- CPU 会进入 内核态
- 操作系统内核获得控制权
- 操作系统分析异常类型
- 异常转换为信号
操作系统会将硬件异常 转换为对应的信号,并发送给当前进程,例如:
| 异常类型 | 信号 |
|---|---|
| 除零运算 | SIGFPE |
| 非法内存访问 | SIGSEGV |
| 非法指令 | SIGILL |
- 进程处理信号
进程收到信号后,可以:
- 执行 默认处理动作
- 忽略信号
- 自定义信号处理函数
但对于大多数硬件异常信号而言:
默认处理动作通常是 终止进程(Terminate)。
因此程序会表现为 崩溃(crash)。
信号产生的第四种方式:软件条件触发
在 Linux/Unix 系统中,信号不仅可以通过:
- 用户操作产生(如
Ctrl+C) - 系统调用产生(如
kill()、raise()) - 硬件异常产生(如除零、非法内存访问)
还可以由 软件条件(Software Condition) 触发。
所谓 软件条件触发信号,是指:
操作系统在检测到某种特定的软件运行状态或系统条件时,自动向相关进程发送信号。
这种信号的产生 并不依赖用户操作,也不由硬件异常直接触发,而是由操作系统根据系统运行状态主动产生。
管道写入错误(SIGPIPE)
在 Linux 中,管道(Pipe)是一种常见的 进程间通信(IPC)机制。
假设存在两个进程:
- 写进程(writer):向管道写入数据
- 读进程(reader):从管道读取数据
结构如下:
进程A (writer) → pipe → 进程B (reader)
如果发生以下情况:
- 读端关闭(reader 关闭文件描述符)
- 写端仍然继续向管道写数据
此时会出现一个问题:
写入的数据将没有任何进程读取。
为了避免这种 无意义的资源消耗,操作系统会采取措施。
当写进程继续写入时:
- 操作系统检测到 管道读端已经关闭
- 内核会向写进程发送一个信号:
SIGPIPE
信号编号:
13
其默认处理行为为:
终止写进程(Terminate)
因此写进程通常会被系统终止。
SIGPIPE 的产生原因
SIGPIPE 的产生并不是由于硬件异常,而是由于一种 软件运行条件:
管道读端关闭 + 写端继续写入
因此:
SIGPIPE 是由 操作系统根据软件运行状态自动产生的信号。
这种情况被称为:
Software Condition Generated Signal
定时器信号
在 Linux 系统中,信号不仅可以由用户操作、系统调用或硬件异常产生,还可以由 软件条件(software condition) 触发。其中一个典型例子是 进程定时器信号。
Linux 提供了 alarm() 系统接口,用于为当前进程设置一个定时器。
该接口定义如下:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
其功能是:
为当前进程设置一个定时器,当指定的时间间隔(以秒为单位)到达后,内核会向该进程发送 SIGALRM 信号。
SIGALRM 的信号编号为 14,其默认处理行为是:
终止进程(Terminate Process)
其行为如下:
- 进程调用
alarm(n) - 内核启动一个定时器
- 当 n 秒时间到达 时
- 操作系统向该进程发送信号
发送的信号为:
SIGALRM
信号编号:
14
默认处理行为:
终止进程
alarm 的基本作用
alarm() 的作用可以理解为:
为当前进程设置一个 闹钟(Timer)。
例如:
alarm(5);
含义是:
5 秒之后
操作系统向该进程发送 SIGALRM 信号
如果进程没有捕获该信号,则默认行为是:
终止进程
Linux 中信号的产生方式可以总结为四类:
| 产生方式 | 示例 |
|---|---|
| 用户操作 | Ctrl+C → SIGINT |
| 系统调用 | kill(), raise() |
| 硬件异常 | 除零 → SIGFPE |
| 软件条件 | SIGPIPE、SIGALRM |
其中:
软件条件产生信号指的是:
操作系统根据程序运行时的某种逻辑条件或系统状态,自动向进程发送信号。
典型例子包括:
- SIGPIPE:管道读端关闭但写端继续写
- SIGALRM:定时器时间到达
alarm() 的工作机制
alarm() 的语义类似于为进程设置一个“闹钟”。
当进程调用:
alarm(1);
其含义是:
- 当前时刻 不会立即触发信号
- 内核启动一个 1 秒的定时器
- 当 1 秒时间到达 时
- 内核向该进程发送 SIGALRM 信号
如果进程未对该信号进行捕获或处理,则会执行默认行为:
进程被终止
因此在终端中通常会看到类似信息:
Alarm clock
这表示进程因为接收到 SIGALRM 信号而退出。
alarm() 的返回值
alarm() 具有返回值:
unsigned int
返回值含义如下:
| 返回值 | 含义 |
|---|---|
| 0 | 之前没有设置定时器 |
| 非0 | 返回 之前定时器剩余的秒数 |
| 这意味着: |
如果在旧定时器尚未触发时再次调用
alarm(),旧定时器会被取消,并返回其剩余时间。
取消闹钟
如果调用:
alarm(0);
其语义是:
取消当前进程已经设置的定时器
同时函数返回:
原定时器剩余的秒数
因此 alarm(0) 的作用等价于:
取消定时器 + 查询剩余时间
为什么 alarm 属于“软件条件触发信号”
在信号分类中,SIGALRM 被归类为 软件条件产生的信号。
其原因在于:
- 该信号并非由 用户操作 触发
- 也不是由 硬件异常 产生
- 而是由 操作系统内部的软件机制在满足某种条件时触发
具体条件是:
系统时间 >= 设定的超时时间
因此它属于 软件条件触发信号(Software-generated signal)。
I/O 操作对性能的影响
循环中包含输出操作:
while(true)
{
cout << cnt++ << endl;
}
运行结果大约为:
4 万次左右
原因是:
每次循环都执行一次 I/O 输出操作
I/O 操作属于 外设访问,其速度远慢于 CPU 运算。
因此程序的大部分时间都消耗在:
- 控制台输出
- 缓冲区刷新
- 系统调用
- 终端或网络传输
从而严重降低了循环执行次数。
将输出移出循环:
while(true)
{
cnt++;
}
并在收到信号后打印结果:
void handler(int signo)
{
printf("%d\n", cnt);
}
此时 1 秒内的统计结果可以达到:
3 亿次左右
相比之前约 提高了 10⁴(10000)倍。
性能差异的根本原因在于:
I/O 操作远慢于 CPU 计算
CPU 执行一次自增操作只需要极少的指令周期,而一次输出操作通常涉及:
- 用户态到内核态切换
- 写入内核缓冲区
- 终端设备处理
- 可能的网络传输(远程终端)
在远程服务器环境中,输出数据还需要:
服务器 → 网络 → 本地终端
因此 I/O 延迟会进一步增加。
I/O 是程序性能的重要瓶颈
在高性能程序设计中,应尽量避免:
在高频循环中执行 I/O 操作
因为 I/O 的性能开销远高于普通计算操作。
SIGALRM 信号只触发一次
在实验代码中,我们为进程设置了一个定时器:
alarm(1);
该调用会在 1 秒后向进程发送一个 SIGALRM 信号。
如果程序捕获该信号,例如:
signal(SIGALRM, handler);
则当信号到达时会执行对应的 信号处理函数(signal handler)。
在实验中可以观察到一个重要现象:
- 信号处理函数只被调用 一次
- 程序只接收到 一个 SIGALRM 信号
原因在于:
alarm()设置的定时器是 一次性定时器(one-shot timer)
即:
- 定时器触发一次后
- 就会自动失效
- 不会再次触发信号
不终止进程时的行为
如果在信号处理函数中 不调用 exit() 终止进程,程序的执行流程如下:
- 程序运行并设置定时器
- 1 秒后收到 SIGALRM
- 内核调用信号处理函数
- 处理函数执行完毕
- 程序继续执行原来的代码
因此我们会看到:
- 处理函数只执行 一次
- 程序不会退出
这是因为我们 覆盖了 SIGALRM 的默认行为。
默认情况下:
SIGALRM → 终止进程
但如果注册了信号处理函数,则由用户代码决定如何处理。
如果希望 每隔一秒执行一次任务,可以在信号处理函数中重新设置定时器,例如:
void handler(int signo)
{
printf("%d\n", cnt);
alarm(1); // 重新设置定时器
}
程序流程变为:
- 设置
alarm(1) - 1 秒后收到 SIGALRM
- 执行
handler - 在 handler 中再次调用
alarm(1) - 再过 1 秒再次触发信号
这样就形成了一个 周期性定时器(periodic timer)。
其逻辑可以表示为:
SIGALRM → handler() → alarm(1) → SIGALRM → handler() ...
与 sleep 的逻辑类比
这种机制在逻辑上类似于:
while (true)
{
sleep(1);
printf("%d\n", cnt);
}
区别在于:
| 方法 | 特点 |
|---|---|
| sleep | 主线程阻塞 |
| alarm + signal | 通过信号异步触发 |
alarm() 的方式属于: |
异步事件驱动机制
而 sleep() 属于:
同步阻塞机制
通过该实验可以得到几个关键结论:
- I/O 操作远慢于 CPU 运算,频繁输出会严重降低程序执行效率。
alarm()设置的定时器是 一次性定时器。- 若需要周期性触发信号,需要在信号处理函数中 重新调用 alarm()
- 使用
alarm()与信号机制可以实现 基于时间的事件触发模型。
操作系统如何管理多个闹钟
任何进程都可以调用 alarm() 设置定时器,因此系统中可能同时存在大量定时器。
操作系统必须对这些定时器进行统一管理。
在操作系统设计中通常遵循一个基本原则:
先描述(Describe),再组织(Organize)
即:
- 使用数据结构描述对象
- 再通过某种结构组织这些对象
在操作系统中,多个进程都可以调用alarm()设置定时器,因此内核中可能同时存在 大量定时器对象。为了高效管理这些定时器,操作系统需要使用合适的数据结构进行组织。
常见的定时器管理策略包括:
时间轮(Time Wheel)
时间轮是一种高效的定时器管理结构,通过将时间划分为多个槽(slot)来组织定时任务。
其核心思想是:
时间 → 离散化 → 映射到时间槽
当时间推进时,只需要检查当前时间槽中的定时器即可。
这种结构具有:
- 插入复杂度:O(1)
- 删除复杂度:O(1)
- 检查复杂度:O(1)
因此常用于 高并发服务器定时器系统。
例如: - Nginx
- Nett
- Redis
都使用类似的思想实现定时任务管理。
最小堆(Min Heap)
另一种经典实现方式是使用 最小堆(优先级队列)。
假设系统中存在 100 个定时器,每个定时器都有一个 超时时间:
5s
10s
20s
55s
...
可以按 超时时间排序 构建最小堆:
5
/ \
10 20
/ \
55 ...
最小堆的特点是:
堆顶元素 = 最早到期的定时器
操作流程如下:
- 定时器加入系统
→ 插入最小堆 - 操作系统检查定时器
检查堆顶节点
- 如果:
current_time >= heap.top().expire_time
说明定时器到期。
此时:
1. 取出堆顶
2. 向目标进程发送 SIGALRM
3. 调整堆结构
4. 再次检查新的堆顶
重复该过程直到:
堆顶未超时
这种设计的时间复杂度:
| 操作 | 复杂度 |
|---|---|
| 插入定时器 | O(log n) |
| 删除定时器 | O(log n) |
| 查询最近超时 | O(1) |
| 因此非常适合 中等规模定时器管理。 |
内核中定时器对象的抽象
在概念上,操作系统可以为每一个定时器维护一个结构体,例如:
struct alarm
{
uint64_t when; // 超时时间(时间戳)
int type; // 定时器类型(一次性 / 周期性)
task_struct *task; // 所属进程
struct alarm *next; // 指向下一个定时器
};
各字段含义如下:
| 字段 | 作用 |
|---|---|
| when | 定时器触发的时间点 |
| type | 定时器类型(one-shot / periodic) |
| task | 关联的进程 |
| next | 用于组织定时器结构 |
| 例如: | |
| 如果当前时间为: |
1000
进程设置:
alarm(100)
则:
when = 1100
表示未来触发时间。
定时器数据结构组织
操作系统需要对所有定时器进行组织,例如:
alarm_list_head → alarm1 → alarm2 → alarm3 → ...
当某个进程调用 alarm() 时:
- 内核创建一个 alarm 对象
- 填充定时信息
- 将其加入 定时器队列
定时器检查机制
操作系统会周期性检查这些定时器,例如:
当前时间 = now
对于每个定时器:
if (now >= alarm.when)
说明:
定时器已经到期
此时内核会执行:
向对应进程发送 SIGALRM
伪代码示意:
for each alarm in alarm_list
{
if (current_time >= alarm.when)
{
send_signal(SIGALRM, alarm.task);
}
}
因此,从操作系统内部实现角度来看:
alarm()本质上是 向内核注册一个定时器对象。
操作系统负责:
- 管理所有定时器
- 维护触发时间
- 在条件满足时发送信号
换句话说:
alarm() ≈ 在内核中创建一个定时器任务
Linux 中 alarm() 的工作机制可以概括为:
- 进程调用
alarm(seconds) - 内核创建一个定时器对象
- 将其加入定时器管理结构
- 内核周期性检查定时器
- 当时间到达时发送 SIGALRM
- 进程执行默认或自定义信号处理
因此:
SIGALRM属于 由软件条件(时间到达)触发的信号。
软件条件触发信号的本质
通过上述机制可以看到:
操作系统会 周期性检查定时器是否超时。
判断条件为:
current_time >= timer.expire_time
当条件成立时:
内核 → 向目标进程发送 SIGALRM
整个过程包括:
- 定时器创建
- 数据结构管理
- 时间检查
- 信号发送
这些行为全部由 操作系统软件逻辑完成。
因此:
SIGALRM 属于 软件条件触发信号
条件就是:
定时器超时
信号产生方式总结
在 Linux 系统中,常见的信号产生方式包括以下几类:
| 产生方式 | 示例 |
|---|---|
| 用户输入 | Ctrl+C → SIGINT |
| 命令/工具 | kill 命令 |
| 系统调用 | kill(), raise() |
| 硬件异常 | 除零 → SIGFPE |
| 软件条件 | 定时器超时 → SIGALRM |
| 无论信号来源如何: |
最终发送信号的主体都是操作系统
因为只有操作系统才具有 修改进程状态和向进程发送信号的权限。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)