信号量
互斥:任何时候只允许一个执行流访问共享资源
临界资源:共享的,任何时刻只允许一个执行流访问的资源叫做临界资源,一般是内存空间(例如管道)
临界区:访问临界资源的代码
信号量:是同步与互斥工具。用于协调多个进程或线程对共享资源的访问顺序,防止数据竞争。是一种"资源计数器"和"访问许可证"。
核心定义
信号量是一个特殊的计数器,用于控制多个执行流(进程/线程)对共享资源的访问。其核心操作(P操作和V操作)是原子操作,意味着在执行过程中不会被中断,从而保证了操作的唯一性和正确性。
工作原理
P操作(等待/尝试)
功能:尝试申请一个资源。信号量的值(计数器)减1。
行为:如果减1后,信号量的值大于等于0,则进程/线程继续执行,获得资源访问权。如果减1后,值小于0,则说明当前无可用资源,该进程/线程被阻塞,进入等待队列,直到有其他进程执行V操作释放资源后将其唤醒。
V 操作 (增加/发信号):
功能:释放一个资源。信号量的值加1。
行为:执行加1后,如果信号量的值大于0,说明没有进程在等待。如果值小于等于0,说明之前有进程在等待,此时会唤醒等待队列中的一个进程。
信号量类型
二元信号量 (Binary Semaphore):其值只能是0或1。本质上等同于互斥锁 (Mutex),用于实现资源的互斥访问(一次只允许一个执行流进入临界区)。
计数信号量 (Counting Semaphore):其值可以大于1。用于控制对多个同类资源实例的并发访问(例如,一个有5个插座的充电站,信号量初始值为5)。
System V信号量
核心概念
信号量集
这是 System V 信号量的核心。它不是单个的计数器,而是一个信号量数组。当你创建一个信号量时,你创建的是一个包含多个(默认为 1 个)信号量的集合。你可以对这个集合中的任何一个信号量进行独立操作,也可以进行原子性的集合操作。
键值
一个唯一的键(通常是 key_t类型),用于在不同进程间标识同一个信号量集。最常用的生成键值的方法是 ftok函数。
信号量值
每个信号量本质是一个非负整数。其经典含义是:> 0: 表示可用资源的数量。= 0: 表示资源已被用完。其值不能为负(但在某些操作的"等待"语义下,进程会阻塞直到值变为正数)
等待 和 发信号 操作
这是操作信号量的两个基本原语,通常被称为 P 操作 和 V 操作。
P 操作: 尝试将信号量的值减 1。如果值大于 0,则成功减 1 并立即返回。如果值等于 0,则调用进程阻塞,直到信号量值变为大于 0。
V 操作: 将信号量的值加 1。如果有其他进程正在因为这个信号量而阻塞,其中一个会被唤醒。
主要系统调用
semget
功能:根据 key创建新的或获取已有的信号量集。
int semget(key_t key, int nsems, int semflg);
参数:(1)key: 信号量集的键值。可以使用 IPC_PRIVATE创建一个只有当前进程及其子进程能访问的新集合。
(2)nsems: 指定要创建/获取的信号量集中信号量的个数。
(3)semflg: 权限标志位(如 0666)和创建选项(如 IPC_CREAT | IPC_EXCL)。
返回值:成功返回信号量集的标识符(一个非负整数),失败返回 -1。
信号量可以申请多个信号量(与信号量是几是两个概念)即信号量集。
semctl
功能:对信号量集或其内的单个信号量进行控制操作。
int semctl(int semid, int semnum, int cmd, ... /* union semun arg */);
参数:
semid: semget返回的信号量集标识符。
semnum: 信号量在集合中的索引(从 0 开始)。对于针对整个集合的操作,此参数被忽略。
cmd: 控制命令,最重要的有:
IPC_RMID: 立即删除信号量集,并唤醒所有因此信号量集而阻塞的进程。
SETVAL: 将第 semnum个信号量的值设置为 arg.val。这是初始化单个信号量最直接的方法。
GETVAL: 获取第 semnum个信号量的当前值。
SETALL: 使用 arg.array指向的数组一次性设置整个集合中所有信号量的值。
GETALL: 获取整个集合中所有信号量的值到 arg.array指向的数组。
IPC_STAT: 获取信号量集的元信息到 arg.buf指向的 struct semid_ds结构。
参数 arg: 一个可选的 union semun联合体,根据 cmd不同而使用不同的成员。这个联合体需要用户自己定义。
例如:
union semun {
int val; /* 用于 SETVAL */
struct semid_ds *buf; /* 用于 IPC_STAT, IPC_SET */
unsigned short *array; /* 用于 GETALL, SETALL */
struct seminfo *__buf; /* 用于 IPC_INFO (Linux特有) */
};
semop
功能:对一个或多个信号量执行原子性的操作。这是实现 P 操作和 V 操作的关键。
int semop(int semid, struct sembuf *sops, unsigned nsops);
参数:semid: 信号量集标识符。
sops: 指向一个 struct sembuf结构体数组的指针,每个结构体描述一个操作。
nsops: sops数组的长度。
struct sembuf结构体:
struct sembuf {
unsigned short sem_num; /* 信号量在集合中的索引 */
short sem_op; /* 操作值 */
short sem_flg; /* 操作标志,如 IPC_NOWAIT, SEM_UNDO */
};
sem_op决定了操作:
sem_op > 0: V 操作。将信号量的值增加 sem_op。这通常对应于释放资源。
sem_op == 0: 等待零。调用者将阻塞,直到该信号量的值变为 0。用于同步场景。
sem_op < 0: P 操作。尝试从信号量的值中减去 |sem_op|。如果信号量的当前值 >= |sem_op|,则减法立即成功。否则,调用者将阻塞,直到信号量值增长到足够大。这对应于获取/请求资源。
sem_flg标志:
IPC_NOWAIT: 如果操作会导致阻塞,则函数立即返回错误 EAGAIN,而不是阻塞。
SEM_UNDO: 非常重要!启用"撤消"机制。如果进程异常终止(未执行 V 操作释放信号量),内核会自动"撤消"该进程对信号量所做的所有更改,防止死锁和资源泄漏。在大多数需要长期持有信号量的场景下都应使用此标志。
信号
基本概念
在 Linux 中,信号(Signal) 是进程间通信(IPC)或内核向进程通知事件的一种基本机制,用于异步处理事件(如中断、异常、终止等)。
前/后台进程
前台进程
终端控制:占用终端(标准输入、输出、错误)
交互性:可接收键盘输入(如 Ctrl+C)
启动方式:直接输入命令运行
Shell行为:Shell 等待进程结束才返回提示符
信号影响:受终端信号影响(如 SIGINT、SIGTSTP)
后台进程
终端控制:不占用标准输入(可输出到终端)
交互性:无法通过键盘直接交互
启动方式:在命令后加 &或 Ctrl+Z+ bg
Shell行为:Shell 立即返回提示符
信号影响:默认忽略 SIGINT,但接收 SIGHUP(可能)
Linux中,一次登录中一个终端一般会配上一个bash,每一个登录只允许一个进程是前台进程,可以允许多个进程是后台进程。
常见信号列表
|------|---------|----------|-------------------------|
| 信号编号 | 信号名 | 默认行为 | 说明 |
| 1 | SIGHUP | 终止 | 终端挂起或控制进程终止 |
| 2 | SIGINT | 终止 | 键盘中断(Ctrl+C) |
| 3 | SIGQUIT | 终止+核心转储 | 键盘退出(Ctrl+\) |
| 9 | SIGKILL | 终止(不可捕获) | 强制杀死进程 |
| 15 | SIGTERM | 终止 | 优雅终止进程(默认 kill 命令发送的信号) |
| 18 | SIGCONT | 继续 | 恢复暂停的进程 |
| 19 | SIGSTOP | 暂停(不可捕获) | 强制暂停进程 |
| 20 | SIGTSTP | 暂停 | 终端挂起(Ctrl+Z) |
(1)每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义 #define SIGINT 2
(2)编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下 产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal
信号的产生方式
终端输入:Ctrl+C(SIGINT)、Ctrl+\`(SIGQUIT)、Ctrl+Z`(SIGTSTP)。
命令发送:kill -信号 PID(如 kill -9 1234)。
系统函数:
kill():向指定进程发送信号。
raise():向自身发送信号。
alarm():定时发送 SIGALRM。
sigqueue():发送带附加数据的信号。
硬件部分
键盘数据如何输入给内核?crtl+c又是如何变成信号的?
背景
CPU和外设(如键盘)的通信分为数据层面和控制层面:
数据层面:CPU不会主动读取外设数据(外设数据需"推"给CPU)。
控制层面:CPU通过硬件针脚(集成在主板上)与外设通信,外设也插在主板上,因此物理上可通过主板电路与CPU间接连接。
键盘数据输入的内核流程
键盘向内核输入数据,核心是"硬件中断 + 驱动处理":
- 硬件层:键盘触发中断
当用户按下键盘按键时:
键盘控制器(硬件模块)会检测到按键事件,通过硬件中断线(主板上的电路)向CPU发送硬件中断信号。
CPU的"中断引脚"(物理针脚)接收到该信号,触发CPU暂停当前任务,转去处理中断。
- CPU层:中断响应与向量表
中断号:每个硬件中断对应唯一的"中断号"(如键盘中断可能对应中断号1 ,不同硬件/系统可能有差异)。
中断向量表:CPU内部有一张"中断向量表",存储了每个中断号对应的处理程序入口地址。当CPU收到中断号时,会根据中断号查向量表,跳转到对应的中断处理程序(如键盘驱动的处理函数)。
寄存器的作用:CPU寄存器本质是高低电平的组合(物理上通过电路存储0/1状态),可临时保存中断上下文(如程序计数器、状态寄存器),保证中断处理完成后能恢复原任务。
- 内核层:驱动处理与数据拷贝
操作系统(内核)的键盘驱动会被调用,执行以下操作:
从键盘控制器的硬件缓冲区中,拷贝按键数据到内核的输入缓冲区。
判断数据类型:如果是普通按键(如字母、数字),则将数据放入"输入队列",供上层应用(如Shell)读取;如果是控制键组合(如Ctrl+C),则进入"信号处理分支"。
crtl+c转换为信号的过程
Ctrl+C 是"终端控制序列",内核会将其识别为终止信号(SIGINT,信号编号通常为2),并发送给前台进程:
- 控制序列识别
当用户按下 Ctrl+C 时,键盘驱动会将这组按键(Ctrl + C)解析为控制命令,而非普通字符数据。
- 信号生成与发送
内核根据控制序列的语义,确定要发送的信号(如 SIGINT )。
内核通过进程控制块(PCB)找到前台进程(当前终端的前台进程组),将信号( SIGINT )标记到该进程的信号位图中(PCB的 struct signal_struct 或类似结构,每个信号对应一个比特位,1表示该信号待处理)。
示例图:

信号处理方式
默认动作:执行系统预设操作(终止、暂停、忽略等)。
忽略:直接丢弃信号(SIGKILL、SIGSTOP 不可忽略)。
自定义动作:通过 signal()或 sigaction()注册信号处理器。
示例:
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
void handler(int sig) {
printf("收到信号: %d\n", sig);
}
int main() {
signal(SIGINT, handler); // 捕获 Ctrl+C
while(1) pause();
return 0;
}