问题的复杂性产生的根本原因在于,如 2.2 节所述,共享变量的访问始终是“单向信息流”。也就是说,一个进程可以分配新值或检查当前值,但这种检查不会为其他进程留下任何痕迹。结果是,当一个进程想要对共享变量的当前值作出反应时,在检查和随后执行反应之间,该值可能已被其他进程更改。换句话说,现有的通信机制对于手头的问题来说是不充分的,我们需要寻找更合适的替代方案。
这种替代方案通过以下方式引入:
a) 在共享变量中引入特殊用途的整数,我们称之为“信号量(semaphores)”。
b) 在构成各个进程的动作集合中添加两种新的基本操作,我们分别称之为“P 操作”和“V 操作”。这些操作始终作用于信号量,并代表并发进程访问信号量的唯一方式。信号量本质上是非负整数。如果仅用于解决互斥(Mutual Exclusion)问题,则其值的范围甚至可以限制为“0”和“1”。荷兰物理学家兼计算机设计师 C.S. Scholten 博士证明了信号量在取更大值时具有广泛的应用价值。需要区分时,我们会将它们分别称为“二进制信号量(binary semaphores)”和“通用信号量(general semaphores)”。我接下来给出的 P 操作和 V 操作的定义不受这种区别的影响。
——Dijkstra 的笔记 EWD 123
引言
事实上,信号量是一个难以理解的概念。它是同步问题的核心,与互斥锁一起是最先学习的概念之一,但初次接触时往往难以理解。
通常,信号量可以用以下方式总结:
“信号量是一种通过 P 和 V 这两个特殊的原子操作来操作表示资源可用性的计数器,从而解决因共享资源并发访问而导致同步无法保证的问题的技术。”
但是,共享资源到底是什么?原子操作又是什么?资源的可用性、P 和 V 又是什么?
为了回答这些问题,我写下了这篇文章。
本文的目标是帮助理解并发编程的基础以及信号量的概念。
什么是共享资源?
当程序并行执行时,多个线程或进程同时访问的数据被称为“共享资源(shared resource)”。
- 示例:内存中的变量、文件句柄、网络端口、队列等。
共享资源具有状态(state)。如果同时对这个状态进行访问和修改,可能会引发意外错误。
用打印机比喻来理解共享资源
假设有一台打印机。想象一下,两个人同时尝试使用这台打印机时会发生什么:
- 如果 A 正在打印,而 B 在中途开始打印会怎样?
- 可能会导致打印中断、输出重叠,或者打印出空白页。
这表明对共享资源的并发访问会引起冲突。
为什么需要原子操作?
那么,如何解决这个问题呢?
核心在于,即使多个线程同时访问,也要确保状态的一致性,即保证“原子性(Atomicity)”。
例如,将变量 x
加 1 的操作 x = x + 1
实际上可以分解为以下三个步骤:
- 读取
x
的当前值。 - 将其加 1。
- 将结果写回
x
。
即使是这种看似简单的操作,如果有其他进程在中间介入,结果可能会被破坏。例如,两个线程同时执行 x = x + 1
时,最终结果可能只增加 1 而不是预期的 2,甚至可能出现更少的增量。
这种互相竞争修改值的情况导致了意想不到的状态,这种情况被称为竞态条件(Race Condition) 。
即使是“看起来像一行代码的操作”,实际上也可能分为多个步骤执行,因此需要一种防止中间介入的手段。
这就是原子操作 的作用。原子操作是不可分割的单位操作,在执行过程中不允许任何外部干预。
再次用打印机比喻来理解
- 当 A 向打印机发送打印请求时,只有在打印完全结束、失败或中止后,B 的请求才能开始处理。
- 如果在 A 打印过程中 B 插入并开始打印,可能会导致输出混合或部分丢失的错误。
- 因此,只有在“A 的打印状态”明确结束后,才能处理下一个请求(B),从而保证输出状态的一致性。
这就是原子性的要求条件。
在打印过程中,打印机的状态(State)应处于“使用中”的锁定(lock)状态,
只有在完全结束后才能进行下一个任务。
为了同时管理程序中的基于状态的资源 ,需要一个无法被中途打断的原子控制机制 。
好了,那么原子性是如何保证的呢?这就是我们今天的主题。
Dijkstra 的提议:什么是信号量?
特殊用途的整数(special-purpose integers)
正如之前打印机的例子所示,我们需要能够从外部明确控制和监控“正在使用中”的状态。然而,仅靠普通变量无法安全地管理这种状态。
原因如下:
- 任何人都可以读取和写入变量。
- 某个进程读取的值可能在下一刻被其他进程更改。
- 换句话说,在读取值并对其做出反应的时间间隔内,状态可能会发生变化,而这种变化不会被反映出来。
因此,现有的方式是一种容易中断的“检查-决定-执行”流程。
为了克服这种结构性限制,Edsger W. Dijkstra 提出了一个新的解决方案。
引入一种特殊用途的整数作为共享变量,我们称之为“信号量(semaphores)”。
——Dijkstra, EWD 123
什么是信号量?
信号量(semaphore)不仅仅是一个简单的整数。
它是一个外部访问受到控制的、具有特殊用途的状态值。
其核心在于:“禁止直接访问,只能通过两种操作(P/V)间接控制。”
在构成各个进程的动作集合中,添加两种新的基本操作,我们分别称之为“P 操作”和“V 操作”。这些操作始终作用于信号量,并代表并发进程访问信号量的唯一方式。
——Dijkstra, EWD 123
这个特殊的整数只能通过以下两个操作进行操作:
- P 操作 (Proberen,意为“测试”)
- V 操作 (Verhogen,意为“增加”)
这两个操作遵循以下规则:
操作 | 含义 | 效果 |
---|---|---|
P(s) | wait / acquire | 如果信号量值为正,则减 1 并通过;如果为 0,则等待。 |
V(s) | signal / release | 将信号量值增加 1。 |
这些操作始终以原子性 方式执行,即它们不会被任何中断或干扰打断。
这正是我们一直在寻找的“防止中途介入的机制”。
这就是信号量。
P/V 操作的定义
一个简单的实现如下:
// 信号量用整数值 s 表示
P(s): // waitwhile (s <= 0) wait;s = s - 1;V(s): // signals = s + 1; // 在此之后,如果有等待中的进程,需要唤醒它们
现代操作系统中,这一过程通过 futex、spinlock、sleep queue 等方式实现。
需要注意的是,在 P/V 操作伪代码中,s = s - 1
、s = s + 1
和 while (s <= 0) wait;
条件检查部分必须作为不可分割的原子操作 执行。
如果 P(s)
的 while (s <= 0) wait;
部分是通过持续占用 CPU 并反复检查条件是否满足的方式(即忙等待(busy-waiting 或 spin-waiting) ),那么这将非常低效地使用 CPU 资源。这是在浪费本可以用于其他有用任务的 CPU 时间。
因此,现代操作系统采用以下机制来更高效地处理这种“等待”过程,这些方法可以看作是现代异步处理的核心技术:
睡眠队列(Sleep Queue / Wait Queue)、上下文切换(Context Switching)、自旋锁(Spinlock)、以及 Futex(快速用户空间互斥锁) 。
这些方法的整体理论基础正是上述简单的操作。
总结一下,在现代操作系统中,信号量操作可以描述如下:
- 1. 基本的等待/唤醒: 使用睡眠队列 高效地挂起和唤醒进程(避免忙等待)。
- 2. 内部原子性保障: 在 P/V 操作短暂的执行期间,为了安全地修改信号量变量(如
s
)或睡眠队列,内核内部可能会使用自旋锁 等机制。 - 3. 性能优化(尤其是 Linux): 利用Futex ,当没有资源竞争时在用户空间快速处理,只有在发生竞争时才使用内核的睡眠队列功能,从而减少系统调用开销。
二进制信号量 vs 通用信号量
区分 | 含义 | 使用场景 |
---|---|---|
二进制信号量 | 值:0 或 1 | 等同于互斥锁(Mutex) |
通用信号量 | 值:0 或更大 | 有限资源(例如:数据库连接池) |
-
C.S. Scholten 的通用信号量概念扩展。
-
与互斥锁的区别: 拥有权限的概念 vs 状态驱动模型
让我们回到打印机的例子,使用信号量控制打印机。
在打印机示例中应用二进制信号量的过程如下:
- 用一个初始值为
s = 1
的信号量表示打印机的状态。 - 用户 A 调用
P(s)
,将s
减为0
并开始打印。 - 在用户 A 打印过程中,用户 B 调用
P(s)
,但由于s <= 0
,用户 B 进入等待状态。 - 用户 A 完成打印后调用
V(s)
,使s = 1
,用户 B 随即可以开始打印。
通过这种方式,信号量将资源的“状态”抽象为数字,并通过原子操作改变该数值,从而实现对并发的控制。
前面提到的二进制信号量 适用于办公室只有一台打印机的情况(互斥访问)。
s=1
表示“打印机可用”,s=0
表示“打印机正在使用”。
但是,如果办公室有多个相同性能的打印机(例如:3 台)该怎么办呢?
在这种情况下,虽然允许多个人同时使用打印机,但必须确保使用的打印机数量不会超过可用的数量。
这正是**通用信号量(General Semaphore)或 计数信号量(Counting Semaphore)**发挥作用的时候。
通用信号量 s
是一个非负整数值,用于表示可用资源的数量。
使用通用信号量控制 3 台打印机的示例
-
初始状态:
- 办公室有 3 台打印机,因此信号量
s
的初始值设置为3
(s = 3
)。 - 这表示“当前有 3 台可用的打印机”。
- 办公室有 3 台打印机,因此信号量
-
用户 A 请求使用打印机(执行
P(s)
操作):- 调用
P(s)
。 - 当前
s
的值(3)大于 0,因此将s
减 1(s = 2
)。 - 用户 A 分配到一台打印机并开始打印。(现在可用的打印机数量为 2)
- 调用
-
用户 B 请求使用打印机(执行
P(s)
操作):- 调用
P(s)
。 - 当前
s
的值(2)大于 0,因此将s
减 1(s = 1
)。 - 用户 B 也分配到一台打印机并开始打印。(现在可用的打印机数量为 1)
- 调用
-
用户 C 请求使用打印机(执行
P(s)
操作):- 调用
P(s)
。 - 当前
s
的值(1)大于 0,因此将s
减 1(s = 0
)。 - 用户 C 分配到一台打印机并开始打印。(现在可用的打印机数量为 0)
- 调用
-
用户 D 请求使用打印机(执行
P(s)
操作):- 调用
P(s)
。 - 当前
s
的值(0)小于或等于 0,因此用户 D 会在P(s)
操作中的while (s <= 0) wait;
条件下进入等待状态 ,直到有打印机可用。
- 调用
-
用户 A 打印完成并归还打印机(执行
V(s)
操作):- 用户 A 完成打印后调用
V(s)
。 - 将
s
增加 1(s = 1
)。(现在有 1 台打印机可用) V(s)
操作完成后,之前在P(s)
操作中等待的用户 D 被唤醒,并重新检查s
的值。由于s
现在为 1,用户 D 将s
设置为 0 并开始使用打印机。
- 用户 A 完成打印后调用
通用信号量的核心作用:
如上所述,通用信号量不仅仅是一个简单的二进制“锁定/解锁”机制,它还可以精确管理有限资源池的并发访问 ,并准确跟踪可用资源的数量 。
结语
信号量仍然是内核级同步的核心工具,同时也是基于状态的访问模型的典型代表。
信号量看似只是一个简单的整数,
但它却是系统中用于准确表示资源状态 、
安全控制资源 、以及以可预测的方式共享资源的唯一抽象手段 。
我们必须牢记,在这个小小的整数背后,
承载着无数进程的秩序、冲突避免和系统稳定性 。