信号量是一种用于实现进程间同步与互斥的经典机制,由操作系统提供,通过 P(wait)和 V(signal)两个原子操作来管理。它本质上是一个整型变量(或记录型变量),记录了可用资源的数量,并关联一个等待队列,用于阻塞暂时无法获取资源的进程 。

一、信号量的基本结构与操作

信号量通常分为两种类型:

  • 整型信号量:仅有一个整数值,用于表示资源数量,但不记录等待进程,可能导致“忙等” 。
  • 记录型信号量:包含一个整型值 value 和一个进程等待队列 queue,是更常用的实现方式,能有效避免忙等,遵循“让权等待”原则 。

记录型信号量的数据结构可抽象表示如下:

typedef struct {
    int value;         // 当前信号量的值,通常表示可用资源数
    struct process *queue; // 指向因等待该信号量而阻塞的进程队列
} semaphore;

P(wait)操作和 V(signal)操作是信号量机制的核心,它们必须是原子操作(执行过程不可被中断)。

// P操作(等待、申请资源)
void P(semaphore *S) {
    S->value--; // 表示申请一个资源
    if (S->value < 0) {
        // 资源不足,将当前进程插入S->queue阻塞队列
        block(S->queue); // 该进程自我阻塞,放弃CPU 
    }
}

// V操作(发信号、释放资源)
void V(semaphore *S) {
    S->value++; // 表示释放一个资源
    if (S->value <= 0) {
        // 有进程在等待,从S->queue中唤醒一个进程
        wakeup(S->queue); // 唤醒一个等待进程,使其进入就绪态 
    }
}

注:上述代码逻辑展示了信号量操作的核心思想。在实际操作系统中,这些操作由内核以系统调用的形式提供,确保其原子性 。

二、使用信号量实现进程互斥

互斥用于确保临界资源(一个时间段内只允许一个进程使用的资源,如打印机、共享变量等)被安全访问 。其关键是设置一个初始值为 1 的互斥信号量 mutex,将其视为进入临界区的“钥匙”。

实现模型如下:

semaphore mutex = 1; // 初始化互斥信号量为1,表示钥匙可用

Process Pi() {
    while(1) {
        P(mutex);      // 进入区:尝试获取钥匙,若被占用则阻塞
        // 临界区 (Critical Section) 
        // 访问共享资源/临界资源的代码段 
        V(mutex);      // 退出区:释放钥匙
        // 剩余区 (Remainder Section)
    }
}

原理说明:

  1. 任何进程在进入临界区前必须先执行 P(mutex)
  2. mutex=1,第一个进程执行 P(mutex) 后,mutex 变为 0,该进程进入临界区。
  3. 此时若第二个进程也执行 P(mutex)mutex 将变为 -1,该进程被阻塞并放入等待队列。
  4. 第一个进程退出临界区时执行 V(mutex)mutex 从 0 变回 0(因 value<=0),它会唤醒等待队列中的一个进程。
  5. 被唤醒的进程从 P(mutex) 中恢复,完成 value--(此时 value 可能仍为负,表示还有进程在等待),然后进入临界区。

这种方法严格保证了“忙则等待”(有进程在临界区时,其他进程阻塞)和“空闲让进”(临界区空闲时,允许一个进程立即进入)的互斥原则 。

三、使用信号量实现进程同步

同步用于协调合作进程之间的执行顺序,即某些操作必须在另一些操作之后执行(直接制约关系)。通常设置一个初始值为 0 的同步信号量 S

经典案例:保证操作A在操作B之后执行
假设有两个并发进程 P1 和 P2,P1 中有一段代码 A,P2 中有一段代码 B。要求 A 执行完成后,B 才能开始执行。

semaphore syn = 0; // 初始化同步信号量为0

Process P1() {
    // ...
    // 执行代码A
    printf("P1 executes operation A.
");
    V(syn); // 操作A完成,发信号给P2
    // ...
}

Process P2() {
    // ...
    P(syn); // 等待P1完成操作A的信号
    // 执行代码B
    printf("P2 executes operation B after A is done.
");
    // ...
}

原理说明:

  1. 初始时 syn=0,表示“事件A已完成”这个信号尚未发生。
  2. 若 P2 先执行到 P(syn),由于 syn-- 后变为 -1,P2 被阻塞。
  3. 当 P1 执行完 A 后,执行 V(syn),使 syn 从 -1 变为 0,并唤醒阻塞的 P2。
  4. P2 被唤醒后,继续执行 B。从而保证了 A→B 的执行顺序。

四、综合应用:生产者-消费者问题

生产者-消费者问题是同步与互斥结合的经典模型。它包含:

  • 一个大小为 n 的缓冲区(临界资源)。
  • 两类进程:生产者(向缓冲区放产品)和消费者(从缓冲区取产品)。
  • 三种关系
    1. 生产者与生产者互斥访问缓冲区(放置产品)。
    2. 消费者与消费者互斥访问缓冲区(取走产品)。
    3. 生产者与消费者同步:缓冲区满时生产者等待;缓冲区空时消费者等待。

解决方案需要三个信号量:

semaphore mutex = 1;   // 互斥信号量,用于互斥访问缓冲区(临界区)
semaphore empty = n;   // 同步信号量,表示空闲缓冲区数量,初始为n
semaphore full = 0;    // 同步信号量,表示已填充缓冲区数量,初始为0

生产者和消费者的伪代码如下:

// 生产者进程
Producer() {
    while(1) {
        produce an item;       // 生产一个产品
        P(empty);             // 申请一个空缓冲区(若满则阻塞)[同步]
        P(mutex);             // 申请进入临界区(互斥访问缓冲区)[互斥]
        // 临界区开始
        put item into buffer; // 将产品放入缓冲区
        // 临界区结束
        V(mutex);             // 离开临界区,释放锁
        V(full);              // 增加一个满缓冲区,通知消费者 [同步]
    }
}

// 消费者进程
Consumer() {
    while(1) {
        P(full);              // 申请一个满缓冲区(若空则阻塞)[同步]
        P(mutex);             // 申请进入临界区(互斥访问缓冲区)[互斥]
        // 临界区开始
        take item from buffer; // 从缓冲区取出产品
        // 临界区结束
        V(mutex);             // 离开临界区,释放锁
        V(empty);             // 增加一个空缓冲区,通知生产者 [同步]
        consume the item;     // 消费产品
    }
}

关键点与注意事项:

  1. 互斥信号量 mutex 的初始值必须为 1,以实现对缓冲区的互斥访问 。
  2. 同步信号量 emptyfull 的初始值分别对应缓冲区的初始状态(全空和全满)。
  3. P操作的顺序至关重要。在上例中,必须先对同步信号量(empty/full)执行 P 操作,再对互斥信号量(mutex)执行 P 操作。如果顺序颠倒(先 P(mutex)P(empty)),当缓冲区满时,生产者会先获得缓冲区的锁,然后发现没有空位而阻塞,同时它又持有锁,导致任何消费者都无法进入缓冲区取走产品,从而形成死锁
  4. V操作的顺序一般没有严格要求,因为它们不会导致进程阻塞。

五、信号量与其它互斥同步方法的对比

方法 核心思想 优点 缺点 是否满足“让权等待”
软件方法 (如Peterson算法) 通过共享变量和循环检查实现 纯软件实现,不依赖特殊硬件 实现复杂,可能违背“让权等待”(忙等)
硬件方法 (如中断屏蔽、TSL/Swap指令) 利用硬件指令的原子性实现上锁 简单高效,适用于多处理机 通常不满足“让权等待”(忙等)
互斥锁 (Mutex Lock) 提供acquire()和release()原子操作 概念简单,使用方便 通常基于硬件实现,也可能忙等 取决于具体实现
信号量机制 通过P/V原子操作和等待队列管理资源 功能强大,既可实现互斥,也可实现复杂的同步;记录型信号量满足“让权等待” 使用不当易造成死锁、优先级反转等问题 记录型信号量是

总结来说,信号量机制是操作系统层面提供的强大工具。通过互斥信号量(初始值1) 可以解决对临界资源的独占访问问题;通过同步信号量(初始值常为0或资源数) 可以解决进程间的执行顺序协调问题。在实际编程中(如Linux下的System V IPC或POSIX信号量),其接口(如 sem_wait, sem_post)封装了底层的P/V操作,但核心逻辑与上述一致 。正确理解并应用信号量,是编写正确、高效并发程序的基础。


参考来源

 

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐