在并发编程中,多个线程或进程同时访问共享资源时,容易出现数据不一致、逻辑混乱等问题(即 "竞态条件")。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 并发编程中更从容地处理资源竞争,写出安全、高效的代码。