第三章 多线程编程:threading模块详解、线程安全与锁机制
各位同学,今天我们来聊聊多线程。说实话,我在刚入行那会儿,对多线程是又爱又怕。爱的是它能榨干CPU的每一滴性能,怕的是那些莫名其妙的死锁和竞态条件。有一次线上服务半夜挂了,查了半天发现是线程间共享变量没加锁——嗯,从那以后我对锁机制就格外上心了。
3.1 threading模块核心组件
Python的threading模块,说白了就是帮我们管理线程的瑞士军刀。我个人习惯把线程分为两类:工作线程和守护线程。工作线程干完活就退,守护线程则陪着主进程到最后一刻。
核心类一览:
Thread:线程对象,你直接new它就行Lock:互斥锁,一次只让一个线程进RLock:可重入锁,同一个线程可以多次获取Condition:条件变量,线程间通信用的Event:事件标志,简单粗暴的同步方式
来看个最基础的例子:
import threading
import time
def worker(name):
print(f"线程 {name} 开始工作")
time.sleep(2)
print(f"线程 {name} 结束工作")
# 创建线程
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))
# 启动线程
t1.start()
t2.start()
# 等待线程结束
t1.join()
t2.join()
print("所有线程执行完毕")
你想想看,如果不用join(),主线程可能直接跑完了,子线程还在后台默默干活。我在项目中就吃过这个亏——主线程退出了,子线程的日志还没写完,排查问题的时候一脸懵。
3.2 线程安全与锁机制
线程安全是个老生常谈的话题。为什么需要锁?因为多个线程同时读写共享数据时,会出现数据竞争。举个例子:
counter = 0
def increment():
global counter
for _ in range(100000):
counter += 1
threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"期望值: 1000000, 实际值: {counter}")
跑一下你会发现,实际值永远小于1000000。为什么会这样?因为counter += 1在底层是三步操作:读取、加1、写回。线程A读到counter=5,还没来得及写回,线程B也读到了5,然后两个线程都写回6——明明加了两次,结果只加了1。
避坑指南:我曾经在压测时发现QPS上不去,查了半天发现是锁粒度太大。记住:锁的范围越小,并发性能越好。能用局部变量就别用全局变量,能用原子操作就别用锁。
3.2.1 Lock vs RLock
Lock是基础互斥锁,一个线程获取后,其他线程只能等着。但有个坑——同一个线程不能重复获取同一个Lock,否则会死锁。
RLock(可重入锁)就灵活多了。同一个线程可以多次获取,内部有个计数器,获取一次加1,释放一次减1,减到0才真正释放。
| 特性 | Lock | RLock |
|---|---|---|
| 同一线程重复获取 | ❌ 会死锁 | ✅ 可以 |
| 性能 | 略高 | 略低(有计数开销) |
| 适用场景 | 简单互斥 | 递归调用、嵌套锁 |
看个RLock的典型用法:
lock = threading.RLock()
def outer():
with lock:
print("外层锁获取")
inner()
def inner():
with lock:
print("内层锁获取——同一个线程,没问题")
# 如果这里用Lock,inner()会直接卡死
outer()
我个人建议:除非你非常确定不会嵌套获取锁,否则直接用RLock更安全。我在重构一个遗留系统时,就因为把RLock换成了Lock,导致线上递归调用直接死锁——那次教训让我记住了:不要为了那点性能牺牲安全性。
3.3 死锁的避免
死锁,说白了就是线程们互相等着对方释放资源,结果谁也动不了。经典的死锁四条件:
- 互斥:资源一次只能被一个线程占用
- 持有并等待:线程拿着一个锁,还想要另一个
- 不可剥夺:锁只能由持有者主动释放
- 循环等待:线程A等B,B等A
实战技巧:我曾经在写一个交易系统时,设计了统一的锁获取顺序——所有线程都按「账户ID从小到大」的顺序获取锁。这样就不会出现A等B、B等A的循环了。你想想看,如果所有线程都先拿小ID的锁,再拿大ID的锁,死锁自然就破了。
来看一个会死锁的例子:
lock_a = threading.Lock()
lock_b = threading.Lock()
def thread_1():
with lock_a:
print("线程1获取锁A")
with lock_b:
print("线程1获取锁B")
def thread_2():
with lock_b:
print("线程2获取锁B")
with lock_a:
print("线程2获取锁A")
# 启动后大概率死锁
t1 = threading.Thread(target=thread_1)
t2 = threading.Thread(target=thread_2)
t1.start()
t2.start()
避免死锁的几个实用方法:
- 固定锁顺序:所有线程按相同顺序获取锁
- 使用超时:
lock.acquire(timeout=5),拿不到就放弃 - 尽量少用锁:能用队列就用队列,能用原子操作就用原子操作
- 锁粒度要小:别一把锁锁住整个系统
嗯,这里要特别提一下超时机制。我在一个爬虫项目中,用try-finally配合超时,确保即使拿不到锁也不会卡死整个线程池:
if lock.acquire(timeout=3):
try:
# 临界区代码
pass
finally:
lock.release()
else:
print("获取锁超时,跳过本次操作")
3.4 实战建议
最后,分享几个我在高并发项目中的经验:
- 能用
concurrent.futures就别手写线程:它帮你管理了线程池和异常处理 - IO密集型用多线程,CPU密集型用多进程:Python的GIL决定了这点
- 锁的粒度要细,但别太细:太细了性能提升有限,反而增加复杂度
- 写单元测试时一定要模拟并发:用
threading.Barrier让线程同时出发,更容易暴露问题
总结一句话:多线程编程的核心不是「怎么创建线程」,而是「怎么安全地共享数据」。锁是工具,不是目的。能用无锁数据结构解决的问题,就别上锁。
好了,这一章的内容就到这里。下一章我们会聊聊线程池与异步编程,到时候我会分享一个用asyncio重构爬虫的真实案例——那个项目让我对并发编程的理解又上了一个台阶。