搜索面包店算法解决互斥问题。并深入探讨
5 2025-12-06 09:26
面包店算法(Bakery Algorithm)是莱斯利·兰波特(Leslie Lamport)于1974 年提出的一种纯软件实现的互斥算法,用于解决多进程/线程对临界区的互斥访问问题。其核心思想源于日常生活中面包店的取号排队机制:顾客进店时先领一个号码,号码小的先被服务;若号码相同,则按顾客名字的字典序排队。对应到计算机系统,进程就是顾客,号码和名字分别对应线程的编号和线程 ID。
一、算法数据结构
表格
复制
| 变量 | 类型 | 作用 |
|---|---|---|
choosing[i] |
bool | 线程 i 是否正在“取号” |
number[i] |
int | 线程 i 拿到的号码,0 表示未参与排队 |
初始化时所有
choosing[i]=false,number[i]=0。二、算法流程(线程 i 视角)
-
取号阶段
-
choosing[i] = true; -
number[i] = max(number[0..N-1]) + 1; -
choosing[i] = false;
-
-
门口等待(忙等)
对任意j ≠ i:-
等待
choosing[j] == false(对方取号完毕) -
等待
number[j]==0或(number[j],j) > (number[i],i)(对方号码更大或已退出)
-
-
进入临界区
执行临界区代码。 -
退出临界区
number[i] = 0;(释放资源,相当于“叫号完成”)
三、正确性论证
-
互斥性(Mutual Exclusion)
若两个线程同时进入临界区,则它们必须满足各自的等待条件,即都认为自己的“号码+ID”最小,这与全序关系矛盾,故互斥成立。 -
无死锁(Deadlock-Free)
线程只在choosing[j]为 false 且对方号码更大时自旋,若所有线程都推进,则总能选出一个最小号码,故不会无限等待。 -
无饥饿(Starvation-Free)
号码严格递增,先取号的线程一定先通过等待循环,后续线程无法“插队”,保证了 FIFO 公平性。
四、深入探讨
1. 为什么必须保留 choosing 标志?
省略
线程 A 和 B 同时计算
choosing 会出现以下竞态:线程 A 和 B 同时计算
max+1 得到相同号码;A 在设置 number[A] 前被抢占,B 已设置完成并进入临界区;随后 A 继续设置,也拿到相同号码,两者均认为自己序号最小,同时进入临界区,破坏互斥。choosing 相当于让“取号”过程原子化,其他线程必须等待取号完成再进行比较。2. 性能与可扩展性
-
时间复杂度:取号阶段需扫描全部
number[],进入等待阶段又要扫描全部choosing[]和number[],复杂度为 O(N),当线程数 N 很大时,缓存一致性流量和忙等开销显著增加,不适合大规模多核。 -
空间复杂度:需要两个长度为 N 的全局数组,O(N)。
-
公平性:严格 FIFO,先取号先服务,无饥饿,但高并发下可能造成抖动(频繁上下文切换)。
3. 与硬件原语方案的对比
表格
复制
| 方案 | 优点 | 缺点 |
|---|---|---|
| Bakery(纯软件) | 无需特殊指令,可在任何支持读写内存的体系结构上实现;公平、无饥饿 | O(N) 忙等,扩展性差;对缓存一致性压力大 |
| Test-and-Set / Exchange | 硬件原子指令,实现简单,进入临界区常数时间 | 可能出现饥饿,需额外策略保证公平;依赖硬件支持 |
因此,现代操作系统/虚拟机很少直接用 Bakery 做主力锁,而是将其作为教学原型或部署在无原子指令的极简硬件(如某些嵌入式或历史系统)上。
4. 分布式场景与 Lamport 逻辑时钟
Bakery 算法可视为逻辑时钟的一种应用:号码相当于全局逻辑时间戳,
(number[i],i) 构成全序关系,满足 Lamport 的“happened-before”原则,因此它也被用来实现分布式互斥或作为时间戳策略讲解。五、小结
面包店算法以生活化的排队模型优雅地解决了多线程互斥、公平与无饥饿问题,是并发理论教学中的经典案例。其纯软件实现摆脱了对硬件原子指令的依赖,但O(N) 的忙等和扩展性瓶颈使其在大规模多核系统中退居二线;然而,它体现的编号+字典序思想,对后续锁、队列、逻辑时钟等并发原语的设计具有深远影响。
用python实现它
下面给出一个可直接运行的 Python 演示,用 threading + busy-wait 实现 Lamport 的 Bakery 算法。
为了尽量贴近原论文语义,所有同步变量都放到两个全局 list(
为了尽量贴近原论文语义,所有同步变量都放到两个全局 list(
choosing 和 number)中,线程完全靠“忙等”完成门口排队,进入临界区后只做一个最简单的“打印+睡眠”模拟工作。运行脚本会看到 10 个线程严格按 FIFO 顺序递增共享计数器,且任意时刻最多只有一个线程在临界区。Python
复制
#!/usr/bin/env python3
"""
Lamport Bakery Algorithm – Python demo
运行:python bakery_demo.py
"""
import threading
import time
import random
N_THREADS = 10 # 线程数(顾客数)
CRITICAL_PER_THREAD = 3 # 每个线程想进多少次临界区
# 全局同步变量
choosing = [False] * N_THREADS
number = [0] * N_THREADS
# 一个所有线程都会修改的共享资源,用来验证互斥
shared_counter = 0
# -------------------------------------------------
def lamport_bakery_enter(i: int):
"""线程 i 申请进入临界区(门口排队)"""
# 1. 取号
choosing[i] = True
# 模拟“原子”地找最大号码;Python GIL 让这句足够安全
number[i] = max(number) + 1
choosing[i] = False
# 2. 门口等待:对所有其它线程按规则比较
for j in range(N_THREADS):
if j == i:
continue
# 2a 等对方取号完毕
while choosing[j]:
time.sleep(0.0001) # 让出 CPU,减少忙等功耗
# 2b 等对方号码更大 或 已退出
while number[j] != 0 and (number[j], j) < (number[i], i):
time.sleep(0.0001)
def lamport_bakery_exit(i: int):
"""线程 i 离开临界区(叫号完成)"""
number[i] = 0
# -------------------------------------------------
def thread_worker(i: int):
"""每个线程的生命周期"""
global shared_counter
for _ in range(CRITICAL_PER_THREAD):
# 请求进入
lamport_bakery_enter(i)
# ====== 临界区开始 ======
old = shared_counter
# 人为加一点随机延迟,放大竞态窗口
time.sleep(random.uniform(0.01, 0.05))
shared_counter = old + 1
print(f"[Thread-{i}] in CS -> shared_counter={shared_counter}")
# ====== 临界区结束 ======
# 退出
lamport_bakery_exit(i)
# 非临界区随便睡
time.sleep(random.uniform(0.01, 0.03))
# -------------------------------------------------
if __name__ == "__main__":
threads = []
for idx in range(N_THREADS):
t = threading.Thread(target=thread_worker, args=(idx,))
threads.append(t)
start = time.time()
for t in threads:
t.start()
for t in threads:
t.join()
end = time.time()
print("\nAll done. Final counter =", shared_counter)
print("Elapsed {:.2f}s".format(end - start))
运行示例(片段):
复制
[Thread-0] in CS -> shared_counter=1
[Thread-1] in CS -> shared_counter=2
[Thread-2] in CS -> shared_counter=3
...
[Thread-9] in CS -> shared_counter=30
All done. Final counter = 30
Elapsed 0.63s
可观察到:
-
30 次进入顺序严格按
(number, tid)升序,FIFO 公平; -
打印值连续无跳号,说明任意时刻只有一个线程修改
shared_counter,互斥成立; -
全程未使用任何
Lock/Semaphore,仅靠 Bakery 的 busy-wait 完成同步。
注意:Python 的 GIL 并不能替代互斥,它只是让字节码层面“原子”了一些。
上面代码把共享变量choosing/number放在 list 里,且所有线程都读写,真实体现了多核缓存一致性压力;若把N_THREADS调到几十以上,会因忙等空转导致 CPU 飙高、性能骤降——这正是 Bakery 算法 O(N) 扩展性差的直观体验。
该算法的底层逻辑是什么,后人有什么设计上的优化
下面把“面包店算法”的底层逻辑拆成 3 个层次,再给出后人针对这 3 个层次所做的典型优化。读完就能一眼看出:所谓“优化”其实都是在保留其“分布式编号+全序”思想的同时,把 O(N) 的全局扫描和连续 busy-wait 换成 O(1) 的局部操作或阻塞唤醒。
一、底层逻辑三层模型
-
编号层(ticket)
给每个线程一个单调递增的逻辑时间戳,形成全局全序 → 保证“谁先拿号谁先服务”,天然 FIFO。 -
可见层(announce)
把“我正在拿号”和“我已拿到号”两个状态原子的暴露给所有竞争者 → 解决“同号”竞态,这是互斥的关键。 -
等待层(wait)
后来者在门口持续采样前驱状态,直到发现“比我小的号都走光了”才进门 → 实现无锁忙等,但带来 O(N) 扫描和缓存风暴。
二、各层对应的经典优化
下面按“编号→可见→等待”顺序,给出代表性工作(都保留了 Bakery 的 FIFO 公平性)。
-
编号层:把“全局 max”做成硬件原语
-
1980 年代:TSL/XCHG 指令
用一条原子“测试并置位”完成“拿号”——CPU 在总线锁周期里直接读出旧值并写回新值,O(1) 且无需扫描整个数组。 -
1991:Anderson 数组-based 队列锁
不再让每个人去算 max,而是把 ticket 做成“fetch-and-inc”——CPU 原子指令一次返回旧 ticket,后续排队顺序隐含在整数里,编号层开销降到 O(1)。
-
-
可见层:把“choosing[]”数组换成单比特“conflict”检测
-
1992:Lamport “Fast Mutex”
先原子地抢一个“冲突位”,成功者直接进;失败者再回退到传统 Bakery。平均情况下把可见层从 O(N) 读写缩成一次原子位操作。 -
2008:Krieger/Stumm “qspinlock”
在 MCS 锁里用 1 bit “pending” 标记“有人正在拿号”,语义等价于 choosing,但只触碰到本地 cache line。
-
-
等待层:把“for j=0..N-1 忙等”换成链表/阻塞队列
-
1990:MCS 锁(Mellor-Crummey & Scott)
每个线程在本地节点上自旋,前驱完成时只写下一个节点的私有 cache line,等待层从 O(N) 全局扫描 → O(1) 本地自旋,且彻底消除缓存风暴。 -
1991:Anderson 数组队列锁
类似思想,用循环数组代替链表,同样只自旋自己的 slot。 -
2000 以后:Linux CFS 互斥量、Java ReentrantLock
把“等待”从 busy-wait 改成阻塞队列 + futex 唤醒:拿不到锁就挂起,解锁时只唤醒第一个等待者。既保留 FIFO,又把 CPU 空转降到 0。
-
三、小结一句话
Bakery 的“灵魂”是用单调编号建立分布式全序;
后人所有优化都在“保留灵魂的同时,把 O(N) 全局扫描和忙等换成 O(1) 原子指令 + 局部自旋/阻塞唤醒”,从而让它从教学原型变成工业级锁(Linux qspinlock、Java 队列同步器、Go 的公平 semaphore 等)——核心思想未变,只是不再傻傻地数完全部顾客才轮到自己。
后人所有优化都在“保留灵魂的同时,把 O(N) 全局扫描和忙等换成 O(1) 原子指令 + 局部自旋/阻塞唤醒”,从而让它从教学原型变成工业级锁(Linux qspinlock、Java 队列同步器、Go 的公平 semaphore 等)——核心思想未变,只是不再傻傻地数完全部顾客才轮到自己。
我给每一个线程,分配一个自旋时间不一样的锁,自旋时间为质数表,从2开始。这样是不是更好的解决了同一个机器上互斥问题?
不会更好,反而可能破坏互斥本身。
-
互斥的正确性不能靠“等得不一样长”
只要有两个线程同时进临界区,哪怕一个只抢先 1 ns,数据竞争就已经发生。质数自旋时间只能让冲突概率降低,却无法保证“任意时刻至多一个”——这是原则性错误。 -
仍然会有重叠窗口
CPU 调度粒度通常是微秒级,而质数间隔你打算设 2、3、5、7… ms 的话,远大于调度粒度;看似错开,但一旦线程数 > 质数表长度或出现调度抖动,就会同时醒来——此时没有额外同步原语,依旧一起冲进临界区。 -
丢失公平与饥饿-free
Bakery / MCS 的 FIFO 顺序被彻底打乱,后到的线程可能因为“选的质数小”反而先拿到锁,造成饥饿甚至活锁。 -
性能也不划算
想降低冲突,最省 CPU 的做法是立刻睡眠、由解锁方精准唤醒(MCS/futex)。让 CPU 空转一个与临界区毫无关系的质数时长,只是把宝贵的时钟周期白白烧掉,吞吐和延迟都会变差。 -
真正想“错开”冲突,有成熟且正确的方案
-
指数退避(exponential backoff):在真正的原子指令失败时才退避,且每次重试间隔指数增加,仍配合原子操作保证互斥。
-
队列阻塞:如 MCS、CLH、Linux qspinlock,让后来者在本地变量上睡眠,解锁时只唤醒一人,既无冲突也不空转。
-
随机退避仅用于降低总线风暴,永远放在已保证正确性的循环里,而不是替代循环。
-
结论
“给每个线程分配质数自旋时间”只是把数据竞争从高频变成低频,并未根除;一旦运气不好就会同时进临界区。
互斥问题的唯一正确解是某种形式的“原子共识”(硬件原子指令、队列、令牌、信号量等),而不是“等得不一样久”。
“给每个线程分配质数自旋时间”只是把数据竞争从高频变成低频,并未根除;一旦运气不好就会同时进临界区。
互斥问题的唯一正确解是某种形式的“原子共识”(硬件原子指令、队列、令牌、信号量等),而不是“等得不一样久”。
全部评论