用锁保护共享资源:互斥锁上岗
开篇除恐
当人们听到“互斥锁”(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(☼☼ 正经):多重资源保护——避免死锁的锁顺序
任务:两个共享变量 a 和 b,分别由两个互斥锁 lock_a 和 lock_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.Lock,process() 加锁后调用 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)在特定场景下有用,但一般情况下尽量避免,以免掩盖设计问题并增加难以追踪的锁持有计数。
- 掌握了互斥锁的正确使用后,你就能够有效地保护共享数据,防止竞态条件和可见性问题。
- 下一章我们将学习另一种常用的同步原语——条件变量:如何让线程在某个条件满足前等待,以及条件满足时如何安全地唤醒等待者,实现经典的生产者‑消费者模式。
就这样。 下一章我们来看看“条件变量:厨师之间的递话筒”,了解怎样让厨师在食材没好的时候先休息,等到食材准备好时再被叫醒继续工作。