第2章:多线程实战:threading模块详解,线程创建与管理,线程同步机制
多线程,说白了就是让程序同时干多件事。Python 的 threading 模块,是我们做并发最常用的工具之一。我刚开始用的时候也踩过不少坑,今天把这些经验掰开揉碎了讲给你听。
2.1 线程的创建与管理
Python 里创建线程有两种方式:直接实例化 Thread 类,或者继承 Thread 重写 run 方法。我个人习惯用第一种,代码更简洁。
2.1.1 直接创建线程
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() 方法。如果不调用 join(),主线程不会等子线程,程序可能直接退出了。我在项目中遇到过好几次这种问题——日志还没写完,进程就挂了。
2.1.2 继承 Thread 类
当你需要在线程里维护状态时,继承方式更合适。
class MyThread(threading.Thread):
def __init__(self, name, delay):
super().__init__()
self.name = name
self.delay = delay
def run(self):
print(f"{self.name} 启动")
time.sleep(self.delay)
print(f"{self.name} 完成")
threads = [MyThread(f"线程-{i}", i) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
我的建议:如果只是执行一个函数,用第一种方式;如果需要在线程中保存多个属性或复杂状态,用继承方式。别为了炫技而过度设计。
2.2 线程的生命周期
一个线程从创建到结束,会经历几个状态。我画了张图帮你理解:
你想想看,线程从 start() 开始,进入就绪队列等待 CPU 调度。拿到 CPU 时间片就开始运行,如果遇到 I/O 操作或者锁,就进入阻塞状态。等条件满足了,又回到就绪队列排队。这就是线程的完整生命周期。
2.3 线程同步机制
多线程最头疼的问题是什么?数据竞争。多个线程同时读写同一个变量,结果就乱套了。我在生产环境遇到过线上订单金额被多线程改错的情况,从那以后我对同步机制格外重视。
2.3.1 Lock(互斥锁)
Lock 是最基本的同步工具。它保证同一时刻只有一个线程能访问共享资源。
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100000):
lock.acquire()
counter += 1
lock.release()
threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"最终结果: {counter}") # 应该输出 1000000
我曾经踩过的坑:忘记在
finally 块中释放锁,导致死锁。建议用上下文管理器 with lock:,自动释放,省心又安全。
# 推荐写法
def increment():
global counter
for _ in range(100000):
with lock:
counter += 1
2.3.2 RLock(可重入锁)
RLock 和 Lock 的区别是什么?说白了,RLock 允许同一个线程多次获取锁,而 Lock 不行。我举个例子你就明白了:
lock = threading.Lock()
rlock = threading.RLock()
# Lock 会死锁
def bad_example():
with lock:
with lock: # 这里会阻塞,因为锁已经被自己占用了
print("永远不会执行到这里")
# RLock 没问题
def good_example():
with rlock:
with rlock: # 同一个线程可以重复获取
print("RLock 允许重入")
什么时候用 RLock?当你的函数有递归调用,或者一个函数调用另一个需要同一把锁的函数时。我在写缓存更新逻辑时经常用到 RLock。
2.3.3 Semaphore(信号量)
Semaphore 控制同时访问资源的线程数量。比如数据库连接池,最多允许 5 个线程同时连接。
import threading
import time
# 最多允许3个线程同时访问
semaphore = threading.Semaphore(3)
def access_db(thread_id):
with semaphore:
print(f"线程 {thread_id} 获取数据库连接")
time.sleep(2)
print(f"线程 {thread_id} 释放数据库连接")
threads = [threading.Thread(target=access_db, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
我的经验:Semaphore 的初始值就是最大并发数。如果设为 1,效果等同于 Lock。我在做爬虫限速时经常用 Semaphore 控制并发请求数,防止被目标网站封 IP。
2.4 三种同步机制对比
| 特性 | Lock | RLock | Semaphore |
|---|---|---|---|
| 可重入 | 否 | 是 | 否 |
| 控制粒度 | 单线程独占 | 单线程独占(可重入) | 多线程并发(上限可控) |
| 典型场景 | 保护简单共享变量 | 递归函数、嵌套调用 | 连接池、限流 |
| 死锁风险 | 高(忘记释放) | 低(同一线程可重入) | 中(需注意 acquire/release 配对) |
2.5 实战避坑指南
- 锁的粒度要小:只锁必要的代码段,别把整个函数都锁住。我见过有人把整个 HTTP 请求都放在锁里,性能直接崩了。
- 避免死锁:多个锁嵌套时,保证所有线程按相同顺序获取锁。我曾经因为锁顺序不一致,排查了整整一个下午。
- 用
with语句:自动管理锁的获取和释放,代码更清晰,也减少出错概率。 - 不要滥用线程:Python 的 GIL 限制了 CPU 密集型任务的并行能力。I/O 密集型任务用线程效果更好。
核心要点:线程同步不是银弹。能用不可变数据结构解决的问题,就别加锁。锁越多,性能越差,死锁风险越高。先想清楚共享什么、怎么共享,再动手写代码。