为什么需要锁?—— 线程安全问题
我们需要理解在多线程编程中,为什么会出现问题,问题的根源在于共享数据和线程调度的不确定性。
想象一个简单的场景:两个线程(Thread A 和 Thread B)都要对一个共享的变量 counter 进行 +1 操作,在单线程中,这个操作是原子的,可以看作一步:
counter = counter + 1
但在多线程环境下,这个操作实际上在 Python 解释器(CPython)中至少需要三个步骤:
- 读取:从内存中读取
counter的当前值。 - 计算:将这个值加 1。
- 写入:将计算后的新值写回内存。
由于线程是操作系统调度的,执行顺序可能是混乱的,让我们看看一个典型的竞态条件(Race Condition)会发生什么:
假设初始时 counter = 0。
| 时间点 | 线程 A 操作 | 线程 B 操作 | counter 值 |
|---|---|---|---|
| T1 | 读取 counter (值为 0) |
0 | |
| T2 | 读取 counter (值为 0) |
0 | |
| T3 | 计算新值: 0 + 1 = 1 |
0 | |
| T4 | 计算新值: 0 + 1 = 1 |
0 | |
| T5 | 将新值 1 写入 counter |
1 | |
| T6 | 将新值 1 写入 counter |
1 |
结果:两个线程都执行了一次 +1 操作,但 counter 的值只增加了 1(从 0 变成了 1),而不是期望的 2,这就是线程安全问题。
锁就是为了解决这类问题而生的,它就像一个保证独占访问的通行证。
什么是锁?
锁是一种同步原语,它强制要求线程在访问共享资源之前,先获取锁。
- 获取锁:如果一个线程成功获取了锁,那么它就获得了对共享资源的“访问权限”,这个锁就处于“锁定”状态。
- 阻塞:如果另一个线程也想访问同一个资源,它会发现锁已经被占用,于是它必须等待,直到锁被释放,这个等待的线程就处于“阻塞”状态。
- 释放锁:当持有锁的线程完成了对共享资源的操作,它会释放锁,锁一旦被释放,其他等待的线程中会有一个(通常是等待时间最长的那个)获取到锁,并获得访问权限。
通过这种方式,锁确保了在任何时刻,只有一个线程可以执行被锁保护的代码块,这就保证了代码块的“原子性”,从而避免了竞态条件。
如何使用锁?—— threading.Lock
Python 的 threading 模块提供了 Lock 类,它的使用非常简单,核心方法有三个:
acquire():获取锁。release():释放锁。with语句:这是最推荐、最安全的使用方式。
示例1:不使用锁(线程不安全)
import threading
import time
# 共享资源
counter = 0
def worker():
global counter
for _ in range(1_000_000):
counter += 1
print(f"线程 {threading.current_thread().name} 结束, counter 当前值: {counter}")
# 创建并启动多个线程
threads = []
for i in range(5):
t = threading.Thread(target=worker, name=f"Thread-{i}")
threads.append(t)
t.start()
# 等待所有线程完成
for t in threads:
t.join()
print(f"counter 值: {counter}") # 结果几乎肯定不是 5,000,000
运行这个代码,你会发现最终 counter 的值远小于 5,000,000,因为发生了我们上面描述的竞态条件。
示例2:使用 acquire() 和 release()(线程安全)
这是最基本的使用方式,但容易出错(比如忘记 release)。
import threading
import time
counter = 0
# 创建一个锁对象
lock = threading.Lock()
def worker():
global counter
for _ in range(1_000_000):
# 尝试获取锁
lock.acquire()
try:
# 在这里执行需要同步的代码(临界区)
counter += 1
finally:
# 确保锁一定会被释放
lock.release()
print(f"线程 {threading.current_thread().name} 结束, counter 当前值: {counter}")
threads = []
for i in range(5):
t = threading.Thread(target=worker, name=f"Thread-{i}")
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"counter 值: {counter}") # 结果应该是 5,000,000
在这个例子中,lock.acquire() 和 lock.release() 之间的代码块就是临界区,任何时刻只有一个线程能进入这个区域。
注意:使用 try...finally 是一个好习惯,它能确保即使临界区内发生异常,锁也能被正确释放,避免死锁。
示例3:使用 with 语句(推荐!)
with 语句是 Python 的上下文管理器协议,Lock 对象实现了这个协议,使用 with 可以让代码更简洁、更安全,因为它会自动处理锁的获取和释放,即使代码块内部抛出异常。
import threading
counter = 0
lock = threading.Lock()
def worker():
global counter
for _ in range(1_000_000):
# with 语句会自动调用 acquire() 和 release()
with lock:
# 临界区代码
counter += 1
print(f"线程 {threading.current_thread().name} 结束, counter 当前值: {counter}")
threads = []
for i in range(5):
t = threading.Thread(target=worker, name=f"Thread-{i}")
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"counter 值: {counter}") # 结果应该是 5,000,000
强烈推荐使用 with 语句,它更 Pythonic,也更健壮。
锁的类型
threading.Lock 在 Python 中实际上是一个“可重入锁”(Reentrant Lock),也叫“递归锁”。
- 可重入锁:允许同一个线程多次获取同一个锁,而不会导致自己被阻塞,每次获取锁,内部计数器加一;每次释放锁,计数器减一,只有当计数器降为 0 时,锁才会真正被释放,其他线程才能获取它。
为什么需要可重入锁? 考虑一个场景:你在一个被锁保护的函数 A 中,又调用了另一个同样需要同一个锁的函数 B。
lock = threading.Lock()
def function_b():
with lock: # 第一次获取锁
print("在 function_b 中执行...")
def function_a():
with lock: # 第一次获取锁
print("在 function_a 中执行...")
function_b() # function_a 已经持有锁,现在又想获取同一个锁
# 如果锁不是可重入的,function_b 在这里会因为无法获取锁而阻塞,导致死锁。
# 因为锁是可重入的,function_b 可以成功获取锁,执行完后释放,function_a 继续执行。
如果锁不是可重入的,上面的代码会导致死锁。
threading 模块还提供了另一个锁 threading.RLock,它和 threading.Lock 功能完全一样,因为它就是可重入锁的显式表示,通常我们直接使用 Lock 即可,因为它在 CPython 中已经默认是可重入的。
死锁
死锁是多线程编程中最需要警惕的问题之一,它指的是两个或多个线程因争夺资源而造成的一种互相等待的僵局,若无外力作用,它们都将无法向前推进。
一个经典的死锁场景是“哲学家就餐问题”的简化版:
import threading
import time
# 两把叉子(锁)
chopstick_1 = threading.Lock()
chopstick_2 = threading.Lock()
def philosopher_1():
while True:
print("哲学家1 拿起左叉子")
chopstick_1.acquire()
print("哲学家1 拿起右叉子")
chopstick_2.acquire() # 会阻塞,因为chopstick_2被哲学家2拿着
print("哲学家1 正在吃饭...")
chopstick_2.release()
chopstick_1.release()
time.sleep(1)
def philosopher_2():
while True:
print("哲学家2 拿起左叉子")
chopstick_2.acquire()
print("哲学家2 拿起右叉子")
chopstick_1.acquire() # 会阻塞,因为chopstick_1被哲学家1拿着
print("哲学家2 正在吃饭...")
chopstick_1.release()
chopstick_2.release()
time.sleep(1)
# 创建并启动线程
t1 = threading.Thread(target=philosopher_1)
t2 = threading.Thread(target=philosopher_2)
t1.start()
t2.start()
t1.join()
t2.join()
运行这段代码,你会看到两个线程很快就会互相等待对方释放锁,导致程序卡住。
如何避免死锁?
- 按固定顺序获取锁:在上面的例子中,如果规定所有哲学家都必须先拿起编号小的叉子,再拿起编号大的叉子,就不会死锁。
- 设置超时:
lock.acquire(timeout=...)可以在获取锁时设置一个超时时间,如果在指定时间内没有获取到锁,就放弃本次尝试,可以做其他事情,避免无限等待。 - 使用高级同步工具:对于复杂的场景,可以考虑使用
threading.Condition、threading.Semaphore或queue.Queue等工具,它们的设计更不容易导致死锁。
总结与最佳实践
| 特性 | 描述 |
|---|---|
| 目的 | 解决多线程环境下共享资源的线程安全问题,防止竞态条件。 |
| 核心机制 | 互斥:确保在任何时刻只有一个线程能执行被保护的代码块。 |
| 关键方法 | acquire() (获取锁), release() (释放锁) |
| 推荐用法 | with lock: 语句,它能自动处理锁的获取和释放,代码更简洁、安全,且能处理异常。 |
| 锁类型 | threading.Lock 在 CPython 中是可重入锁,允许同一线程多次获取同一把锁。 |
| 风险 | 死锁:多个线程互相等待对方释放锁,导致程序卡住。 |
| 最佳实践 | 尽量缩小临界区:只在真正需要同步的代码块上使用锁,不要把整个大函数都包起来,这会影响并发性能。 避免在持有锁时调用外部代码:因为你无法保证外部代码会不会也尝试获取同一个锁,这极易导致死锁。 注意死锁:设计时要考虑线程获取锁的顺序,避免循环等待。 |
锁是强大的工具,但也是一柄双刃剑,过度使用锁会降低程序的并发性能,因为它本质上把并行执行串行化了,在编写多线程代码时,应首先思考是否真的需要共享状态,如果可以,尽量通过其他方式(如消息队列、无锁数据结构)来避免使用锁,如果必须使用,请务必小心,并遵循最佳实践。
