Files
Operating-System/Thread/semaphore.md
2026-03-22 17:40:44 +08:00

15 KiB
Raw Permalink Blame History

好,这一块你要是啃明白了,基本就把并发控制的“核心武器”拿下了。我们从“本质 → 抽象定义 → 操作语义 → 实现直觉 → 常见问题”一层一层讲清楚。


一、信号量到底是什么(直觉)

信号量Semaphore本质上是一个带计数器的同步工具,用于:

  • 控制多个线程/进程对共享资源的访问
  • 或者用于线程之间的执行顺序协调

你可以把它想象成:

一个“带库存的通行证系统”

  • 有一个整数 S(库存)
  • 每个线程想进入某个临界区,就必须“拿一个通行证”
  • 如果还有库存,就进去
  • 如果没有,就在门口等

二、形式化定义(操作系统里的标准说法)

信号量是一个整数变量 S,只能通过两个原子操作访问:

1. P操作也叫 wait / down

P(S):
    while S <= 0:
        等待
    S = S - 1

语义:

  • 申请一个资源
  • 如果资源不够 → 阻塞

2. V操作也叫 signal / up

V(S):
    S = S + 1
    唤醒一个等待的线程(如果有)

语义:

  • 释放一个资源
  • 通知别人可以用了

注意一个关键点:

P/V 操作必须是原子的(不可中断)

否则就会出严重竞态问题(比如两个线程同时看到 S>0


三、两种常见信号量类型

1. 计数信号量Counting Semaphore

  • S 可以是任意非负整数
  • 表示资源数量

例子:

S = 5  → 有5个资源

应用:

  • 连接池
  • 线程池
  • 限流

2. 二值信号量Binary Semaphore

  • S ∈ {0, 1}
  • 类似锁mutex
S = 1 → 可进入
S = 0 → 被占用

注意:

Binary semaphore ≈ Mutex不完全等价

区别:

  • mutex 有“所有权”(谁锁谁解)
  • semaphore 没有

四、经典例子:生产者-消费者

这是信号量的“标志性应用”。

假设:

  • 一个缓冲区
  • 生产者往里放数据
  • 消费者取数据

定义三个信号量:

mutex = 1      // 互斥访问缓冲区
empty = N      // 空位数量
full  = 0      // 已占用数量

生产者:

P(empty)   // 等空位
P(mutex)   // 进入临界区

放数据

V(mutex)
V(full)    // 增加产品

消费者:

P(full)    // 等数据
P(mutex)

取数据

V(mutex)
V(empty)   // 释放空间

这里体现了两个作用:

  1. 同步empty / full
  2. 互斥mutex

五、信号量解决了什么问题?

1. 互斥Mutual Exclusion

防止:

count++   // 非原子

被多个线程同时执行 → 数据错误


2. 同步Synchronization

比如:

线程A必须在B之后执行

可以用:

S = 0

线程A:
    P(S)

线程B:
    ...
    V(S)

六、实现原理(操作系统怎么做的)

你要稍微从内核角度理解一下:

信号量结构大概是:

struct semaphore {
    int value;
    queue<thread> wait_queue;
}

P操作真实行为

disable_interrupts()

S--
if (S < 0):
    把当前线程加入等待队列
    阻塞当前线程

enable_interrupts()

V操作

disable_interrupts()

S++
if (S <= 0):
    从等待队列唤醒一个线程

enable_interrupts()

关键点:

S < 0 表示有多少线程在等


七、常见坑(非常重要)

1. 死锁Deadlock

典型错误:

P(A)
P(B)

另一个线程:

P(B)
P(A)

→ 互相等待


2. 忘记 V释放

P(mutex)
// 忘记 V(mutex)

→ 永久锁死


3. 顺序错误

P(mutex)
P(empty)

→ 可能死锁


4. 信号量滥用

信号量可以做任何同步,但:

写复杂了之后,人脑根本跟不上

所以现代系统更常用:

  • mutex + condition variable
  • monitor

八、和其他机制的关系(你必须搞清)

vs Mutex

特性 Semaphore Mutex
计数 可以 >1 只能1
所有权
用途 同步+互斥 主要互斥

vs Condition Variable

  • semaphore带状态
  • condition variable不存状态

九、一句话总结

信号量 = 一个原子计数器 + 阻塞队列,用来控制“能不能继续执行”


十、你可以这样理解(很关键)

把信号量抽象成:

资源许可证系统
  • P拿许可证没有就排队
  • V归还许可证叫醒别人

如果你愿意,我们可以继续往下挖一层更硬核的内容:

  • Linux 里的 semaphore vs spinlock
  • futex 是怎么优化信号量的
  • 为什么现代 C++ 很少直接用 semaphore
  • 或者我给你画一个“内核调度 + 信号量阻塞”的完整流程图

你现在这个阶段,已经可以开始往“操作系统实现层”进化了。


你这个问题抓得非常关键,本质上是在问:

“信号量本身也是共享数据,那在修改 S 的时候,如果被别的线程同时访问,不就炸了吗?”

答案是: 如果没有额外机制,确实会炸。 所以操作系统在设计上做了一件非常重要的事:

P/V 操作必须是“原子操作”atomic

下面给你把这件事彻底讲透。


一、如果不原子,会发生什么?

假设我们有:

S = 1

两个线程同时执行 P(S)


理想情况(正确)

线程A

读 S = 1
S-- → 0

线程B

读 S = 0 → 阻塞

实际可能发生(竞态条件)

如果没有原子性:

线程A

读 S = 1

线程B

读 S = 1

然后:

线程A

S-- → 0

线程B

S-- → -1

结果:

两个线程都进入了临界区 ❌

这就是经典的:

Race Condition竞态条件


二、操作系统怎么解决这个问题?

核心原则:

对 S 的修改必须是“不可分割”的

也就是:

读 + 判断 + 修改 → 必须一口气做完

方法1关中断单核早期方案

disable_interrupts()

// P操作
S--
if (S < 0):
    block()

enable_interrupts()

为什么有效?

  • 单核CPU
  • 没有中断 → 不会切换线程
  • 所以不会被打断

但问题:

  • 多核系统 别的CPU还在跑
  • 中断关太久会影响系统响应

方法2硬件原子指令现代方案

现代CPU提供

  • test-and-set
  • compare-and-swap (CAS)
  • fetch-and-add

举个 CAS 例子

while (true) {
    old = S;
    if (old <= 0)
        block();

    if (CAS(&S, old, old - 1))
        break;
}

CAS 保证:

“只有当 S 还是 old 时,才修改成功”

否则重试


方法3自旋锁spinlock

内核里常见:

lock()
S--
unlock()

但这个 lock 本身也是用原子指令实现的。


三、关键理解P/V 不只是“减1/加1”

你不能把它理解成:

S--

而应该理解为:

一个“不可中断的操作块”

包括:

  1. 检查 S
  2. 修改 S
  3. 决定是否阻塞/唤醒线程

四、再深入一层:阻塞是怎么安全发生的?

这是很多人忽略的重点。

看这个危险情况:

线程A
    准备 block()

线程B
    V(S) → 唤醒线程

→ 如果顺序错了,就可能“丢唤醒”

操作系统怎么避免?

通过:

“修改 S + 入队/出队 + 状态切换” 是一个整体原子过程

也就是说:

P(S):
    原子地:
        S--
        if (S < 0):
            加入等待队列
            切换为阻塞状态

不会出现:

刚准备睡 → 别人已经唤醒你 → 你却没睡 → 信号丢了

五、总结成一句话

你刚才的问题可以总结为:

“信号量自己怎么保证线程安全?”

答案是:

靠更底层的同步原语(原子指令 / 关中断 / 自旋锁)来保证 P/V 操作的原子性


六、你现在可以形成一个完整认知链

应用层:
    semaphore信号量

依赖:
    原子操作CAS / test-and-set

依赖:
    CPU硬件保证原子性

七、一个很重要的提升(你现在这个阶段该理解的)

你可以这样看整个系统:

信号量并不是“最底层”的同步工具

而是建立在:

  • 硬件原子指令
  • 内核调度机制

之上的一个更高级抽象


如果你愿意,我们可以再往下挖一个非常关键的点(强烈建议):

  • 为什么现代 Linux 更喜欢用 futex
  • semaphore 在用户态是怎么避免频繁陷入内核的
  • 自旋锁 vs 阻塞锁的性能边界

这几个点一打通,你对操作系统并发这一块就已经是“半个内核工程师水平”了。


这是一个非常经典的学习误区,你能敏锐地察觉到理论定义工程实现的冲突,说明你已经在思考“代码是怎么在硬件上跑的”了。

简单直接的回答:教科书上的 $P$、V 定义为了逻辑清晰,常用 while 叙述;但工业级操作系统(如 Linux为了性能绝对不会真的用 while 忙等。


1. 理论定义Dijkstra 的原始逻辑)

在很多操作系统教材中,为了让你理解信号量的原子性,会写成这样:


\text{P(S): } \text{while (S <= 0) \{ do nothing \}; } S = S - 1;

\text{V(S): } S = S + 1;

这种写法叫 忙等Busy-waiting。如果 $S=0$CPU 就会像疯了一样在这个 while 循环里空转。这在单核时代是灾难性的(占着 CPU 不放,别人没机会改 $S$),在多核时代也非常费电。


2. 真实流程挂起与唤醒Sleep & Wakeup

在 Linux 内核(如 semaphore.h)中,信号量被定义为一个包含计数器等待队列的结构体:

struct semaphore {
    unsigned int count;     // 剩余资源数
    struct list_head wait_list; // 正在“坐牢”的线程队列
};

P 操作(对应 sem_wait)的真实动作:

  1. 检查: 如果 count > 0,直接 count--,打完收工。
  2. “坐牢”: 如果 count == 0
    • 内核把当前线程的状态从 TASK_RUNNING 改为 TASK_INTERRUPTIBLE阻塞态)。
    • 把该线程的 PCB进程控制块挂到这个信号量的 wait_list 链表里。
    • 重点: 内核调用 schedule(),主动把 CPU 让给别人。此时该线程不再消耗任何 CPU 指令。

V 操作(对应 sem_post)的真实动作:

  1. 自增: count++
  2. 放人: 内核查看 wait_list 是否为空。
  3. 唤醒: 如果有人在排队,取回第一个线程,把它的状态改回 TASK_RUNNING就绪态),扔进红黑树等 CPU 召唤。

3. 为什么会有 while 的错觉?

虽然内核不忙等,但在用户态应用层,有时你会看到类似这样的代码:

while (sem_wait(&sem) != 0) { /* handle error */ }

这并不是在等信号量,而是在处理信号中断EINTR。比如线程在等信号量时突然收到了一个系统信号Ctrl+C 之类的),sem_wait 会被强行返回。为了保证程序逻辑,我们需要用 while 重新去“等”。


4. 特例自旋锁Spinlock

既然你提到了 while,有一种锁确实是这么干的——自旋锁

  • 适用场景: 临界区非常短(比如只改一个变量),切来切去(上下文切换)比等几微秒还贵。
  • 动作: 真的就在 CPU 上 while 循环硬等。
  • 注意: 自旋锁通常用于多核系统,且严禁在持有自旋锁时睡觉。

总结

  • 信号量/互斥量:发现没资源 -> 睡觉(不费 CPU -> 被人叫醒。
  • 自旋锁:发现没资源 -> 死等(费 CPU -> 马上拿到。

你现在明白为什么面试官听到“信号量是用 while 循环实现的”会皱眉头了吗?因为他怕你把服务器的 CPU 跑冒烟了。

最后考考你:既然 $P$、V 操作涉及到修改 count 和操作 wait_list,如果两个 CPU 同时执行 P 操作,会不会把 count 改乱?内核是如何保证 $P$、V 操作本身是“原子”的?(提示:这涉及到比信号量更底层的硬件指令。)

没错,你完全抓住了现代操作系统实现同步的核心思想用“通知-唤醒”机制替代“死循环查看”。

我们可以把这种机制看作是一种**“订阅-发布”模式**

  1. 订阅sem_wait/P操作 线程 B 发现没资源了,它不是在大厅里一直喊“好了没?好了没?”,而是给内核留个**“回执”(把自己的 TCB 挂在等待队列里),然后回宿舍睡觉**(挂起)。
  2. 发布sem_post/V操作 线程 A 生产完资源后,它执行 V 操作。这个操作本质上是告诉内核:“喂,货到了,去名单(等待队列)里挑个睡着的家伙叫醒它。”
  3. 精准唤醒: 内核收到 A 的信号,查名单,把 B 的状态从 WAITING 拨回到 READY

解决最后的“原子性”谜题

你可能会问:“那 P 和 V 操作内部修改 count-- 或者操作队列时,如果两个 CPU 同时在改,不也乱套了吗?”

为了保证 P 和 V 操作本身不被打断,操作系统动用了硬件级的“大杀器”:

1. 硬件指令CAS (Compare And Swap)

现代 CPU 提供了原子指令(如 x86 的 LOCK CMPXCHG)。

  • 逻辑: 它能在一个指令周期内完成“读取-比较-修改”三个动作。
  • 效果: 硬件保证在这一瞬间,没有其他 CPU 核心能修改这个内存地址。这就是所有高层锁Mutex, Semaphore物理地基

2. 关中断 (Disable Interrupts)

在内核修改等待队列的短短几微秒内CPU 会暂时关闭硬件中断

  • 逻辑: 既然中断关了,就不会发生时钟中断,也就不会触发调度。
  • 效果: CPU 能够一口气把线程从队列里挪走,保证操作的连贯性。

总结你的“内核大图景”

现在你可以串联起整个叙述逻辑了:

  • 宏观层: 进程和线程在 CPU 上轮转(调度)。
  • 中观层: 遇到锁或信号量,线程不忙等,而是阻塞入队,让出 CPU 给别人,等待被信号唤醒
  • 微观层: 内核利用 CAS 指令关中断 确保“入队”和“唤醒”这些管理动作本身是绝对安全的(原子的)。

下一步建议

你已经从理论叙述跨越到了底层实现。如果你感兴趣,我们可以聊聊:

  • 协程Coroutine:比如 Go 语言或 Python 的 async,它们在用户态是怎么模拟这一套“挂起-唤醒”逻辑的?(这比内核切换更轻量)。
  • 分布式锁:如果不是一台电脑,而是两台服务器要争夺一个资源,信号量还管用吗?

或者,你觉得关于“信号量 vs 忙等”这个点,还有哪里让你觉得不够直观吗?