文章目录

信号产生

键盘组合键

在终端环境中,某些 键盘组合键 会触发信号。
例如:

组合键 信号
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 为程序进程 PID
  • 3815 为父进程 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 信号位图

也就是说:
真正发送信号的是内核,而不是用户程序。
用户程序只是通过系统调用请求内核执行该操作。


系统调用的设计目的

操作系统通常遵循以下原则:

内核功能必须通过 系统调用接口 向用户空间提供服务。

原因包括:

  1. 安全性(Security)
  2. 权限控制(Permission Control)
  3. 系统稳定性(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 信号机制的关键结论:

  1. 信号的接收对象是进程
  2. 信号状态存储在 PCB(task_struct)
  3. 信号发送本质是 修改 PCB 中的信号位图
  4. 用户程序必须通过 系统调用 发送信号
  5. 键盘组合键(如 Ctrl+C)也是信号产生方式
  6. Ctrl+C 对应信号:
SIGINT (2)
  1. 信号处理方式包括:
  • 默认处理
  • 自定义捕捉
  • 忽略信号
  1. signal() 用于注册信号处理函数
  2. 信号处理函数是 回调函数
  3. 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) 时:
  1. 保存当前进程寄存器内容
  2. 恢复下一个进程寄存器内容
    因此:

CPU 寄存器的值实际上是进程上下文的一部分。


除零异常的状态不会被用户进程修复

当 CPU 执行除零运算时:

10 / 0

CPU 会产生 算术异常(Arithmetic Exception),并在 状态寄存器(Status Register) 中设置异常标志位。
例如:

  • Overflow Flag
  • Divide Error Flag
    这些标志位:
  • CPU 硬件维护
  • 用户程序 无法直接修改
    因此:

用户进程无法修复 CPU 的异常状态。

信号不会必然导致进程终止

在 Linux/Unix 系统中:

进程收到信号并不一定会终止。

原因是:
每个信号都有三种处理方式:

  1. 默认处理(Default Action)
  2. 忽略信号(Ignore)
  3. 自定义处理函数(User-defined handler)
    例如:
signal(SIGFPE, handler);

当进程收到 SIGFPE 时:

  • 系统不会执行默认终止动作
  • 而是调用用户注册的 handler 函数
    因此:

通过自定义信号处理函数,可以改变信号的默认处理行为,从而避免进程终止。


进程未退出仍然可能继续执行

如果信号处理函数执行完毕后:

  • 进程 没有退出
  • 没有修复异常状态
    那么该进程仍然是可调度的。
    换句话说:

只要进程没有终止,它仍然会被操作系统调度继续运行。

信号机制是 Linux 中用于 通知进程发生特定事件的一种异步机制
其特点包括:

  1. 信号表示系统中的某种 事件或异常
  2. 信号可以由 用户、进程或操作系统自动产生
  3. 硬件异常(如除零)会被操作系统转换为信号
  4. 进程收到信号后可以
    • 执行默认动作
    • 忽略信号
    • 自定义处理函数
      例如:
除零异常
→ CPU 检测异常
→ 操作系统捕获异常
→ 转换为 SIGFPE
→ 发送给当前进程
→ 进程执行默认终止动作

为什么 SIGFPE 会不断触发

当发生除零异常时,系统执行流程如下:

  1. CPU 执行非法算术运算
  2. CPU 触发硬件异常
  3. 操作系统检测异常
  4. 操作系统向进程发送 SIGFPE
    如果进程:
  • 捕获了 SIGFPE
  • 但没有终止
  • 也没有修复异常
    那么程序状态仍然存在问题。
    当进程再次被调度时:
  1. 操作系统恢复该进程的寄存器上下文
  2. 状态寄存器中的异常标志仍然存在
  3. 操作系统再次检测到异常状态
  4. 再次向进程发送 SIGFPE
    于是形成循环:
异常状态存在
↓
操作系统发送 SIGFPE
↓
信号处理函数执行
↓
进程继续运行
↓
再次调度
↓
异常状态仍然存在
↓
再次发送 SIGFPE

因此就会出现:

信号处理函数被反复调用的现象。

可以总结为以下几个关键点:

  1. 信号不会必然导致进程终止
    如果进程注册了信号处理函数,默认终止行为会被替换。
  2. CPU 寄存器属于进程上下文
    在进程切换时会被保存和恢复。
  3. 硬件异常状态由 CPU 维护
    用户进程无法直接修改状态寄存器。
  4. 异常状态未被修复时
    每次进程恢复执行时操作系统都会重新检测到异常。
    因此:

如果异常状态未被修复,而进程又没有退出,操作系统可能会反复向该进程发送相同的信号。


语言层面的异常本质是系统层面的硬件异常

在 C/C++ 程序中,一些常见的运行时错误,例如:

  • 除零运算
  • 空指针解引用
  • 数组越界
  • 野指针访问
    在语言层面通常被认为是 程序错误
    但从操作系统角度来看:

这些错误本质上都是 硬件异常(Hardware Exception)

当 CPU 或相关硬件检测到异常时:

  1. 硬件触发异常
  2. 操作系统捕获异常
  3. 操作系统将异常转换为 信号(signal)
  4. 将信号发送给当前进程
  5. 进程按照信号的默认动作或自定义动作处理
    因此:
    很多程序崩溃的根本原因是进程收到了某种信号。

野指针导致程序崩溃的原因

考虑如下代码:

int *p = NULL;
*p = 100;

分析:

int *p = NULL;

该语句只是定义了一个指针变量 p,并将其值设为 0,不会产生错误。
但:

*p = 100;

表示对地址 0 进行写操作。
也就是:

访问地址 0

空指针访问为什么会触发异常

在 Linux 中,进程访问内存的过程如下:

程序
 ↓
虚拟地址
 ↓
页表
 ↓
MMU
 ↓
物理内存

每个进程都有:

  • 虚拟地址空间
  • 页表(Page Table)
    虚拟地址必须通过 页表映射 才能访问物理内存。
    指针本质是虚拟地址
    例如:
int *p

指针变量 p 中保存的值,本质上是一个 虚拟地址
当执行:

*p = 100;

系统执行过程为:

  1. 取出 p 中的值(0)
  2. 将 0 当作虚拟地址
  3. 通过 MMU(Memory Management Unit) 进行地址转换
  4. 查页表

为什么访问 0 地址会出错

在现代操作系统中:

虚拟地址 0 一般不会映射到任何物理内存。

这是为了防止:

  • 空指针访问
  • 程序错误
    因此:
    当 MMU 尝试进行地址转换时:
虚拟地址 0
 ↓
查页表
 ↓
没有合法映射

MMU 会触发:

内存访问异常(Memory Fault)


MMU 异常如何变成信号

当 MMU 检测到非法内存访问时:

  1. MMU 产生 硬件异常
  2. CPU 进入 异常处理流程
  3. 操作系统内核获得控制权
  4. 操作系统判断异常类型
    如果是非法内存访问:
    操作系统会向进程发送信号:
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)
        ↓
进程执行默认动作
        ↓
进程终止

从系统层面来看:

  1. C/C++ 程序中的很多运行时错误本质上是硬件异常。
  2. 硬件异常会被操作系统捕获,并转换为 信号(Signal)
  3. 常见对应关系:
程序错误 信号
除零 SIGFPE (8)
非法内存访问 SIGSEGV (11)
非法指令 SIGILL
  1. 进程收到信号后:
  • 默认行为通常是 终止进程
    因此程序表现为 崩溃(crash)

所有程序崩溃,本质上都是进程收到了某个信号。

最常见三个:

SIGSEGV  段错误
SIGABRT  abort()
SIGFPE   算术异常
程序异常与信号的关系

在 C/C++ 程序运行过程中,如果发生某些 非法操作,例如:

  • 除零运算
  • 空指针或野指针访问
  • 非法内存访问
    程序通常会直接 崩溃终止
    从操作系统的角度来看,其本质原因是:

程序的非法操作会触发 硬件异常(Hardware Exception),操作系统检测到该异常后,会将其转换为 信号(Signal) 并发送给对应进程。


硬件异常触发信号的基本过程

当程序执行非法操作时,系统内部会经历如下过程:

  1. 程序执行异常指令
    例如:
  • 10 / 0(除零运算)
  • *p = 100p == NULL(空指针访问)
    这些操作属于 非法或未定义行为
  1. 硬件检测异常
    CPU 或相关硬件组件会检测到异常,例如:
  • CPU 运算单元检测到算术异常(除零)
  • **MMU(Memory Management Unit)**检测到非法内存访问
    此时硬件会触发 异常(Exception)
  1. 操作系统接管异常
    当硬件产生异常后:
  • CPU 会进入 内核态
  • 操作系统内核获得控制权
  • 操作系统分析异常类型
  1. 异常转换为信号
    操作系统会将硬件异常 转换为对应的信号,并发送给当前进程,例如:
异常类型 信号
除零运算 SIGFPE
非法内存访问 SIGSEGV
非法指令 SIGILL
  1. 进程处理信号
    进程收到信号后,可以:
  • 执行 默认处理动作
  • 忽略信号
  • 自定义信号处理函数
    但对于大多数硬件异常信号而言:

默认处理动作通常是 终止进程(Terminate)

因此程序会表现为 崩溃(crash)

信号产生的第四种方式:软件条件触发

在 Linux/Unix 系统中,信号不仅可以通过:

  1. 用户操作产生(如 Ctrl+C
  2. 系统调用产生(如 kill()raise()
  3. 硬件异常产生(如除零、非法内存访问)
    还可以由 软件条件(Software Condition) 触发。
    所谓 软件条件触发信号,是指:

操作系统在检测到某种特定的软件运行状态或系统条件时,自动向相关进程发送信号。

这种信号的产生 并不依赖用户操作,也不由硬件异常直接触发,而是由操作系统根据系统运行状态主动产生。


管道写入错误(SIGPIPE)

在 Linux 中,管道(Pipe)是一种常见的 进程间通信(IPC)机制
假设存在两个进程:

  • 写进程(writer):向管道写入数据
  • 读进程(reader):从管道读取数据
    结构如下:
进程A (writer)  →  pipe  →  进程B (reader)

如果发生以下情况:

  1. 读端关闭(reader 关闭文件描述符)
  2. 写端仍然继续向管道写数据
    此时会出现一个问题:

写入的数据将没有任何进程读取。

为了避免这种 无意义的资源消耗,操作系统会采取措施。
当写进程继续写入时:

  • 操作系统检测到 管道读端已经关闭
  • 内核会向写进程发送一个信号:
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)
其行为如下:

  1. 进程调用 alarm(n)
  2. 内核启动一个定时器
  3. n 秒时间到达
  4. 操作系统向该进程发送信号
    发送的信号为:
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 执行一次自增操作只需要极少的指令周期,而一次输出操作通常涉及:

  1. 用户态到内核态切换
  2. 写入内核缓冲区
  3. 终端设备处理
  4. 可能的网络传输(远程终端)
    在远程服务器环境中,输出数据还需要:
服务器 → 网络 → 本地终端

因此 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. 程序运行并设置定时器
  2. 1 秒后收到 SIGALRM
  3. 内核调用信号处理函数
  4. 处理函数执行完毕
  5. 程序继续执行原来的代码
    因此我们会看到:
  • 处理函数只执行 一次
  • 程序不会退出
    这是因为我们 覆盖了 SIGALRM 的默认行为
    默认情况下:
SIGALRM → 终止进程

但如果注册了信号处理函数,则由用户代码决定如何处理。


如果希望 每隔一秒执行一次任务,可以在信号处理函数中重新设置定时器,例如:

void handler(int signo)
{
    printf("%d\n", cnt);
    alarm(1);  // 重新设置定时器
}

程序流程变为:

  1. 设置 alarm(1)
  2. 1 秒后收到 SIGALRM
  3. 执行 handler
  4. 在 handler 中再次调用 alarm(1)
  5. 再过 1 秒再次触发信号
    这样就形成了一个 周期性定时器(periodic timer)
    其逻辑可以表示为:
SIGALRM → handler() → alarm(1) → SIGALRM → handler() ...

与 sleep 的逻辑类比
这种机制在逻辑上类似于:

while (true)
{
    sleep(1);
    printf("%d\n", cnt);
}

区别在于:

方法 特点
sleep 主线程阻塞
alarm + signal 通过信号异步触发
alarm() 的方式属于:
异步事件驱动机制

sleep() 属于:

同步阻塞机制

通过该实验可以得到几个关键结论:

  1. I/O 操作远慢于 CPU 运算,频繁输出会严重降低程序执行效率。
  2. alarm() 设置的定时器是 一次性定时器
  3. 若需要周期性触发信号,需要在信号处理函数中 重新调用 alarm()
  4. 使用 alarm() 与信号机制可以实现 基于时间的事件触发模型

操作系统如何管理多个闹钟

任何进程都可以调用 alarm() 设置定时器,因此系统中可能同时存在大量定时器。
操作系统必须对这些定时器进行统一管理。
在操作系统设计中通常遵循一个基本原则:

先描述(Describe),再组织(Organize)

即:

  1. 使用数据结构描述对象
  2. 再通过某种结构组织这些对象
    在操作系统中,多个进程都可以调用 alarm() 设置定时器,因此内核中可能同时存在 大量定时器对象。为了高效管理这些定时器,操作系统需要使用合适的数据结构进行组织。
    常见的定时器管理策略包括:
    时间轮(Time Wheel)
    时间轮是一种高效的定时器管理结构,通过将时间划分为多个槽(slot)来组织定时任务。
    其核心思想是:
时间 → 离散化 → 映射到时间槽

当时间推进时,只需要检查当前时间槽中的定时器即可。
这种结构具有:

  • 插入复杂度:O(1)
  • 删除复杂度:O(1)
  • 检查复杂度:O(1)
    因此常用于 高并发服务器定时器系统
    例如:
  • Nginx
  • Nett
  • Redis
    都使用类似的思想实现定时任务管理。

最小堆(Min Heap)
另一种经典实现方式是使用 最小堆(优先级队列)
假设系统中存在 100 个定时器,每个定时器都有一个 超时时间

5s
10s
20s
55s
...

可以按 超时时间排序 构建最小堆:

            5
         /     \
       10       20
      /  \
    55   ...

最小堆的特点是:

堆顶元素 = 最早到期的定时器

操作流程如下:

  1. 定时器加入系统
    → 插入最小堆
  2. 操作系统检查定时器
检查堆顶节点
  1. 如果:
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() 时:

  1. 内核创建一个 alarm 对象
  2. 填充定时信息
  3. 将其加入 定时器队列

定时器检查机制

操作系统会周期性检查这些定时器,例如:

当前时间 = now

对于每个定时器:

if (now >= alarm.when)

说明:

定时器已经到期

此时内核会执行:

向对应进程发送 SIGALRM

伪代码示意:

for each alarm in alarm_list
{
    if (current_time >= alarm.when)
    {
        send_signal(SIGALRM, alarm.task);
    }
}

因此,从操作系统内部实现角度来看:

alarm() 本质上是 向内核注册一个定时器对象

操作系统负责:

  1. 管理所有定时器
  2. 维护触发时间
  3. 在条件满足时发送信号
    换句话说:
alarm() ≈ 在内核中创建一个定时器任务

Linux 中 alarm() 的工作机制可以概括为:

  1. 进程调用 alarm(seconds)
  2. 内核创建一个定时器对象
  3. 将其加入定时器管理结构
  4. 内核周期性检查定时器
  5. 当时间到达时发送 SIGALRM
  6. 进程执行默认或自定义信号处理
    因此:

SIGALRM 属于 由软件条件(时间到达)触发的信号


软件条件触发信号的本质

通过上述机制可以看到:
操作系统会 周期性检查定时器是否超时
判断条件为:

current_time >= timer.expire_time

当条件成立时:

内核 → 向目标进程发送 SIGALRM

整个过程包括:

  1. 定时器创建
  2. 数据结构管理
  3. 时间检查
  4. 信号发送
    这些行为全部由 操作系统软件逻辑完成
    因此:
SIGALRM 属于 软件条件触发信号

条件就是:

定时器超时

信号产生方式总结

在 Linux 系统中,常见的信号产生方式包括以下几类:

产生方式 示例
用户输入 Ctrl+C → SIGINT
命令/工具 kill 命令
系统调用 kill(), raise()
硬件异常 除零 → SIGFPE
软件条件 定时器超时 → SIGALRM
无论信号来源如何:
最终发送信号的主体都是操作系统

因为只有操作系统才具有 修改进程状态和向进程发送信号的权限

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐