1 互斥(Mutual Exclusion)
定义:保证同一时刻只有一个执行流(线程/进程)能访问共享资源(如变量、文件、设备等)。
作用:解决并发访问导致的竞态条件(Race Condition)。
实现方式:通过锁(如互斥锁、信号量)实现。执行流进入临界区前加锁,退出时解锁。
示例:
pthread_mutex_lock(&mutex); // 加锁 // 临界区代码(访问共享资源) pthread_mutex_unlock(&mutex); // 解锁
2. 临界资源(Critical Resource)
定义:任何时刻只能被一个执行流访问的共享资源(如全局变量、内存块、打印机等)。
特点:
必须通过互斥机制保护。
多个执行流同时访问可能导致数据不一致或错误。
示例:多线程共用的计数器、数据库中的某条记录。
3. 临界区(Critical Section)
定义:访问临界资源的代码段。这部分代码需要被保护,以避免并发访问。
要求:
互斥进入:同一时间仅允许一个执行流在临界区内。
有限等待:其他执行流应在合理时间内获得访问权(避免饥饿)。
示例:
void thread_func() { // 非临界区代码... pthread_mutex_lock(&mutex); // 进入临界区 counter++; // 操作临界资源 pthread_mutex_unlock(&mutex); // 退出临界区 }
4. 原子性(Atomicity)
定义:操作不可被中断的特性,即**“要么全部执行,要么完全不执行”**,不存在中间状态。
重要性:原子操作是解决竞态条件的基础(如无锁编程)。
实现方式:
硬件支持:CPU提供的原子指令(如x86的
CAS
指令)。软件实现:通过锁模拟原子性(如数据库事务的原子提交)。
示例:
原子自增:
atomic_fetch_add(&counter, 1);
银行转账:扣款和收款必须作为一个原子事务。
四者关系总结
临界资源是需要保护的共享对象。
临界区是操作临界资源的代码段。
互斥是保护临界区的机制(如加锁)。
原子性是临界区操作的理想特性(通过互斥或硬件支持实现)。
常见面试问题
Q:如何保证临界区的线程安全?
A:通过互斥锁、信号量或原子操作实现互斥访问。Q:原子性和互斥的区别?
A:原子性强调操作的不可分割性(如单条机器指令),而互斥是通过锁实现的代码段串行化。Q:若没有互斥机制会怎样?
A:可能导致数据竞争(Data Race),结果依赖于线程执行顺序(非确定性错误)。
理解这些概念对设计高并发程序至关重要!
2.信号量的感性认识
信号量(Semaphore)是一种用于协调多线程/多进程访问共享资源的机制,可以类比为现实生活中的**“资源预约系统”**。它的核心思想是:通过计数器的增减,控制资源的访问权限,确保系统不会因资源竞争而陷入混乱。
1. 信号量的现实类比:电影院购票
想象一个电影院:
资源:放映厅的座位。
问题:如果电影票卖多了(超卖),观众会争夺同一个座位,导致冲突。
解决方案:售票系统(信号量)确保卖出的票数 ≤ 实际座位数。
场景分析
场景 | 信号量作用 | 对应编程概念 |
---|---|---|
普通放映厅(100个座位) | 每卖一张票,剩余座位数减1(P 操作);退票时加1(V 操作)。 |
计数信号量(资源数量可大于1) |
VIP放映厅(1个座位) | 唯一座位被占用时,其他人必须等待(类似锁)。 | 二进制信号量(互斥锁,资源数量=1) |
2. 信号量的核心操作
信号量通过两个原子操作控制资源:
P
操作(Proberen,荷兰语的“尝试”):行为:申请资源,信号量值减1;若值已为0,则阻塞等待。
类比:买票时检查剩余座位,若无票则排队。
V
操作(Verhogen,荷兰语的“增加”):行为:释放资源,信号量值加1;若有等待者,唤醒一个。
类比:退票后空出座位,通知排队的人。
#include <semaphore.h> sem_t sem; sem_init(&sem, 0, 10); // 初始化信号量,初始值=10(10个可用资源) // 线程A:申请资源(P操作) sem_wait(&sem); // 访问共享资源... sem_post(&sem); // 释放资源(V操作)
3. 信号量的两种类型
(1) 计数信号量(Counting Semaphore)
用途:管理多个同类资源(如线程池任务队列、缓冲区空位)。
特点:信号量初始值 = 资源总数。
示例:
数据库连接池(10个连接)→ 初始信号量值=10。
生产者-消费者模型中的缓冲区空位。
(2) 二进制信号量(Binary Semaphore)
用途:实现互斥锁(临界区保护)。
特点:信号量初始值=1,相当于互斥锁的“开/关”状态。
示例:
sem_t mutex; sem_init(&mutex, 0, 1); // 二进制信号量(互斥锁) sem_wait(&mutex); // 加锁 // 临界区代码... sem_post(&mutex); // 解锁
4. 信号量 vs 互斥锁
特性 | 信号量 | 互斥锁 |
---|---|---|
资源数量 | 可管理多个资源(计数信号量) | 仅保护1个资源 |
持有者 | 无需由同一线程释放 | 必须由加锁线程解锁 |
用途 | 同步(如生产者-消费者)、互斥 | 严格互斥 |
5. 经典应用场景
生产者-消费者问题
用两个信号量分别表示空缓冲区数量和已填充缓冲区数量。
读者-写者问题
通过信号量控制写者的独占访问。
线程池任务调度
信号量表示待处理任务数,工作者线程通过
P
操作获取任务。
感性总结
信号量像门票:有了它,你才能入场(访问资源),否则必须等待。
互斥是特例:VIP单座放映厅的信号量,本质就是一把锁。
灵活性:信号量既能实现互斥,也能管理资源池,是并发编程的“瑞士军刀”。
关键理解:信号量的值代表当前可用的资源数,P/V
操作是安全分配和回收资源的协议!
3 信号量的本质:计数器
信号量(Semaphore)的核心本质就是一个计数器(int count
),它记录了当前可用的资源数量。任何执行流(线程/进程)想要访问临界资源中的一个子资源时,必须先通过信号量机制申请权限,而不能直接访问。
1. 信号量的核心逻辑
信号量的定义
struct semaphore { int count; // 当前可用资源数 Queue wait_queue; // 等待资源的执行流队列 };
关键规则
count > 0
:表示还有count
个资源可用,执行流可以直接获取资源(count--
)。count == 0
:表示资源已被占满,执行流必须阻塞等待(加入wait_queue
)。释放资源时:
count++
,并唤醒一个等待的执行流(如果有)。
2. 信号量的工作流程
假设初始时,信号量count = N
(共有N
个资源):
执行流动作 | 信号量操作 | count 变化 |
行为说明 |
---|---|---|---|
申请资源(P 操作) |
sem_wait(&sem) |
count-- |
若count >= 0 ,成功获取资源;否则阻塞。 |
释放资源(V 操作) |
sem_post(&sem) |
count++ |
释放资源,若wait_queue 非空,唤醒一个等待者。 |
3. 为什么不能直接访问资源?
问题场景
假设有一个全局变量int ticket = 100
(表示剩余票数),多个线程并发抢票:
// 错误示例:直接访问临界资源 if (ticket > 0) { ticket--; // 可能导致超卖(竞态条件) }
风险:两个线程可能同时读到
ticket=1
,都执行ticket--
,最终ticket=-1
(超卖)。
信号量解决方案
sem_t sem; sem_init(&sem, 0, 100); // 初始100张票 // 正确示例:通过信号量控制访问 sem_wait(&sem); // P操作:原子地检查并减少count if (ticket > 0) { ticket--; } sem_post(&sem); // V操作:释放资源(此例中可省略,但需保持对称)
关键:
sem_wait
是原子操作,确保count
的检查和减1不会被中断。
4. 信号量的两种类型
(1) 计数信号量(Count > 1)
用途:管理多个同类资源(如线程池任务、缓冲区空位)。
示例:
sem_t empty_slots; sem_init(&empty_slots, 0, 10); // 缓冲区有10个空位
(2) 二进制信号量(Count = 1)
用途:实现互斥锁(临界区保护)。
示例:
sem_t mutex; sem_init(&mutex, 0, 1); // 二进制信号量(类似互斥锁) sem_wait(&mutex); // 加锁 // 临界区代码... sem_post(&mutex); // 解锁
5. 信号量的底层实现
操作系统内核通常通过原子指令(如CAS
)和线程阻塞/唤醒机制实现信号量:
sem_wait
伪代码:void sem_wait(sem_t *sem) { disable_interrupts(); // 关闭中断(避免上下文切换) while (sem->count <= 0) { enqueue(sem->wait_queue, current_thread); block(current_thread); // 阻塞当前线程 } sem->count--; enable_interrupts(); }
sem_post
伪代码:void sem_post(sem_t *sem) { disable_interrupts(); sem->count++; if (!empty(sem->wait_queue)) { thread = dequeue(sem->wait_queue); wakeup(thread); // 唤醒一个等待线程 } enable_interrupts(); }
6. 信号量的应用场景
资源池管理(如数据库连接池)。
生产者-消费者问题(用两个信号量分别控制空缓冲区和满缓冲区)。
读者-写者问题(允许并发读,但写操作独占)。
总结
信号量是计数器:
count
表示当前可用资源数。P/V操作是原子操作:确保资源分配的正确性。
直接访问资源的风险:竞态条件导致数据不一致。
互斥锁是信号量的特例:当
count=1
时,信号量退化为互斥锁。
核心思想:信号量通过计数器+阻塞唤醒机制,实现了对共享资源的安全分配!
4 信号量作为进程间通信(IPC)的机制
两个进程能否看到同一个int count
(信号量的计数器)?
可以!这正是信号量被归类为**进程间通信(IPC, Inter-Process Communication)**的原因。
1. 为什么需要进程间共享信号量?
场景:多个进程需要协同访问同一个共享资源(如打印机、共享内存、文件等)。
问题:进程的地址空间是隔离的,普通变量(如全局
int count
)无法直接被其他进程访问。解决方案:
信号量的计数器(
count
)必须存放在内核或共享内存中,对所有进程可见。通过系统调用(如
sem_wait
/sem_post
)操作信号量,内核保证其原子性。
2. 信号量在进程间的实现方式
(1) 内核维护的信号量
原理:信号量的计数器(
count
)由操作系统内核管理,进程通过系统调用访问。特点:
内核保证操作的原子性(避免竞态条件)。
信号量标识符(如
semid
)在进程间共享。
示例(Linux系统调用):
#include <sys/sem.h> int semid = semget(IPC_PRIVATE, 1, 0666); // 创建信号量 semctl(semid, 0, SETVAL, 1); // 初始化count=1 struct sembuf op = {0, -1, 0}; // P操作(申请资源) semop(semid, &op, 1);
(2) 基于共享内存的信号量
原理:将信号量的
count
变量放在共享内存中,进程通过映射同一块内存访问。风险:需自行保证原子性(如结合硬件指令或互斥锁)。
示例:
int *count = mmap(NULL, sizeof(int), PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0); *count = 10; // 多个进程可看到同一个count
3. 信号量 vs 其他进程间通信(IPC)
机制 | 用途 | 是否支持同步 |
---|---|---|
信号量 | 控制对共享资源的访问 | 是(通过P/V操作) |
共享内存 | 高效共享数据 | 否(需额外同步机制) |
管道/消息队列 | 传输数据 | 是(但设计目的不同) |
4. 信号量的进程间通信特性
共享计数器:
信号量的
count
对所有参与的进程可见,且修改是全局性的。
内核原子性:
sem_wait
和sem_post
是系统调用,内核确保它们不会被中断。
跨进程阻塞唤醒:
进程A因
count=0
阻塞后,进程B的V
操作可唤醒A。
5. 代码示例:父子进程共享信号量
#include <sys/sem.h> #include <stdio.h> #include <unistd.h> int main() { int semid = semget(IPC_PRIVATE, 1, 0666); // 创建信号量 semctl(semid, 0, SETVAL, 1); // 初始化count=1 if (fork() == 0) { // 子进程 struct sembuf op = {0, -1, 0}; // P操作 semop(semid, &op, 1); printf("Child process enters critical section.\n"); sleep(2); op.sem_op = 1; // V操作 semop(semid, &op, 1); } else { // 父进程 struct sembuf op = {0, -1, 0}; // P操作 semop(semid, &op, 1); printf("Parent process enters critical section.\n"); sleep(2); op.sem_op = 1; // V操作 semop(semid, &op, 1); } return 0; }
输出:
父子进程会交替进入临界区,不会同时打印(互斥生效)。
6. 为什么信号量属于IPC?
共享资源管理:信号量的核心功能是协调多进程对共享资源的访问。
内核支持:信号量的生命周期和操作依赖内核(跨进程可见)。
同步需求:解决进程间的竞态条件问题(如生产者-消费者模型)。
总结
信号量的
count
是跨进程共享的:通过内核或共享内存实现。信号量是IPC的一种:因为它解决了进程间的同步与互斥问题。
与线程间信号量的区别:
线程间信号量的
count
在进程地址空间内(如pthread_sem_t
)。进程间信号量的
count
在内核或共享内存中。
关键点:信号量通过共享计数器+内核原子操作,实现了进程间对资源的安全协同访问!
5 信号量的核心规则与原子性保证
信号量的本质是一个资源计数器(count
),所有进程必须遵守统一的**“游戏规则”**来访问临界资源。以下是其核心逻辑和关键要求:
1. 信号量的核心规则
(1) 申请资源(P操作)
P(semaphore): if (count > 0): count--; // 成功获取资源 else: 挂起当前进程,加入等待队列; // 阻塞
行为:
若
count > 0
,表示有可用资源,进程直接占用(count--
)。若
count == 0
,表示资源已耗尽,进程阻塞等待。
(2) 释放资源(V操作)
V(semaphore): count++; // 释放资源 if (等待队列非空): 唤醒一个等待进程; // 通知资源可用
行为:
进程释放资源时,
count++
。若有其他进程在等待,唤醒其中一个(使其重新尝试
P
操作)。
2. 为什么必须保证++
/--
是原子的?
问题场景(非原子操作)
假设两个进程并发执行count--
(初始count=1
):
进程A读取
count=1
。进程B读取
count=1
(此时进程A还未修改count
)。进程A和B均执行
count--
,最终count=-1
(超卖问题)。
解决方案:原子操作
硬件支持:CPU提供原子指令(如
x86
的LOCK
前缀指令)。内核实现:通过关闭中断或**CAS(Compare-And-Swap)**确保
P/V
操作的原子性。
伪代码(原子count--
):
asm
LOCK: mov eax, [count] ; 读取count到寄存器 cmp eax, 0 ; 检查是否>0 jle BLOCK ; 若<=0,跳转到阻塞逻辑 dec eax ; 原子减1 mov [count], eax ; 写回新值
3. 信号量的关键特性
特性 | 说明 |
---|---|
全局可见性 | 所有进程必须看到同一个count (通过内核或共享内存实现)。 |
原子性操作 | P/V 操作必须不可中断(由内核或硬件保证)。 |
阻塞唤醒机制 | 资源不足时挂起进程,释放资源时唤醒等待者。 |
公平性 | 通常按FIFO唤醒等待进程(避免饥饿)。 |
4. 代码示例(Linux系统V信号量)
#include <sys/sem.h> #include <stdio.h> int main() { // 1. 创建信号量(初始count=1) int semid = semget(IPC_PRIVATE, 1, 0666); semctl(semid, 0, SETVAL, 1); // 2. P操作(申请资源) struct sembuf op_p = {0, -1, 0}; // 信号量编号0,操作-1(P操作) semop(semid, &op_p, 1); printf("Entered critical section. count=%d\n", semctl(semid, 0, GETVAL)); // 3. 临界区代码... sleep(2); // 4. V操作(释放资源) struct sembuf op_v = {0, 1, 0}; // 操作+1(V操作) semop(semid, &op_v, 1); printf("Left critical section. count=%d\n", semctl(semid, 0, GETVAL)); return 0; }
5. 常见问题
Q1:如果P/V
操作不是原子的会怎样?
后果:多个进程可能同时看到
count>0
并执行count--
,导致count
变为负数(资源超分配)。
Q2:信号量的count
可以初始化为负数吗?
答案:可以,但表示初始时有进程在等待(通常用于同步场景,如生产者-消费者问题中的缓冲区初始状态)。
Q3:信号量和互斥锁的区别?
信号量:可管理多个资源(
count≥1
),支持进程间同步。互斥锁:本质是
count=1
的信号量,仅用于互斥。
总结
信号量是资源计数器:
count
表示当前可用资源数。P/V操作必须原子化:由内核或硬件指令保证,避免竞态条件。
进程必须遵守规则:先
P
操作申请资源,后V
操作释放资源。内核是关键:信号量的全局可见性和原子性依赖操作系统内核的实现。
核心思想:信号量通过原子计数器+阻塞唤醒机制,实现了多进程对共享资源的安全、有序访问!
6 IPC资源的内核统一管理与多态思想
1. IPC资源的内核管理机制
在操作系统中,进程间通信(IPC)资源(如信号量、消息队列、共享内存等)由内核统一管理。内核通常采用全局数组或链表结构来组织这些资源,每个IPC资源对应一个唯一的标识符(如semid
、shmid
等)。
设计思想:
统一抽象:将不同类型的IPC资源(信号量、消息队列等)抽象为内核中的同一类数据结构(如
struct ipc_perm
)。多态实现:通过共用相同的管理接口(如
get
、ctl
、op
),但内部行为因资源类型而异。
2. 内核中的IPC资源数组
Linux内核中,IPC资源通常通过以下方式管理:
struct ipc_ids { struct kern_ipc_perm *entries[IPC_MAX]; // 全局数组,存储所有IPC资源 int max_id; // ...其他元数据 }; struct kern_ipc_perm { key_t key; // 资源键值(用户层指定) uid_t uid; // 所有者UID mode_t mode; // 权限 int id; // 资源ID(如semid) // ...其他公共字段 };
信号量、消息队列、共享内存均继承自
kern_ipc_perm
,但各自扩展专用字段(如信号量的count
、消息队列的msg_queue
等)。
3. 多态在IPC中的体现
多态(Polymorphism):同一接口(如
ipc()
系统调用)根据资源类型(信号量/消息队列等)执行不同操作。示例:
// 用户层调用:创建信号量或消息队列 int semid = semget(key, nsems, flags); // 信号量 int msgid = msgget(key, flags); // 消息队列
内核实现:
semget
和msgget
最终调用内核的ipc_get()
函数,但传入不同的ops
结构体(包含资源类型特定的方法)。
struct ipc_ops { int (*get)(struct ipc_namespace *, struct ipc_params *); int (*ctl)(struct ipc_namespace *, int, int, void __user *); // ...其他操作 }; static struct ipc_ops sem_ops = { .get = semget, .ctl = semctl }; static struct ipc_ops msg_ops = { .get = msgget, .ctl = msgctl };
4. 用户层与内核的交互流程
用户调用
semget
:通过系统调用进入内核,传递
key
和flags
。
内核查找/创建资源:
根据
key
在ipc_ids.entries
中查找是否已存在信号量。若不存在且
IPC_CREAT
被设置,则分配新的struct sem_array
(信号量专用结构体)。
返回资源ID:
用户层获得
semid
,后续通过semop
等操作信号量。
5. 多态的优势
代码复用:内核只需维护一套IPC资源管理框架(如权限检查、ID分配)。
扩展性:新增IPC类型(如未来支持的资源)只需实现对应的
ipc_ops
,无需修改核心逻辑。一致性:用户层对不同IPC资源的操作接口风格统一(如
get
/ctl
/op
)。
6. 示例:信号量与消息队列的对比
特性 | 信号量(Semaphore) | 消息队列(Message Queue) |
---|---|---|
内核结构体 | struct sem_array |
struct msg_queue |
资源标识符 | semid |
msgid |
核心操作 | sem_wait /sem_post |
msgsnd /msgrcv |
多态实现 | 共用ipc_ids 数组,但操作由sem_ops 定义 |
共用数组,操作由msg_ops 定义 |
7. 总结
IPC资源统一管理:内核通过全局数组(如
ipc_ids.entries
)组织所有IPC资源,以kern_ipc_perm
为基类。多态的核心:相同的管理接口(如
get
/ctl
),不同的具体行为(通过ipc_ops
实现)。设计价值:提升内核代码的模块化和可维护性,同时为用户层提供一致的IPC体验。
关键点:IPC的多态设计是操作系统**“抽象与复用”**思想的经典体现!
7 信号(Signal)的本质与处理机制
1. 什么是信号?
信号是操作系统向进程传递的异步通知,用于告知进程某个事件已发生。类比生活中的信号:
红绿灯:红灯亮时,你知道要停车(因为交规“培养”了你的反应)。
闹钟:铃声响起,你知道该起床(预设了处理逻辑)。
进程信号:收到
SIGINT
(Ctrl+C)时,进程知道要终止(程序员预设了处理方式)。
核心特点:
信号是一个整数编号(如
SIGKILL=9
)。进程提前知道如何处理信号(即使信号尚未产生)。
2. 信号的处理流程
信号产生(异步事件触发,如用户按下Ctrl+C)。
信号记录:内核将信号写入目标进程的信号位图(
task_struct->signal
)。信号处理:进程在合适时机(如从内核态返回到用户态)检查并处理信号。
3. 信号如何被记录?
位图(Bitmap):
进程通过task_struct
中的位图(如uint32_t signals
)记录收到的信号。比特位位置:信号编号(如
SIGINT
对应第2位)。比特位值:
1
表示收到该信号,0
表示未收到。
// 内核中的信号位图(简化版) struct task_struct { unsigned long signals; // 位图,例如:0000 0000 0000 0010 表示收到SIGINT(2号信号) // ... };
发送信号:
本质是修改目标进程的信号位图(由内核完成):// 内核函数(伪代码) void send_signal(int pid, int signo) { struct task_struct *task = find_task_by_pid(pid); task->signals |= (1 << signo); // 将对应比特位置1 }
4. 为什么信号处理是异步的?
优先级问题:进程可能正在执行更重要的任务(如系统调用),无法立即处理信号。
处理时机:
在内核态切换到用户态时,内核会检查信号位图。
若发现未处理的信号,调用进程注册的信号处理函数(如
signal(SIGINT, handler)
)。
5. 信号的“先描述,再组织”
描述:用位图(
signals
)表示信号的状态。组织:通过
task_struct
将信号与进程关联,内核统一管理所有进程的信号。
6. 信号处理示例
#include <stdio.h> #include <signal.h> #include <unistd.h> void handler(int signo) { printf("Received signal %d\n", signo); } int main() { signal(SIGINT, handler); // 注册SIGINT的处理函数 while (1) { printf("Running...\n"); sleep(1); } return 0; }
运行结果:
按下
Ctrl+C
时,进程收到SIGINT
,调用handler
函数。注意:若未注册处理函数,默认行为是终止进程。
7. 信号的生命周期
产生:由事件(如硬件异常、用户输入、其他进程)触发。
注册:进程通过
signal()
或sigaction()
预设处理方式。记录:内核修改目标进程的信号位图。
处理:进程在合适时机执行处理函数。
8. 关键总结
信号是整数编号:进程通过位图记录收到的信号。
异步性:信号可能在任何时候产生,但处理时机由内核调度。
内核管控:所有信号的发送和记录由操作系统完成(用户无法直接修改
task_struct
)。设计哲学:
“先描述”:用数据结构(位图)表示信号状态。
“再组织”:通过
task_struct
嵌入信号字段,实现统一管理。
核心结论:信号机制是操作系统**“事件驱动”的体现,通过异步通知+预设处理逻辑**实现进程的高效响应!
8. 信号处理的三种方式
信号的处理方式可以分为以下三种,进程可以通过系统调用(如 signal()
或 sigaction()
)来指定对某个信号的处理方式:
1. 默认动作(Default Action)
每个信号都有一个默认行为,通常由操作系统预先定义。常见的默认动作包括:
终止进程(Terminate):如
SIGKILL (9)
、SIGTERM (15)
。终止并生成核心转储(Core Dump):如
SIGSEGV (11)
(段错误)。忽略(Ignore):如
SIGCHLD (17)
(子进程状态变化时默认忽略)。暂停进程(Stop):如
SIGSTOP (19)
(强制暂停进程)。继续进程(Continue):如
SIGCONT (18)
(恢复被暂停的进程)。
示例:
// 不对 SIGINT 做特殊处理,使用默认行为(终止进程) signal(SIGINT, SIG_DFL);
适用场景:
当进程不需要特殊处理某个信号时,使用默认行为即可。
2. 忽略信号(Ignore Signal)
进程可以显式地告诉操作系统忽略某个信号,即收到该信号时不采取任何行动。
注意:
SIGKILL (9)
和SIGSTOP (19)
不能被捕获或忽略(强制终止或暂停进程)。
示例:
// 忽略 SIGINT(Ctrl+C 将不再终止进程) signal(SIGINT, SIG_IGN);
适用场景:
不希望进程被某些信号中断(如后台守护进程忽略
SIGHUP
)。
3. 自定义动作(User-Defined Handler)
进程可以注册一个信号处理函数,在信号发生时执行自定义逻辑。
处理函数必须是可重入的(避免在信号处理中调用非异步安全函数,如
printf
、malloc
)。信号处理是异步的,可能在任何时间点触发。
示例:
#include <stdio.h> #include <signal.h> #include <unistd.h> void sigint_handler(int signo) { printf("Caught SIGINT (Ctrl+C), but I won't die!\n"); // 注意:printf 不是异步安全的,仅用于演示 } int main() { // 注册自定义处理函数 signal(SIGINT, sigint_handler); while (1) { printf("Running...\n"); sleep(1); } return 0; }
运行效果:
按下
Ctrl+C
时,进程不会终止,而是执行sigint_handler
。若要退出,可使用
kill -9 <PID>
(SIGKILL
无法被捕获)。
适用场景:
优雅清理资源(如
SIGTERM
时保存数据再退出)。实现特殊逻辑(如
SIGUSR1
触发日志轮转)。
4. 信号处理的高级控制(sigaction
)
signal()
是简化版接口,更推荐使用 sigaction()
,它提供更精细的控制:
struct sigaction { void (*sa_handler)(int); // 信号处理函数 sigset_t sa_mask; // 执行处理函数时阻塞的信号 int sa_flags; // 控制行为(如 SA_RESTART) }; // 示例:使用 sigaction 注册处理函数 struct sigaction act; act.sa_handler = sigint_handler; sigemptyset(&act.sa_mask); // 清空阻塞信号集 act.sa_flags = SA_RESTART; // 系统调用被中断后自动重启 sigaction(SIGINT, &act, NULL); // 注册
优势:
可指定阻塞信号集(防止处理函数被嵌套调用)。
支持
SA_RESTART
(自动重启被中断的系统调用)。
5. 关键总结
处理方式 | 特点 | 适用场景 |
---|---|---|
默认动作 | 由操作系统定义(终止、忽略、暂停等)。 | 无需特殊处理的信号。 |
忽略信号 | 明确告知内核忽略信号(SIGKILL /SIGSTOP 除外)。 |
防止进程被意外中断。 |
自定义处理 | 注册用户函数,实现灵活逻辑(需注意异步安全性)。 | 优雅退出、自定义事件响应。 |
核心原则:
信号处理是异步的,设计处理函数时应保持简单(避免竞态条件)。
优先使用
sigaction
而非signal
(更安全、功能更全)。SIGKILL
和SIGSTOP
无法被捕获或忽略(确保管理员能强制控制进程)。
9. 信号的产生:从键盘输入到进程接收信号的过程
1. 键盘输入如何触发信号?
当你按下键盘(如 Ctrl+C
),计算机通过以下步骤检测并生成信号:
硬件中断
键盘按下时,硬件电路产生一个中断信号(如
IRQ1
)。CPU 收到中断后,查询中断向量表,跳转到键盘驱动程序的中断处理函数(如
keyboard_interrupt
)。
读取键盘数据
驱动程序从键盘缓冲区读取扫描码(
scancode
),解析出具体按键(如Ctrl+C
)。
生成信号
如果按键是
Ctrl+C
,内核将其转换为SIGINT
(2号信号)。内核找到前台进程组(即当前正在运行的进程),向其发送
SIGINT
。
进程接收信号
目标进程的
task_struct
中的信号位图(signals
)对应位置1
(表示收到SIGINT
)。进程在合适时机(如从内核态返回用户态时)检查信号,并执行默认动作(终止)或自定义处理函数。
2. 详细流程解析
(1) 硬件中断(IRQ)
中断号:键盘通常使用
IRQ1
(x86架构)。中断向量表:CPU 根据中断号找到对应的处理函数(如
keyboard_interrupt
)。
(2) 键盘驱动解析按键
键盘控制器将按键转换为扫描码(
scancode
),例如:Ctrl
的扫描码:0x1D
C
的扫描码:0x2E
驱动组合判断
Ctrl+C
,并通知内核生成SIGINT
。
(3) 内核发送信号
前台进程组:由 shell 管理,
Ctrl+C
默认发送给前台进程。写入信号:内核修改目标进程的
task_struct->signal
位图,将SIGINT
对应的比特位置1
。
(4) 进程处理信号
默认行为:
SIGINT
的默认动作是终止进程。自定义处理:如果进程注册了
signal(SIGINT, handler)
,则调用handler
函数。
3. 代码示例:模拟 Ctrl+C
发送 SIGINT
#include <stdio.h> #include <signal.h> #include <unistd.h> void handler(int sig) { printf("Received SIGINT (%d)\n", sig); } int main() { signal(SIGINT, handler); // 注册SIGINT处理函数 printf("Press Ctrl+C to test...\n"); while (1) sleep(1); // 等待信号 return 0; }
运行结果:
Press Ctrl+C to test... ^CReceived SIGINT (2) # 按下Ctrl+C后触发handler
4. 关键点总结
步骤 | 关键行为 |
---|---|
硬件中断 | 键盘按下触发 IRQ1 ,CPU 调用键盘驱动。 |
按键解析 | 驱动读取扫描码,识别 Ctrl+C 。 |
信号生成 | 内核将 Ctrl+C 映射为 SIGINT (2号信号)。 |
信号发送 | 内核修改目标进程的 task_struct->signals 位图。 |
信号处理 | 进程在合适时机检查信号,执行默认动作或自定义处理函数。 |
5. 扩展问题
Q1:为什么
SIGKILL (9)
不能被捕获或忽略?
A:SIGKILL
是强制终止信号,由内核直接处理,确保管理员能无条件终止失控进程。Q2:如何自定义
Ctrl+C
的行为?
A:通过signal(SIGINT, handler)
或sigaction()
注册处理函数。Q3:信号和中断的区别?
A:中断是硬件触发的(如键盘、定时器),信号是软件层面的通知(由内核或进程发送)。
核心结论:Ctrl+C
的信号处理流程是 “硬件中断 → 驱动解析 → 内核发送 → 进程处理” 的经典案例!
10 系统调用:OS 中的闹钟(Alarm)机制
1. 闹钟(Alarm)的核心概念
在操作系统中,闹钟(Alarm) 是一种定时信号机制,允许进程在指定时间后接收 SIGALRM
(14号信号)。
本质:内核维护一个定时器队列,记录每个进程设置的闹钟时间。
触发条件:当系统时间 ≥
当前时间 + 设定的时间间隔
时,内核向进程发送SIGALRM
。
2. 内核如何管理闹钟?
(1) 数据结构
内核通常为每个进程维护一个闹钟信息结构体(如 struct alarm
),包含:
struct alarm { int timestamp; // 闹钟到期的时间戳(绝对时间) pid_t pid; // 目标进程ID struct alarm *next; // 指向下一个闹钟(链表结构) };
全局闹钟队列:所有未触发的闹钟按
timestamp
排序,存放在内核的优先级队列或时间轮中。
(2) 系统调用 alarm()
#include <unistd.h> unsigned int alarm(unsigned int seconds);
功能:设置一个
seconds
秒后触发的闹钟,覆盖之前的闹钟(如果存在)。返回值:返回上一个闹钟的剩余时间(若之前未设置则返回
0
)。
示例:
alarm(5); // 5秒后发送SIGALRM
(3) 系统调用 setitimer()
(更精确的定时)
#include <sys/time.h> int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);
支持多种定时器:
ITIMER_REAL
:真实时间(触发SIGALRM
)。ITIMER_VIRTUAL
:进程用户态CPU时间(触发SIGVTALRM
)。ITIMER_PROF
:进程总CPU时间(用户态+内核态,触发SIGPROF
)。
3. 闹钟的触发流程
进程调用
alarm(5)
:内核计算到期时间
timestamp = current_time + 5
,并将闹钟插入队列。
系统时钟中断:
每次时钟中断(如每毫秒一次),内核检查闹钟队列中是否有到期的闹钟。
发送
SIGALRM
:若到期,内核从队列中移除该闹钟,并向目标进程发送
SIGALRM
。
进程处理信号:
进程调用注册的
SIGALRM
处理函数(若未注册则默认终止)。
4. 代码示例
(1) 使用 alarm()
实现超时控制
#include <stdio.h> #include <unistd.h> #include <signal.h> void handler(int sig) { printf("Timeout!\n"); _exit(1); } int main() { signal(SIGALRM, handler); alarm(3); // 3秒后触发SIGALRM printf("Waiting for input...\n"); char buf[100]; if (read(STDIN_FILENO, buf, sizeof(buf)) < 0) { perror("read"); } alarm(0); // 取消闹钟(若输入完成) return 0; }
运行效果:
若3秒内无输入,触发
SIGALRM
并退出。若3秒内有输入,
alarm(0)
取消闹钟。
(2) 使用 setitimer()
实现周期性定时
#include <stdio.h> #include <signal.h> #include <sys/time.h> void handler(int sig) { printf("Tick!\n"); } int main() { signal(SIGALRM, handler); struct itimerval timer = { .it_interval = {1, 0}, // 每隔1秒触发一次 .it_value = {1, 0} // 首次触发在1秒后 }; setitimer(ITIMER_REAL, &timer, NULL); while (1) pause(); // 等待信号 return 0; }
输出:
Tick! (每秒打印一次)
5. 关键问题
Q1:如果同时设置多个闹钟会怎样?
A:alarm()
会覆盖之前的闹钟,setitimer()
可管理多个独立定时器。Q2:闹钟的精度如何?
A:依赖系统的时钟中断频率(通常毫秒级),setitimer()
比alarm()
更精确。Q3:如何取消闹钟?
A:调用alarm(0)
或setitimer()
将时间间隔设为0
。
6. 总结
组件 | 作用 |
---|---|
alarm() |
简单定时,精度低(秒级),会覆盖前一个闹钟。 |
setitimer() |
高精度定时(微秒级),支持周期性触发和多类型定时器。 |
内核闹钟队列 | 按时间戳排序,时钟中断时检查并触发到期闹钟。 |
SIGALRM |
闹钟到期时发送的信号,默认终止进程,可自定义处理。 |
核心思想:闹钟机制通过内核定时器 + 信号,实现了进程的异步事件通知!