用锁保护共享资源:互斥锁上岗

开篇除恐

当人们听到“互斥锁”(Mutex)时,常会想到又是一堆符号:pthread_mutex_lock, std::mutex, lock_guard……感觉像是要先读完一本操作系统手册才能敢用。其实,互斥锁只不过是厨房里的“案板使用权”牌子:只有拿到牌子的厨师才能切菜,其他人必须等他放下牌子后才能拿到。只要记住这两点——上锁表示“我正在用这个资源”,下锁表示“我用完了,你们可以来”,就能够正确地用互斥锁保护共享数据,防止竞态条件和可见性问题。

白话化

  • 互斥锁(Mutex):一种同步原语,保证同一时间只有一个线程能持有它。获取锁称为加锁(lock),释放锁称为解锁(unlock)。
  • 临界区(Critical Section):受互斥锁保护的代码段,在这里对共享资源进行读取或修改。临界区内的操作对其他线程是原子的(因为其他线程不可能同时持有同一把锁)。
  • RAII 风格的锁包装器:如 Python 的 with lock:(基于 __enter__/__exit__),它们在进入时自动加锁,在离开时自动解锁,即使在异常情况下也不会忘记解锁,从而避免因忘记解锁而导致的死锁。
  • 递归锁(Recursive Mutex)threading.RLock 允许同一线程多次加锁而不会自死,适用于嵌套调用需要同一把锁的场景,但使用时要小心避免过度设计导致难以追踪的锁持有次数。
  • 读写锁(RWLock)(非本章重点):在读多写少的场景下,允许多个线程同时持有读锁,但写锁是独占的(可通过 threading.Lock 结合计数器实现,或使用第三方库)。

简而言之,互斥锁的使用模式就是: 1. 创建一个互斥锁对象(全局或作为类的成员); 2. 在需要访问共享资源的代码前,加锁; 3. 完成访问后,解锁; 4. 为了安全,最好使用 RAII 风格的 with lock: 自动管理锁的生命周期。

直觉先行

想象你们俩在同一厨房准备一道菜,共有的资源是案板和刀子。只有拿到案板的厨师才能切菜;如果两人都同时去抢案板,就会发生争抢、甚至可能把菜切伤。于是你们约定:案板旁挂一块红牌子,只有拿到红牌子的人才能使用案板和刀子;用完后必须把红牌子挂回原位,才能让另一人拿去。

如果有人用完后忘记把红牌子挂回去,后面的人就会永远等不到牌子,导致饥饿(虽然这里不是死锁,但也是问题)。如果有人在拿到牌子后又去试图再拿一次牌子(而牌子不可递归),就会自我阻塞,导致自死。因此,使用互斥锁时要注意: - 及时释放(避免资源长期被占用); - 不要在已经持有锁的线程里再次尝试加同一个非递归锁(除非你明确使用递归锁); - 尽量让锁的粒度尽量小,只保护真正需要同步的那部分代码,以免不必要地串行化程序。

例子贴身

例子 1(☼ 热身):用 threading.Lock 保护全局计数器

任务:让 10 条线程各自对 counter 加 1000 次,最后得到准确的 10000。

怎么想到的:我们把 threading.Lock 声明为全局变量;每次要修改 counter 时,使用 with lock: 来自动加锁/解锁。这样不管线程如何交错,同一时刻只有一条线程能进入临界区。

示例代码(Python)

import threading

lock = threading.Lock()
counter = 0

def worker():
    global counter
    for _ in range(1000):
        with lock:          # 加锁
            counter += 1    # 临界区
        # with 语句结束时自动解锁

def main():
    threads = []
    for _ in range(10):
        t = threading.Thread(target=worker)
        threads.append(t)
        t.start()
    for t in threads:
        t.join()
    print(f"Final counter = {counter}")  # 期望 10000

if __name__ == "__main__":
    main()

运行结果

Final counter = 10000

要点
- with lock: 是 Python 的 RAII 风格锁包装器,进入时加锁,离开时自动解锁,即便在块中抛出异常也不会忘记解锁。
- 如果你需要在作用域中途提前释放锁(比如满足某个条件后想提前让出资源),则可以显式获取锁并手动调用 release(),但推荐仍是使用 with 语句保持代码简洁安全。
- 若需要一次性锁住多个互斥锁,可使用后文示例的有序加锁方式或自行实现。

例子 2(☼☼ 正经):多重资源保护——避免死锁的锁顺序

任务:两个共享变量 ab,分别由两个互斥锁 lock_alock_b 保护。需要有时同时修改两个变量(比如转账操作:从 a 扣除 X,加到 b 上)。演示如何通过按固定顺序加锁来避免死锁。

怎么想到的:如果线程 A 锁 lock_a 然后锁 lock_b,线程 B 锁 lock_b 然后锁 lock_a,就可能产生死锁。解决办法是:在需要同时持有多个锁时,总是按照同样的全局顺序(比如按照锁的 id)加锁。这样即使线程想要的锁是同两把,也不会出现循环等待。

示例代码(Python)

import threading
import time

def ordered_lock(*locks):
    """按锁的 id 排序后依次获取,避免死锁"""
    sorted_locks = sorted(locks, key=lambda x: id(x))
    for lock in sorted_locks:
        lock.acquire()
    try:
        yield
    finally:
        # 释放时按相反顺序释放(虽然不是必须,但保持对称)
        for lock in reversed(sorted_locks):
            lock.release()

lock_a = threading.Lock()
lock_b = threading.Lock()
a = 1000
b = 0

def transfer_from_a_to_b(tid, amount):
    global a, b
    with ordered_lock(lock_a, lock_b):
        # 临界区:同时读取 a 和 b,然后修改
        if a >= amount:
            a -= amount
            b += amount
            print(f"Thread {tid} transferred {amount} from a to b. a={a}, b={b}")
        else:
            print(f"Thread {tid} insufficient funds in a")
    # with 结束后自动释放所有锁

def worker(wid):
    for i in range(20):
        transfer_from_a_to_b(wid % 10, 10)
        time.sleep(0.005)

def main():
    t1 = threading.Thread(target=worker, args=(1,))
    t2 = threading.Thread(target=worker, args=(2,))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(f"Final a = {a}, b = {b}")

if __name__ == "__main__":
    main()

运行结果(会看到转账成功或失败的信息,最终 a+b 应该始终等于 1000)

Final a = ?, b = ?

要点
- 有序加锁(按 id 排序)是避免死锁的简单有效方法:只要所有线程申请资源的顺序完全一致,就不可能出现环路等待。
- 在实际项目中,常见的做法是为每种锁分配一个全局唯一的编号(比如按照 id 或哈希排序),然后在加锁前按照编号升序排序后再锁。
- 以上示例使用了自定义的 ordered_lock 上下文管理器来实现按 id 排序加锁。

例子 3(☼☼☼ 硬骨头):递归锁的使用场景

任务:展示何时可能需要递归锁(threading.RLock),以及其使用方式和注意事项。

怎么想到的:假设有一个类方法 process() 需要访问受保护的成员变量,而在 process() 内部又会调用另一个私有方法 helper(),而 helper() 也需要访问同一个受保护的成员。如果用普通 threading.Lockprocess() 加锁后调用 helper() 再次尝试加锁会导致自死(因为同一线程不能再次锁同一个非递归互斥锁)。此时使用递归锁 threading.RLock 可以让同一线程多次加锁而不阻塞,只要解锁的次数匹配加锁的次数即可。

示例代码(Python)

import threading
import time

class SharedCounter:
    def __init__(self):
        self._rlock = threading.RLock()
        self._count = 0

    def increment(self):
        with self._rlock:
            self._count += 1

    def helper(self):
        # 这里也需要访问 _count
        with self._rlock:
            self._count += 5  # 示例操作

    def process(self):
        with self._rlock:
            self._count += 1   # 第一次加锁
            self.helper()      # 再次进入 helper,也会加锁,但因为是递归锁,不会阻塞
            # 离开 with 块后自动解锁(实际会递减计数)

    @property
    def count(self):
        with self._rlock:
            return self._count

def worker(wid):
    for _ in range(50):
        SharedCounterInstance.process()

def main():
    global SharedCounterInstance
    SharedCounterInstance = SharedCounter()
    t1 = threading.Thread(target=worker, args=(1,))
    t2 = threading.Thread(target=worker, args=(2,))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(f"Final count = {SharedCounterInstance.count}")

if __name__ == "__main__":
    main()

运行结果

Final count = 200

要点
- threading.RLock 允许同一线程多次加锁而不会阻塞,但使用时要保证解锁次数匹配加锁次数with 语句自动处理),否则会导致资源永远被占用。
- 递归锁往往掩盖了设计上的问题:如果一个函数需要重复进入临界区,或许可以把临界区拆分成更细粒度的锁,或者使用条件变量/状态机来避免递归锁的需要。
- 在多数情况下,普通互斥锁 + 明确的作用域(使用 with lock:)就足够了;只有在特定的嵌套回调或框架需求时才考虑递归锁。

收尾

这一章要带走的东西
- 互斥锁的核心职责是:同一时间只允许一个线程进入被其保护的临界区,从而把对共享资源的读取‑修改‑写入操作变得原子且可见。
- 使用 RAII 风格的锁包装器with lock:)是最安全的做法,能够自动处理加锁/解锁,即使发生异常也不漏掉解锁。
- 当需要同时持有多个锁时,请遵守全局顺序(如按锁的 id)加锁,或使用有序加锁上下文管理器来避免死锁。
- 递归锁threading.RLock)在特定场景下有用,但一般情况下尽量避免,以免掩盖设计问题并增加难以追踪的锁持有计数。
- 掌握了互斥锁的正确使用后,你就能够有效地保护共享数据,防止竞态条件和可见性问题。
- 下一章我们将学习另一种常用的同步原语——条件变量:如何让线程在某个条件满足前等待,以及条件满足时如何安全地唤醒等待者,实现经典的生产者‑消费者模式。

就这样。 下一章我们来看看“条件变量:厨师之间的递话筒”,了解怎样让厨师在食材没好的时候先休息,等到食材准备好时再被叫醒继续工作。