在并发编程中,多个线程或进程同时访问共享资源时,容易出现数据不一致、逻辑混乱等问题(即 "竞态条件")。Python 提供了多种锁机制来解决这些问题,通过控制资源的访问权限,保证临界区代码的 "原子性执行"(即要么不执行,要么完整执行)。本文将详细介绍 Python 中的锁机制,包括核心原理、常用锁类型及实践场景。
一、为什么需要锁?并发编程的 "痛点"
在单线程程序中,代码按顺序执行,不存在资源竞争问题。但在多线程 / 多进程环境中,多个执行单元可能同时操作共享资源(如全局变量、数据库连接、文件等),导致不可预期的结果。
例如,两个线程同时对同一个变量执行 count += 1
操作:
count += 1
本质上是三步操作(读取值→加 1→写回),若线程 1 执行到 "加 1" 时,线程 2 恰好读取了原始值,最终会导致结果少加 1(即 "竞态条件")。
锁的核心作用:通过 "互斥" 机制,确保同一时间只有一个执行单元能进入 "临界区"(需要保护的共享资源操作代码),从而避免竞态条件。
二、Python 中的 "特殊锁":GIL(全局解释器锁)
在深入具体锁类型前,必须先理解 Python 的 GIL(Global Interpreter Lock) ------ 它是 CPython 解释器的一个核心机制,也是很多人对 Python 并发产生误解的根源。
1. GIL 的作用
GIL 是一个互斥锁,确保同一时间只有一个线程能执行 Python 字节码。其设计初衷是简化 CPython 对共享资源(如内存管理)的处理,避免多线程导致的复杂同步问题。
2. GIL 对多线程的影响
- CPU 密集型任务:多线程无法真正并行(因 GIL 限制,同一时间只有一个线程执行),甚至可能比单线程慢(线程切换开销)。此时应使用多进程(规避 GIL)。
- IO 密集型任务:多线程有效(IO 操作时线程会释放 GIL,其他线程可执行),如网络请求、文件读写等。
3. GIL 与用户锁的关系
GIL 仅保证字节码级的原子性,但复杂操作(如多步修改共享变量)仍可能出现竞态条件。例如:
scss
import threading
count = 0
def add():
global count
for _ in range(1000000):
count += 1 # 非原子操作,可能被线程切换打断
t1 = threading.Thread(target=add)
t2 = threading.Thread(target=add)
t1.start()
t2.start()
t1.join()
t2.join()
print(count) # 结果可能小于 2000000,因 GIL 无法完全保护
因此,即使有 GIL,用户仍需手动加锁保护复杂共享资源操作。
三、Python 常用锁类型及实践
Python 的 threading
模块提供了多种锁机制,适用于不同场景。以下是最常用的几种:
1. Lock(互斥锁):最基础的锁
Lock
是最简单的锁类型,通过 acquire()
获取锁、release()
释放锁,实现 "同一时间只有一个线程进入临界区"。
核心特性:
- 非可重入:同一线程无法多次
acquire()
(会阻塞)。 - 必须配对使用:
acquire()
后必须release()
,否则会导致死锁。
使用示例:
csharp
import threading
lock = threading.Lock()
count = 0
def safe_add():
global count
for _ in range(1000000):
lock.acquire() # 获取锁,若已被占用则阻塞
try:
count += 1 # 临界区:受锁保护
finally:
lock.release() # 确保释放锁(即使出错)
t1 = threading.Thread(target=safe_add)
t2 = threading.Thread(target=safe_add)
t1.start()
t2.start()
t1.join()
t2.join()
print(count) # 结果必为 2000000,因锁保证了原子性
注意事项:
- 用
try-finally
确保锁释放,避免因异常导致死锁。 - 避免长时间持有锁(会降低并发效率)。
2. RLock(可重入锁):解决同一线程多次加锁问题
RLock
(Reentrant Lock)允许同一线程多次获取锁(内部维护计数器),适用于递归函数或同一线程需多次进入临界区的场景。
核心特性:
- 可重入:同一线程
acquire()
多少次,需release()
多少次,计数器归 0 时才真正释放锁。 - 仅当前持有锁的线程可释放锁。
使用示例:
python
import threading
rlock = threading.RLock()
def recursive_func(n):
rlock.acquire() # 第一次获取锁
print(f"Enter {n}")
if n > 0:
recursive_func(n - 1) # 递归调用,再次获取锁(RLock 允许)
rlock.release() # 释放锁,需与 acquire 次数匹配
threading.Thread(target=recursive_func, args=(3,)).start()
# 输出:Enter 3 → Enter 2 → Enter 1 → Enter 0(无死锁)
注意:
若用 Lock
替代 RLock
,上述代码会因同一线程第二次 acquire()
而死锁。
3. Semaphore(信号量):控制并发数量
Semaphore
用于限制同时访问资源的线程数量(类似 "许可证" 机制),适用于资源有限的场景(如数据库连接池、并发请求限制)。
核心特性:
- 初始化时指定 "许可证数量"(
Semaphore(n)
)。 acquire()
:获取许可证(数量 - 1,若为 0 则阻塞)。release()
:释放许可证(数量 + 1)。
使用示例:限制同时访问的线程数为 2
python
import threading
import time
semaphore = threading.Semaphore(2) # 最多2个线程同时执行
def access_resource(name):
semaphore.acquire()
print(f"Thread {name} is accessing resource")
time.sleep(2) # 模拟资源操作
print(f"Thread {name} finished")
semaphore.release()
# 创建5个线程
for i in range(5):
threading.Thread(target=access_resource, args=(i,)).start()
输出会显示:每次最多 2 个线程同时执行,其他线程需等待前序线程释放许可证。
4. Event(事件):线程间通信的 "开关"
Event
用于线程间的状态同步 (如 "通知" 机制),一个线程可通过 set()
发送信号,其他线程通过 wait()
等待信号。
核心特性:
- 内部维护一个 "标志位"(默认 False)。
set()
:将标志位设为 True,唤醒所有等待的线程。clear()
:将标志位设为 False。wait(timeout)
:阻塞等待,直到标志位为 True 或超时。
使用示例:主线程通知子线程开始工作
scss
import threading
import time
event = threading.Event()
def worker():
print("Worker: Waiting for start signal...")
event.wait() # 等待事件触发
print("Worker: Start working!")
t = threading.Thread(target=worker)
t.start()
time.sleep(2) # 主线程准备工作
print("Main: Sending start signal")
event.set() # 触发事件,唤醒worker
t.join()
5. Condition(条件变量):更灵活的线程协作
Condition
结合了锁 和事件的功能,允许线程在满足特定条件时才执行,适用于复杂的线程协作场景(如生产者 - 消费者模型)。
核心特性:
- 内部包含一个锁(默认
RLock
),需通过acquire()
/release()
管理。 wait()
:释放内部锁,阻塞等待,被唤醒后重新获取锁。notify(n)
:唤醒最多 n 个等待的线程。notify_all()
:唤醒所有等待的线程。
使用示例:生产者 - 消费者模型
scss
import threading
import queue
# 共享队列(最多存3个元素)
q = queue.Queue(maxsize=3)
cond = threading.Condition()
def producer():
for i in range(5):
with cond: # 自动管理 acquire/release
# 若队列满,等待消费者取走元素
while q.full():
cond.wait()
q.put(i)
print(f"Produced {i}, queue size: {q.qsize()}")
cond.notify() # 通知消费者
def consumer():
for _ in range(5):
with cond:
# 若队列空,等待生产者添加元素
while q.empty():
cond.wait()
item = q.get()
print(f"Consumed {item}, queue size: {q.qsize()}")
cond.notify() # 通知生产者
t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)
t1.start()
t2.start()
t1.join()
t2.join()
上述代码中,生产者和消费者通过 Condition
协调:队列满时生产者等待,队列空时消费者等待,避免了无效的轮询。
四、锁的常见问题与避坑指南
1. 死锁:最常见的并发陷阱
死锁指两个或多个线程相互等待对方释放锁,导致程序永久阻塞。例如:
scss
lock1 = threading.Lock()
lock2 = threading.Lock()
def thread1():
lock1.acquire()
lock2.acquire() # 等待 thread2 释放 lock2
# ...
lock2.release()
lock1.release()
def thread2():
lock2.acquire()
lock1.acquire() # 等待 thread1 释放 lock1
# ...
lock1.release()
lock2.release()
避免死锁的方法:
- 按固定顺序获取锁(如先锁 1 后锁 2)。
- 使用
acquire(timeout)
设置超时,超时后释放已获锁。 - 用
threading.Timer
检测并打破死锁(复杂场景)。
2. 性能问题:过度使用锁的代价
锁会降低并发效率,尤其是在高频访问的临界区。优化建议:
- 缩小临界区范围(仅保护必要代码)。
- 用更轻量的机制(如
queue.Queue
内置线程安全,可减少手动锁)。 - 对 CPU 密集型任务,优先考虑多进程(规避 GIL)。
3. 区分线程锁与进程锁
- 线程锁(
threading
模块):仅在同一进程内的线程间有效。 - 进程锁(
multiprocessing.Lock
):需跨进程同步时使用(如多进程操作共享文件),基于操作系统底层机制实现。
五、总结
Python 的锁机制是并发编程的核心工具,其核心目标是解决共享资源的竞争问题。本文介绍了 5 种常用锁类型:
-
Lock
:基础互斥锁,适用于简单临界区保护。 -
RLock
:可重入锁,适合递归或同一线程多次加锁场景。 -
Semaphore
:控制并发数量,适用于资源有限的场景。 -
Event
:线程间简单通知,适用于状态同步。 -
Condition
:灵活的线程协作,适用于复杂同步(如生产者 - 消费者)。
实际开发中,需根据场景选择合适的锁,并注意避免死锁和性能问题。同时,要理解 GIL 对多线程的影响 ------ 在 CPU 密集型任务中,多进程往往是更好的选择。
掌握锁机制,能让你在 Python 并发编程中更从容地处理资源竞争,写出安全、高效的代码。