问题的复杂性产生的根本原因在于,如 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)。如果同时对这个状态进行访问和修改,可能会引发意外错误。
假设有一台打印机。想象一下,两个人同时尝试使用这台打印机时会发生什么:
这表明对共享资源的并发访问会引起冲突。
那么,如何解决这个问题呢?
核心在于,即使多个线程同时访问,也要确保状态的一致性,即保证“原子性(Atomicity)”。
例如,将变量 x
加 1 的操作 x = x + 1
实际上可以分解为以下三个步骤:
x
的当前值。x
。即使是这种看似简单的操作,如果有其他进程在中间介入,结果可能会被破坏。例如,两个线程同时执行 x = x + 1
时,最终结果可能只增加 1 而不是预期的 2,甚至可能出现更少的增量。
这种互相竞争修改值的情况导致了意想不到的状态,这种情况被称为竞态条件(Race Condition) 。
即使是“看起来像一行代码的操作”,实际上也可能分为多个步骤执行,因此需要一种防止中间介入的手段。
这就是原子操作 的作用。原子操作是不可分割的单位操作,在执行过程中不允许任何外部干预。
这就是原子性的要求条件。
在打印过程中,打印机的状态(State)应处于“使用中”的锁定(lock)状态,
只有在完全结束后才能进行下一个任务。
为了同时管理程序中的基于状态的资源 ,需要一个无法被中途打断的原子控制机制 。
好了,那么原子性是如何保证的呢?这就是我们今天的主题。
特殊用途的整数(special-purpose integers)
正如之前打印机的例子所示,我们需要能够从外部明确控制和监控“正在使用中”的状态。然而,仅靠普通变量无法安全地管理这种状态。
原因如下:
因此,现有的方式是一种容易中断的“检查-决定-执行”流程。
为了克服这种结构性限制,Edsger W. Dijkstra 提出了一个新的解决方案。
引入一种特殊用途的整数作为共享变量,我们称之为“信号量(semaphores)”。
——Dijkstra, EWD 123
信号量(semaphore)不仅仅是一个简单的整数。
它是一个外部访问受到控制的、具有特殊用途的状态值。
其核心在于:“禁止直接访问,只能通过两种操作(P/V)间接控制。”
在构成各个进程的动作集合中,添加两种新的基本操作,我们分别称之为“P 操作”和“V 操作”。这些操作始终作用于信号量,并代表并发进程访问信号量的唯一方式。
——Dijkstra, EWD 123
这个特殊的整数只能通过以下两个操作进行操作:
这两个操作遵循以下规则:
操作 |
含义 |
效果 |
---|---|---|
P(s) |
wait / acquire |
如果信号量值为正,则减 1 并通过;如果为 0,则等待。 |
V(s) |
signal / release |
将信号量值增加 1。 |
这些操作始终以原子性 方式执行,即它们不会被任何中断或干扰打断。
这正是我们一直在寻找的“防止中途介入的机制”。
这就是信号量。
一个简单的实现如下:
// 信号量用整数值 s 表示
P(s): // wait
while (s <= 0) wait;
s = s - 1;
V(s): // signal
s = 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(快速用户空间互斥锁) 。
这些方法的整体理论基础正是上述简单的操作。
总结一下,在现代操作系统中,信号量操作可以描述如下:
s
)或睡眠队列,内核内部可能会使用自旋锁 等机制。 区分 |
含义 |
使用场景 |
---|---|---|
二进制信号量 |
值:0 或 1 |
等同于互斥锁(Mutex) |
通用信号量 |
值:0 或更大 |
有限资源(例如:数据库连接池) |
C.S. Scholten 的通用信号量概念扩展。
与互斥锁的区别: 拥有权限的概念 vs 状态驱动模型
在打印机示例中应用二进制信号量的过程如下:
s = 1
的信号量表示打印机的状态。P(s)
,将 s
减为 0
并开始打印。P(s)
,但由于 s <= 0
,用户 B 进入等待状态。V(s)
,使 s = 1
,用户 B 随即可以开始打印。通过这种方式,信号量将资源的“状态”抽象为数字,并通过原子操作改变该数值,从而实现对并发的控制。
前面提到的二进制信号量 适用于办公室只有一台打印机的情况(互斥访问)。
s=1
表示“打印机可用”,s=0
表示“打印机正在使用”。
但是,如果办公室有多个相同性能的打印机(例如:3 台)该怎么办呢?
在这种情况下,虽然允许多个人同时使用打印机,但必须确保使用的打印机数量不会超过可用的数量。
这正是**通用信号量(General Semaphore)或 计数信号量(Counting Semaphore)**发挥作用的时候。
通用信号量 s
是一个非负整数值,用于表示可用资源的数量。
初始状态:
s
的初始值设置为 3
(s = 3
)。用户 A 请求使用打印机(执行 P(s)
操作):
P(s)
。s
的值(3)大于 0,因此将 s
减 1(s = 2
)。用户 B 请求使用打印机(执行 P(s)
操作):
P(s)
。s
的值(2)大于 0,因此将 s
减 1(s = 1
)。用户 C 请求使用打印机(执行 P(s)
操作):
P(s)
。s
的值(1)大于 0,因此将 s
减 1(s = 0
)。用户 D 请求使用打印机(执行 P(s)
操作):
P(s)
。s
的值(0)小于或等于 0,因此用户 D 会在 P(s)
操作中的 while (s <= 0) wait;
条件下进入等待状态 ,直到有打印机可用。用户 A 打印完成并归还打印机(执行 V(s)
操作):
V(s)
。s
增加 1(s = 1
)。(现在有 1 台打印机可用)V(s)
操作完成后,之前在 P(s)
操作中等待的用户 D 被唤醒,并重新检查 s
的值。由于 s
现在为 1,用户 D 将 s
设置为 0 并开始使用打印机。如上所述,通用信号量不仅仅是一个简单的二进制“锁定/解锁”机制,它还可以精确管理有限资源池的并发访问 ,并准确跟踪可用资源的数量 。
信号量仍然是内核级同步的核心工具,同时也是基于状态的访问模型的典型代表。
信号量看似只是一个简单的整数,
但它却是系统中用于准确表示资源状态 、
安全控制资源 、以及以可预测的方式共享资源的唯一抽象手段 。
我们必须牢记,在这个小小的整数背后,
承载着无数进程的秩序、冲突避免和系统稳定性 。