搜索面包店算法解决互斥问题。并深入探讨

2 2025-12-06 09:26

面包店算法(Bakery Algorithm)是莱斯利·兰波特(Leslie Lamport)于1974 年提出的一种纯软件实现的互斥算法,用于解决多进程/线程对临界区的互斥访问问题。其核心思想源于日常生活中面包店的取号排队机制:顾客进店时先领一个号码,号码小的先被服务;若号码相同,则按顾客名字的字典序排队。对应到计算机系统,进程就是顾客,号码和名字分别对应线程的编号和线程 ID。

一、算法数据结构

表格
 
复制
变量 类型 作用
choosing[i] bool 线程 i 是否正在“取号”
number[i] int 线程 i 拿到的号码,0 表示未参与排队
初始化时所有 choosing[i]=falsenumber[i]=0

二、算法流程(线程 i 视角)

  1. 取号阶段
    1. choosing[i] = true;
    2. number[i] = max(number[0..N-1]) + 1;
    3. choosing[i] = false;
  2. 门口等待(忙等)
    对任意 j ≠ i
    • 等待 choosing[j] == false(对方取号完毕)
    • 等待 number[j]==0(number[j],j) > (number[i],i)(对方号码更大或已退出)
  3. 进入临界区
    执行临界区代码。
  4. 退出临界区
    number[i] = 0;(释放资源,相当于“叫号完成”)

三、正确性论证

  1. 互斥性(Mutual Exclusion)
    若两个线程同时进入临界区,则它们必须满足各自的等待条件,即都认为自己的“号码+ID”最小,这与全序关系矛盾,故互斥成立。
  2. 无死锁(Deadlock-Free)
    线程只在 choosing[j] 为 false 且对方号码更大时自旋,若所有线程都推进,则总能选出一个最小号码,故不会无限等待。
  3. 无饥饿(Starvation-Free)
    号码严格递增,先取号的线程一定先通过等待循环,后续线程无法“插队”,保证了 FIFO 公平性。

四、深入探讨

1. 为什么必须保留 choosing 标志?

省略 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(choosingnumber)中,线程完全靠“忙等”完成门口排队,进入临界区后只做一个最简单的“打印+睡眠”模拟工作。运行脚本会看到 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
 
可观察到:
  1. 30 次进入顺序严格按 (number, tid) 升序,FIFO 公平;
  2. 打印值连续无跳号,说明任意时刻只有一个线程修改 shared_counter,互斥成立;
  3. 全程未使用任何 Lock/Semaphore,仅靠 Bakery 的 busy-wait 完成同步。
注意:Python 的 GIL 并不能替代互斥,它只是让字节码层面“原子”了一些。
上面代码把共享变量 choosing/number 放在 list 里,且所有线程都读写,真实体现了多核缓存一致性压力;若把 N_THREADS 调到几十以上,会因忙等空转导致 CPU 飙高、性能骤降——这正是 Bakery 算法 O(N) 扩展性差的直观体验。

 

该算法的底层逻辑是什么,后人有什么设计上的优化

下面把“面包店算法”的底层逻辑拆成 3 个层次,再给出后人针对这 3 个层次所做的典型优化。读完就能一眼看出:所谓“优化”其实都是在保留其“分布式编号+全序”思想的同时,把 O(N) 的全局扫描和连续 busy-wait 换成 O(1) 的局部操作或阻塞唤醒

一、底层逻辑三层模型

  1. 编号层(ticket)
    给每个线程一个单调递增的逻辑时间戳,形成全局全序 → 保证“谁先拿号谁先服务”,天然 FIFO。
  2. 可见层(announce)
    把“我正在拿号”和“我已拿到号”两个状态原子的暴露给所有竞争者 → 解决“同号”竞态,这是互斥的关键。
  3. 等待层(wait)
    后来者在门口持续采样前驱状态,直到发现“比我小的号都走光了”才进门 → 实现无锁忙等,但带来 O(N) 扫描和缓存风暴。

二、各层对应的经典优化

下面按“编号→可见→等待”顺序,给出代表性工作(都保留了 Bakery 的 FIFO 公平性)。
  1. 编号层:把“全局 max”做成硬件原语
    • 1980 年代:TSL/XCHG 指令
      用一条原子“测试并置位”完成“拿号”——CPU 在总线锁周期里直接读出旧值并写回新值,O(1) 且无需扫描整个数组。
    • 1991:Anderson 数组-based 队列锁
      不再让每个人去算 max,而是把 ticket 做成“fetch-and-inc”——CPU 原子指令一次返回旧 ticket,后续排队顺序隐含在整数里,编号层开销降到 O(1)。
  2. 可见层:把“choosing[]”数组换成单比特“conflict”检测
    • 1992:Lamport “Fast Mutex”
      先原子地抢一个“冲突位”,成功者直接进;失败者再回退到传统 Bakery。平均情况下把可见层从 O(N) 读写缩成一次原子位操作。
    • 2008:Krieger/Stumm “qspinlock”
      在 MCS 锁里用 1 bit “pending” 标记“有人正在拿号”,语义等价于 choosing,但只触碰到本地 cache line。
  3. 等待层:把“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 等)——核心思想未变,只是不再傻傻地数完全部顾客才轮到自己。

 

我给每一个线程,分配一个自旋时间不一样的锁,自旋时间为质数表,从2开始。这样是不是更好的解决了同一个机器上互斥问题?

不会更好,反而可能破坏互斥本身
  1. 互斥的正确性不能靠“等得不一样长”
    只要有两个线程同时进临界区,哪怕一个只抢先 1 ns,数据竞争就已经发生。质数自旋时间只能让冲突概率降低,却无法保证“任意时刻至多一个”——这是原则性错误。
  2. 仍然会有重叠窗口
    CPU 调度粒度通常是微秒级,而质数间隔你打算设 2、3、5、7… ms 的话,远大于调度粒度;看似错开,但一旦线程数 > 质数表长度或出现调度抖动,就会同时醒来——此时没有额外同步原语,依旧一起冲进临界区。
  3. 丢失公平与饥饿-free
    Bakery / MCS 的 FIFO 顺序被彻底打乱,后到的线程可能因为“选的质数小”反而先拿到锁,造成饥饿甚至活锁。
  4. 性能也不划算
    想降低冲突,最省 CPU 的做法是立刻睡眠、由解锁方精准唤醒(MCS/futex)。让 CPU 空转一个与临界区毫无关系的质数时长,只是把宝贵的时钟周期白白烧掉,吞吐和延迟都会变差。
  5. 真正想“错开”冲突,有成熟且正确的方案
    • 指数退避(exponential backoff):在真正的原子指令失败时才退避,且每次重试间隔指数增加,仍配合原子操作保证互斥。
    • 队列阻塞:如 MCS、CLH、Linux qspinlock,让后来者在本地变量上睡眠,解锁时只唤醒一人,既无冲突也不空转。
    • 随机退避仅用于降低总线风暴,永远放在已保证正确性的循环里,而不是替代循环。
结论
“给每个线程分配质数自旋时间”只是把数据竞争从高频变成低频,并未根除;一旦运气不好就会同时进临界区
互斥问题的唯一正确解是某种形式的“原子共识”(硬件原子指令、队列、令牌、信号量等),而不是“等得不一样久”。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

全部评论

·