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

765 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
好,这一块你要是啃明白了,基本就把并发控制的“核心武器”拿下了。我们从“本质 → 抽象定义 → 操作语义 → 实现直觉 → 常见问题”一层一层讲清楚。
---
# 一、信号量到底是什么(直觉)
信号量Semaphore本质上是一个**带计数器的同步工具**,用于:
* 控制多个线程/进程对**共享资源**的访问
* 或者用于线程之间的**执行顺序协调**
你可以把它想象成:
> 一个“带库存的通行证系统”
* 有一个整数 `S`(库存)
* 每个线程想进入某个临界区,就必须“拿一个通行证”
* 如果还有库存,就进去
* 如果没有,就在门口等
---
# 二、形式化定义(操作系统里的标准说法)
信号量是一个整数变量 `S`,只能通过两个**原子操作**访问:
### 1. P操作也叫 wait / down
```c
P(S):
while S <= 0:
S = S - 1
```
语义:
* 申请一个资源
* 如果资源不够 → 阻塞
---
### 2. V操作也叫 signal / up
```c
V(S):
S = S + 1
线
```
语义:
* 释放一个资源
* 通知别人可以用了
---
注意一个关键点:
> **P/V 操作必须是原子的(不可中断)**
否则就会出严重竞态问题(比如两个线程同时看到 S>0
---
# 三、两种常见信号量类型
## 1. 计数信号量Counting Semaphore
* S 可以是任意非负整数
* 表示**资源数量**
例子:
```text
S = 5 → 有5个资源
```
应用:
* 连接池
* 线程池
* 限流
---
## 2. 二值信号量Binary Semaphore
* S ∈ {0, 1}
* 类似锁mutex
```text
S = 1 → 可进入
S = 0 → 被占用
```
注意:
> Binary semaphore ≈ Mutex但**不完全等价**
区别:
* mutex 有“所有权”(谁锁谁解)
* semaphore 没有
---
# 四、经典例子:生产者-消费者
这是信号量的“标志性应用”。
假设:
* 一个缓冲区
* 生产者往里放数据
* 消费者取数据
---
## 定义三个信号量:
```c
mutex = 1 // 互斥访问缓冲区
empty = N // 空位数量
full = 0 // 已占用数量
```
---
## 生产者:
```c
P(empty) // 等空位
P(mutex) // 进入临界区
V(mutex)
V(full) // 增加产品
```
---
## 消费者:
```c
P(full) // 等数据
P(mutex)
V(mutex)
V(empty) // 释放空间
```
---
### 这里体现了两个作用:
1. **同步**empty / full
2. **互斥**mutex
---
# 五、信号量解决了什么问题?
## 1. 互斥Mutual Exclusion
防止:
```c
count++ // 非原子
```
被多个线程同时执行 → 数据错误
---
## 2. 同步Synchronization
比如:
```text
线程A必须在B之后执行
```
可以用:
```c
S = 0
线A:
P(S)
线B:
...
V(S)
```
---
# 六、实现原理(操作系统怎么做的)
你要稍微从内核角度理解一下:
信号量结构大概是:
```c
struct semaphore {
int value;
queue<thread> wait_queue;
}
```
---
### P操作真实行为
```c
disable_interrupts()
S--
if (S < 0):
线
线
enable_interrupts()
```
---
### V操作
```c
disable_interrupts()
S++
if (S <= 0):
线
enable_interrupts()
```
---
关键点:
> S < 0 表示有多少线程在等
---
# 七、常见坑(非常重要)
## 1. 死锁Deadlock
典型错误:
```c
P(A)
P(B)
```
另一个线程:
```c
P(B)
P(A)
```
→ 互相等待
---
## 2. 忘记 V释放
```c
P(mutex)
// 忘记 V(mutex)
```
→ 永久锁死
---
## 3. 顺序错误
```c
P(mutex)
P(empty)
```
→ 可能死锁
---
## 4. 信号量滥用
信号量可以做任何同步,但:
> 写复杂了之后,人脑根本跟不上
所以现代系统更常用:
* mutex + condition variable
* monitor
---
# 八、和其他机制的关系(你必须搞清)
## vs Mutex
| 特性 | Semaphore | Mutex |
| --- | --------- | ----- |
| 计数 | 可以 >1 | 只能1 |
| 所有权 | 无 | 有 |
| 用途 | 同步+互斥 | 主要互斥 |
---
## vs Condition Variable
* semaphore**带状态**
* condition variable**不存状态**
---
# 九、一句话总结
> 信号量 = 一个原子计数器 + 阻塞队列,用来控制“能不能继续执行”
---
# 十、你可以这样理解(很关键)
把信号量抽象成:
```text
资源许可证系统
```
* P拿许可证没有就排队
* V归还许可证叫醒别人
---
如果你愿意,我们可以继续往下挖一层更硬核的内容:
* Linux 里的 `semaphore` vs `spinlock`
* futex 是怎么优化信号量的
* 为什么现代 C++ 很少直接用 semaphore
* 或者我给你画一个“内核调度 + 信号量阻塞”的完整流程图
你现在这个阶段,已经可以开始往“操作系统实现层”进化了。
---
你这个问题抓得非常关键,本质上是在问:
> **“信号量本身也是共享数据,那在修改 S 的时候,如果被别的线程同时访问,不就炸了吗?”**
答案是:
**如果没有额外机制,确实会炸。**
所以操作系统在设计上做了一件非常重要的事:
> **P/V 操作必须是“原子操作”atomic**
下面给你把这件事彻底讲透。
---
# 一、如果不原子,会发生什么?
假设我们有:
```c
S = 1
```
两个线程同时执行 `P(S)`
---
### 理想情况(正确)
线程A
```
读 S = 1
S-- → 0
```
线程B
```
读 S = 0 → 阻塞
```
---
### 实际可能发生(竞态条件)
如果没有原子性:
线程A
```
读 S = 1
```
线程B
```
读 S = 1
```
然后:
线程A
```
S-- → 0
```
线程B
```
S-- → -1
```
结果:
```text
两个线程都进入了临界区 ❌
```
这就是经典的:
> **Race Condition竞态条件**
---
# 二、操作系统怎么解决这个问题?
核心原则:
> **对 S 的修改必须是“不可分割”的**
也就是:
```text
读 + 判断 + 修改 → 必须一口气做完
```
---
## 方法1关中断单核早期方案
```c
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 例子
```c
while (true) {
old = S;
if (old <= 0)
block();
if (CAS(&S, old, old - 1))
break;
}
```
CAS 保证:
> “只有当 S 还是 old 时,才修改成功”
否则重试
---
## 方法3自旋锁spinlock
内核里常见:
```c
lock()
S--
unlock()
```
但这个 lock 本身也是用原子指令实现的。
---
# 三、关键理解P/V 不只是“减1/加1”
你不能把它理解成:
```c
S--
```
而应该理解为:
```text
一个“不可中断的操作块”
```
包括:
1. 检查 S
2. 修改 S
3. 决定是否阻塞/唤醒线程
---
# 四、再深入一层:阻塞是怎么安全发生的?
这是很多人忽略的重点。
看这个危险情况:
```text
线程A
准备 block()
线程B
V(S) → 唤醒线程
→ 如果顺序错了,就可能“丢唤醒”
```
---
## 操作系统怎么避免?
通过:
> **“修改 S + 入队/出队 + 状态切换” 是一个整体原子过程**
也就是说:
```c
P(S):
S--
if (S < 0):
```
不会出现:
```text
刚准备睡 → 别人已经唤醒你 → 你却没睡 → 信号丢了
```
---
# 五、总结成一句话
你刚才的问题可以总结为:
> “信号量自己怎么保证线程安全?”
答案是:
> **靠更底层的同步原语(原子指令 / 关中断 / 自旋锁)来保证 P/V 操作的原子性**
---
# 六、你现在可以形成一个完整认知链
```text
应用层:
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`)中,信号量被定义为一个包含**计数器**和**等待队列**的结构体:
```c
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` 的错觉?
虽然内核不忙等,但在**用户态应用层**,有时你会看到类似这样的代码:
```c
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 忙等”这个点,还有哪里让你觉得不够直观吗?**