六十天Linux从0到项目搭建(第二十五天)(互斥、信号量、IPC、信号的产生、处理、Alarm)

发布于:2025-04-05 ⋅ 阅读:(16) ⋅ 点赞:(0)

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);

    • 银行转账:扣款和收款必须作为一个原子事务。


四者关系总结

  1. 临界资源是需要保护的共享对象。

  2. 临界区是操作临界资源的代码段。

  3. 互斥是保护临界区的机制(如加锁)。

  4. 原子性是临界区操作的理想特性(通过互斥或硬件支持实现)。


常见面试问题

  • 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. 经典应用场景

  1. 生产者-消费者问题

    • 用两个信号量分别表示空缓冲区数量已填充缓冲区数量

  2. 读者-写者问题

    • 通过信号量控制写者的独占访问。

  3. 线程池任务调度

    • 信号量表示待处理任务数,工作者线程通过P操作获取任务。


感性总结

  • 信号量像门票:有了它,你才能入场(访问资源),否则必须等待。

  • 互斥是特例:VIP单座放映厅的信号量,本质就是一把锁。

  • 灵活性:信号量既能实现互斥,也能管理资源池,是并发编程的“瑞士军刀”。

关键理解:信号量的值代表当前可用的资源数P/V操作是安全分配和回收资源的协议!

3 信号量的本质:计数器

信号量(Semaphore)的核心本质就是一个计数器int count),它记录了当前可用的资源数量。任何执行流(线程/进程)想要访问临界资源中的一个子资源时,必须先通过信号量机制申请权限,而不能直接访问。


1. 信号量的核心逻辑

信号量的定义
struct semaphore {
    int count;          // 当前可用资源数
    Queue wait_queue;   // 等待资源的执行流队列
};
关键规则
  1. count > 0:表示还有count个资源可用,执行流可以直接获取资源(count--)。

  2. count == 0:表示资源已被占满,执行流必须阻塞等待(加入wait_queue)。

  3. 释放资源时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. 信号量的应用场景

  1. 资源池管理(如数据库连接池)。

  2. 生产者-消费者问题(用两个信号量分别控制空缓冲区和满缓冲区)。

  3. 读者-写者问题(允许并发读,但写操作独占)。


总结

  • 信号量是计数器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. 信号量的进程间通信特性

  1. 共享计数器

    • 信号量的count对所有参与的进程可见,且修改是全局性的。

  2. 内核原子性

    • sem_waitsem_post是系统调用,内核确保它们不会被中断。

  3. 跨进程阻塞唤醒

    • 进程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):

  1. 进程A读取count=1

  2. 进程B读取count=1(此时进程A还未修改count)。

  3. 进程A和B均执行count--,最终count=-1超卖问题)。

解决方案:原子操作
  • 硬件支持:CPU提供原子指令(如x86LOCK前缀指令)。

  • 内核实现:通过关闭中断或**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资源对应一个唯一的标识符(如semidshmid等)。

  • 设计思想

    • 统一抽象:将不同类型的IPC资源(信号量、消息队列等)抽象为内核中的同一类数据结构(如struct ipc_perm)。

    • 多态实现:通过共用相同的管理接口(如getctlop),但内部行为因资源类型而异。

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);         // 消息队列
    • 内核实现

      • semgetmsgget最终调用内核的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. 用户层与内核的交互流程
  1. 用户调用semget

    • 通过系统调用进入内核,传递keyflags

  2. 内核查找/创建资源

    • 根据keyipc_ids.entries中查找是否已存在信号量。

    • 若不存在且IPC_CREAT被设置,则分配新的struct sem_array(信号量专用结构体)。

  3. 返回资源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. 信号的处理流程
  1. 信号产生(异步事件触发,如用户按下Ctrl+C)。

  2. 信号记录:内核将信号写入目标进程的信号位图task_struct->signal)。

  3. 信号处理:进程在合适时机(如从内核态返回到用户态)检查并处理信号。


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. 信号的生命周期
  1. 产生:由事件(如硬件异常、用户输入、其他进程)触发。

  2. 注册:进程通过signal()sigaction()预设处理方式。

  3. 记录:内核修改目标进程的信号位图。

  4. 处理:进程在合适时机执行处理函数。


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)

进程可以注册一个信号处理函数,在信号发生时执行自定义逻辑。

  • 处理函数必须是可重入的(避免在信号处理中调用非异步安全函数,如 printfmalloc)。

  • 信号处理是异步的,可能在任何时间点触发。

示例

#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),计算机通过以下步骤检测并生成信号:

  1. 硬件中断

    • 键盘按下时,硬件电路产生一个中断信号(如 IRQ1)。

    • CPU 收到中断后,查询中断向量表,跳转到键盘驱动程序的中断处理函数(如 keyboard_interrupt)。

  2. 读取键盘数据

    • 驱动程序从键盘缓冲区读取扫描码scancode),解析出具体按键(如 Ctrl+C)。

  3. 生成信号

    • 如果按键是 Ctrl+C,内核将其转换为 SIGINT(2号信号)

    • 内核找到前台进程组(即当前正在运行的进程),向其发送 SIGINT

  4. 进程接收信号

    • 目标进程的 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) 不能被捕获或忽略?
    ASIGKILL 是强制终止信号,由内核直接处理,确保管理员能无条件终止失控进程。

  • 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. 闹钟的触发流程
  1. 进程调用 alarm(5)

    • 内核计算到期时间 timestamp = current_time + 5,并将闹钟插入队列。

  2. 系统时钟中断

    • 每次时钟中断(如每毫秒一次),内核检查闹钟队列中是否有到期的闹钟。

  3. 发送 SIGALRM

    • 若到期,内核从队列中移除该闹钟,并向目标进程发送 SIGALRM

  4. 进程处理信号

    • 进程调用注册的 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:如果同时设置多个闹钟会怎样?
    Aalarm() 会覆盖之前的闹钟,setitimer() 可管理多个独立定时器。

  • Q2:闹钟的精度如何?
    A:依赖系统的时钟中断频率(通常毫秒级),setitimer() 比 alarm() 更精确。

  • Q3:如何取消闹钟?
    A:调用 alarm(0) 或 setitimer() 将时间间隔设为 0


6. 总结
组件 作用
alarm() 简单定时,精度低(秒级),会覆盖前一个闹钟。
setitimer() 高精度定时(微秒级),支持周期性触发和多类型定时器。
内核闹钟队列 按时间戳排序,时钟中断时检查并触发到期闹钟。
SIGALRM 闹钟到期时发送的信号,默认终止进程,可自定义处理。

核心思想:闹钟机制通过内核定时器 + 信号,实现了进程的异步事件通知


网站公告

今日签到

点亮在社区的每一天
去签到