线程与进程
到目前为止,我们写的所有程序都活在单线程世界里:代码从上往下、一件接一件地执行。可现实中的任务常常不是这样——你可能要给几百张照片批量生成缩略图,要同时调用十几个外部接口,要把一段视频逐帧处理。单线程只能老老实实排队,一…
到目前为止,我们写的所有程序都活在单线程世界里:代码从上往下、一件接一件地执行。可现实中的任务常常不是这样——你可能要给几百张照片批量生成缩略图,要同时调用十几个外部接口,要把一段视频逐帧处理。单线程只能老老实实排队,一件做完才轮到下一件。
想让程序"同时"做多件事,就要进入并发的世界。这条路有几条岔道:操作系统层面的进程与线程,以及 Python 在用户态玩出的花样——协程。本章先走前两条,把 threading 和 multiprocessing 两件工具用熟;协程背后的引擎——事件循环——留到下一章拆。
1. 先把任务分进两个筐
任何耗时的任务,大致都能归进两个筐里:
- I/O 密集型:时间花在"等"上——等网络响应、等磁盘读写、等数据库返回。CPU 大部分时间其实在闲着;
- CPU 密集型:时间花在"算"上——图像处理、数值计算、压缩加密。CPU 从头到尾满负荷。
任务属于哪一类,直接决定并发方案怎么选。先记一句话垫底:多线程擅长处理等 I/O 的任务,多进程擅长吃满 CPU 的任务,协程则是海量 I/O 任务的终极形态。本章末尾还有一张速查表,到时候回头验证这句话。
2. 进程、线程、协程:三位选手同场对比
并发三兄弟放在一起看,差异一目了然:
| 对比维度 | 🏭 进程 (Process) | 🧵 线程 (Thread) | 🍃 协程 (Coroutine) |
|---|---|---|---|
| 由谁调度 | 操作系统(内核态) | 操作系统(内核态) | 用户态的事件循环 |
| 创建开销 | 最大:独立内存空间、独立文件描述符,创建要走 fork/clone | 中等:共享进程内存,但要有自己的栈(MB 级) | 极小:几 KB 的状态就够了 |
| 切换成本 | 最高:切页表、刷 TLB | 高:要进内核态,几百个 CPU 周期 | 最低:纯用户态,几十个周期 |
| 数据共享 | 天然隔离,只能靠管道、socket 等 IPC | 同一屋檐下共享内存,必须做同步 | 单线程内执行,天然安全 |
| 多核利用 | ✅ 能 | ✅ 能 | ❌ 单线程内不能 |
| 合适数量级 | 十个左右 | 几百上千 | 成千上万 |
| 典型场景 | 需要隔离风险的独立任务 | CPU 计算、同步阻塞 I/O | 大规模 I/O 并发 |
3. threading 上手:把函数扔进另一个线程
最简用法:Thread + start + join
import threading
import time
def compress_photo(name: str, seconds: int):
print(f"图片 {name} 开始压缩")
time.sleep(seconds) # 假装在干活
print(f"图片 {name} 压缩完毕")
t = threading.Thread(target=compress_photo, args=("sunset.jpg", 2))
t.start() # 线程交给操作系统去跑
print("主线程该干嘛干嘛") # 这行会立刻执行,不必等压缩完成
t.join() # 需要结果了,再等它
print("主线程等到收工")
输出:
图片 sunset.jpg 开始压缩
主线程该干嘛干嘛
……(约 2 秒后)
图片 sunset.jpg 压缩完毕
主线程等到收工
三个动作各司其职:start() 把线程交给操作系统调度;join() 让当前线程原地等待,直到对方跑完;夹在中间那行输出的时机,证明了两条线确实是并行的。
面向对象写法:继承 Thread
import threading
import time
class PhotoWorker(threading.Thread):
def __init__(self, filename: str, seconds: int):
super().__init__(name=f"worker-{filename}")
self._seconds = seconds
def run(self) -> None:
print(f"{self.name} 开工,耗时预算 {self._seconds} 秒")
time.sleep(self._seconds)
print(f"{self.name} 完工")
task = PhotoWorker("avatar.png", 1)
task.start()
task.join()
要点只有一个:把任务逻辑写进 run(),start() 会自动调用它。
守护线程:随主线程一起退场
普通线程会"拽住"进程——只要它没跑完,进程就不退出。守护线程(daemon)正相反:主线程一结束,它立刻被强制掐断。
import threading
import time
def heartbeat():
while True:
print("心跳:进程还活着")
time.sleep(1)
monitor = threading.Thread(target=heartbeat, daemon=True)
monitor.start()
time.sleep(3)
print("主线程跑完了,守护线程被一并带走")
提醒:守护线程是"被掐断",不是"善后退出"。写文件、提交事务这类需要完整收尾的操作,千万别放在守护线程里——它可能做到一半就被带走了。
4. 共享内存的代价:竞态条件
线程最方便的地方是大家共享同一块内存,最危险的地方也在这里。多个线程同时读写同一个变量,就可能踩出竞态条件(Race Condition)。
看一个秒杀场景:库存只剩 10 件,两位买家同时下单、各抢 8 件——
import threading
import time
class FlashStock:
def __init__(self, count: int):
self.count = count
def place_order(stock: FlashStock, quantity: int, buyer: str) -> bool:
"""下单——这一版带着竞态漏洞"""
print(f"{buyer} 查库存:还剩 {stock.count} 件")
# 漏洞所在:查库存和扣库存是两步,中间有空子
if stock.count >= quantity:
print(f"{buyer}:库存够,下单中……")
# 模拟生成订单、走支付流程的耗时
# 这 0.1 秒,足够另一个线程钻进来了
time.sleep(0.1)
stock.count -= quantity
print(f"{buyer} ✓ 抢到 {quantity} 件!库存剩 {stock.count} 件")
return True
print(f"{buyer} ✗ 库存不足,手慢了")
return False
stock = FlashStock(10)
buyer1 = threading.Thread(target=place_order, args=(stock, 8, "买家甲"))
buyer2 = threading.Thread(target=place_order, args=(stock, 8, "买家乙"))
print("=== 秒杀开始,两人同时下单 ===")
buyer1.start()
buyer2.start()
buyer1.join()
buyer2.join()
print("\n=== 结算 ===")
print(f"实际库存:{stock.count} 件")
print(f"本应剩下:{10 - 8} = 2 件")
print(f"实际卖出:{10 - stock.count} 件——超卖了!")
一次典型的运行结果:
=== 秒杀开始,两人同时下单 ===
买家甲 查库存:还剩 10 件
买家乙 查库存:还剩 10 件
买家甲:库存够,下单中……
买家乙:库存够,下单中……
买家甲 ✓ 抢到 8 件!库存剩 2 件
买家乙 ✓ 抢到 8 件!库存剩 -6 件
=== 结算 ===
实际库存:-6 件
本应剩下:2 件
实际卖出:16 件——超卖了!```
两个人都"看到"库存有 10 件,都判断充足,都扣了 8 件,最后库存成了 -6。
> 为什么会这样?`stock.count -= quantity` 看着是一行代码,落到 CPU 上是三条指令:**读出** count、**做减法**、**写回** count。线程切换完全可能发生在任意两条指令之间。更麻烦的是,"检查够不够"和"真的扣掉"这两个动作之间,本身就留着一条时间缝。
## 5. Lock:一次只放一个人进房间
`threading.Lock` 是最基础的互斥锁。它把一段临界区变成"单人房间":谁拿到钥匙谁进,其他人一律在门口排队。用它堵住秒杀漏洞:
```python
import threading
import time
class FlashStock:
def __init__(self, count: int):
self.count = count
self.lock = threading.Lock()
def place_order(stock: FlashStock, quantity: int, buyer: str) -> bool:
"""下单——用锁保证线程安全"""
with stock.lock: # 拿钥匙;拿不到的线程在此阻塞
print(f"{buyer} 查库存:还剩 {stock.count} 件")
if stock.count >= quantity:
print(f"{buyer}:库存够,下单中……")
time.sleep(0.1)
stock.count -= quantity
print(f"{buyer} ✓ 抢到 {quantity} 件!库存剩 {stock.count} 件")
return True
print(f"{buyer} ✗ 库存不足,手慢了")
return False
stock = FlashStock(10)
buyer1 = threading.Thread(target=place_order, args=(stock, 8, "买家甲"))
buyer2 = threading.Thread(target=place_order, args=(stock, 8, "买家乙"))
print("=== 秒杀开始,两人同时下单 ===")
buyer1.start()
buyer2.start()
buyer1.join()
buyer2.join()
print("\n=== 结算 ===")
print(f"实际库存:{stock.count} 件") # 2 件,正确
第二个买家进房间时,库存已经是 2,不够 8,只能悻悻而归。with 语句还保证了一件事:房间里的代码无论是正常走完还是中途抛异常,出门时一定把钥匙还回去。
用锁有个新手必踩的坑——自己锁自己:
import threading
lock = threading.Lock()
lock.acquire()
lock.acquire() # 卡死:钥匙还在自己手里,却还想要一次
第一个 acquire 拿到锁还没释放,第二个 acquire 只能干等——可"释放"永远不会发生。这就是死锁的一种最简单形态。
6. RLock:同一扇门可以反复进
RLock(可重入锁)在内部记了一个数:同一个线程每 acquire 一次计数加一,每 release 一次减一,减到零才真正开门。于是"自己锁自己"不再成立——适合递归调用、或同一线程里嵌套加锁的场合:
import threading
rlock = threading.RLock()
def countdown(n: int):
with rlock:
if n > 0:
print(f"倒数 {n}")
countdown(n - 1) # 同一个线程再次拿锁,放行 ✅
countdown(5) # 顺利跑完
7. Semaphore:给房间挂个限流牌
Lock 是"一次一个人",Semaphore(信号量)是"一次 N 个人"。比如机器只有 3 个核能干活,渲染任务却来了 10 个,就得控制同时在跑的最多 3 个:
import threading
import time
permits = threading.Semaphore(3) # 3 个名额
def render_frame(no: int):
with permits: # 占一个名额;满了就在此等待
print(f"第 {no} 号帧开始渲染")
time.sleep(1)
print(f"第 {no} 号帧渲染完成")
threads = [threading.Thread(target=render_frame, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
观察输出会发现:任意时刻最多 3 帧在渲染,一批结束,下一批立刻顶上。
8. Event:线程之间的发令枪
很多时候线程之间需要的不是"互斥"而是"通知":一个线程等某个条件成立,另一个线程负责宣布它成立。Event 就是这把发令枪:
import threading
import time
gun = threading.Event()
def sprinter():
print("选手:已就位,等枪响")
gun.wait() # 阻塞,直到事件被 set
print("选手:枪响了,冲!")
def referee():
print("裁判:各就各位——预备——")
time.sleep(1)
gun.set() # 扣动扳机
print("裁判:比赛开始")
runner = threading.Thread(target=sprinter)
judge = threading.Thread(target=referee)
runner.start()
judge.start()
runner.join()
judge.join()
wait() 是全体听枪,set() 是一声枪响——所有等在 wait() 上的线程同时被唤醒。
9. Queue:生产者和消费者之间的传送带
一个经典架构:一个线程生产数据,若干线程消费数据。要是自己拿列表加锁去实现,要操心的事非常多;标准库的 queue.Queue 直接把同步全做掉了:
import queue
import random
import threading
import time
def scanner(q: queue.Queue):
"""生产者:模拟扫描文件夹,不断发现新图片"""
for i in range(10):
filename = f"IMG_{i:03d}.jpg"
q.put(filename) # 队列满了会自动阻塞,等着消费者来取
print(f"发现图片:{filename}")
time.sleep(random.random())
# 有几个消费者,就投几个"收工"信号
q.put(None)
q.put(None)
def compressor(name: str, q: queue.Queue):
"""消费者:从队列里取图片做压缩"""
while True:
filename = q.get()
if filename is None: # 收到收工信号
q.task_done()
break
print(f" {name} 正在压缩 {filename}")
time.sleep(0.3)
q.task_done()
q = queue.Queue(maxsize=5) # 传送带长度有限,生产太快会被反压
threads = [
threading.Thread(target=scanner, args=(q,)),
threading.Thread(target=compressor, args=("压缩机1号", q)),
threading.Thread(target=compressor, args=("压缩机2号", q)),
]
for t in threads:
t.start()
for t in threads:
t.join()
queue.Queue内部已经处理好了加锁,put/get尽管放心用;maxsize给传送带限了长度,生产者跑得太快时会被自动"反压"。
10. 线程池:造线程不如租线程
线程的创建和销毁并不便宜,任务一多就该用池子——预先造好一批线程反复使用。concurrent.futures.ThreadPoolExecutor 就是现成的线程池:
from concurrent.futures import ThreadPoolExecutor
import time
def make_thumbnail(album: str) -> str:
time.sleep(1) # 模拟读原图、缩放、写盘
return f"《{album}》的缩略图已生成"
albums = ["旅行相册", "毕业纪念", "产品图集", "宠物日常", "深夜食堂"]
with ThreadPoolExecutor(max_workers=3) as pool:
# map:结果顺序与提交顺序严格一致
for result in pool.map(make_thumbnail, albums):
print(result)
# submit:逐个提交,拿到 future 凭条,再凭条取结果
futures = [pool.submit(make_thumbnail, album) for album in albums]
for f in futures:
print(f.result())
map 像内置的 map 一样按序返回结果;submit 返回的是 future 对象——先欠着,活干完了凭它取货。future 是个重要角色,第 20 章会专门请它出场。
11. 线程池的另一个身份:通往异步世界的接口
这里先剧透一点后面的内容:Python 的现代异步体系(协程 + 事件循环,从下一章开始讲)有条铁律——协程里绝不能出现阻塞调用。可现实总免不了要用同步阻塞的库:老牌数据库驱动、某些只提供同步接口的 SDK。这时候线程池就来救场:把阻塞函数扔到别的线程里跑,协程这边挂起等待,互不耽误:
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor
def read_large_file() -> str:
time.sleep(0.5) # 同步读文件,会把所在线程按住
return "大文件内容读取完毕"
async def main():
# 方式一:asyncio.to_thread,最省事的写法
text = await asyncio.to_thread(read_large_file)
print(text)
# 方式二:手动指定一个线程池
loop = asyncio.get_running_loop()
with ThreadPoolExecutor() as pool:
text = await loop.run_in_executor(pool, read_large_file)
print(text)
asyncio.run(main())
两种写法效果一致:阻塞发生在别的线程里,事件循环所在的线程保持畅通。里面的原理,接下来几章会逐步展开。
12. threading.local:给每个线程一个储物柜
共享数据要加锁,那反过来——每个线程想有"只属于自己"的数据,怎么办?threading.local() 造出一排储物柜:各线程读写同名属性,互不可见、互不干扰:
import threading
import time
locker = threading.local()
def serve(window: str):
locker.window = window # 各写各的
locker.handled = 0
for _ in range(3):
locker.handled += 1
print(f"{locker.window}:已处理 {locker.handled} 个请求")
time.sleep(0.1)
threads = [
threading.Thread(target=serve, args=("窗口A",)),
threading.Thread(target=serve, args=("窗口B",)),
]
for t in threads:
t.start()
for t in threads:
t.join()
两个窗口的计数各自从 0 涨到 3,谁也不会把谁的数字改乱——这种数据天生免锁。
13. GIL:多线程头顶的天花板
终于要直面那个绕不开的问题了:CPython 里有个 GIL(Global Interpreter Lock,全局解释器锁),它规定任一时刻、一个进程里只有一个线程能执行 Python 字节码。
听起来多线程好像白搭?做个实验最直观——纯计算任务,双线程对比单线程连跑两遍:
import threading
import time
def pixel_math() -> int:
"""模拟像素级运算——纯 CPU 计算"""
total = 0
for i in range(30_000_000):
total += i * i
return total
# 双线程版本
t1 = threading.Thread(target=pixel_math)
t2 = threading.Thread(target=pixel_math)
start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
print(f"两个线程:{time.time() - start:.2f} 秒")
# 单线程版本
start = time.time()
pixel_math()
pixel_math()
print(f"单线程串行:{time.time() - start:.2f} 秒")
跑完大概率会发现:双线程不但没快,可能还略微慢一点(多了切换开销)。
那 GIL 之下,多线程到底图什么?图"等"。线程遇到 I/O(网络、磁盘)时会释放 GIL,把执行权让给别的线程。所以对 I/O 密集型任务,多线程照样省时间;但对 CPU 密集型任务,Python 多线程用不上多核,纯属白忙。
14. CPU 密集型的正解:多进程
绕开 GIL 的思路很直接:锁的是"一个进程里的线程",那就多开几个进程。每个进程有独立的解释器、独立的 GIL,各占一个核,互不牵制:
from multiprocessing import Process
def pixel_math() -> int:
total = 0
for i in range(30_000_000):
total += i * i
return total
if __name__ == "__main__": # 多进程程序必须加这一行保护
p1 = Process(target=pixel_math)
p2 = Process(target=pixel_math)
p1.start()
p2.start()
p1.join()
p2.join()
API 和 threading.Thread 几乎一模一样,上手零成本。代价是进程更重、启动更慢,进程间通信还得走 IPC,所以别一上来就开一大堆。
15. 死锁:谁也不撒手,谁也走不了
锁用多了会出新毛病。设想办公室只有一台打印机和一台扫描仪,两位同事都要同时用到两台设备,但占用的顺序相反:
import threading
import time
printer = threading.Lock()
scanner = threading.Lock()
def colleague_a():
with scanner: # 甲先占扫描仪
time.sleep(0.1)
with printer: # 再等打印机
print("甲:扫描、打印,一条龙完成")
def colleague_b():
with printer: # 乙先占打印机
time.sleep(0.1)
with scanner: # 再等扫描仪——两人僵住了
print("乙:打印、扫描,一条龙完成")
ta = threading.Thread(target=colleague_a)
tb = threading.Thread(target=colleague_b)
ta.start()
tb.start()
ta.join()
tb.join()
# 程序永远停在这里:
# 甲攥着扫描仪等打印机,乙攥着打印机等扫描仪,谁也等不来
这就是死锁:两个线程各握一把锁,又都在等对方手里的那把。破局的原则简单到一句话——所有线程按同样的顺序拿锁:
import threading
import time
printer = threading.Lock()
scanner = threading.Lock()
def colleague_a():
with scanner: # 统一先拿扫描仪
time.sleep(0.1)
with printer:
print("甲:扫描、打印,一条龙完成")
def colleague_b():
with scanner: # 乙也先拿扫描仪——顺序一致,僵局不再
time.sleep(0.1)
with printer:
print("乙:打印、扫描,一条龙完成")
ta = threading.Thread(target=colleague_a)
tb = threading.Thread(target=colleague_b)
ta.start()
tb.start()
ta.join()
tb.join()
print("两件事都办完了")
顺序统一后,后到的人会在第一把锁处排队,而不是各占一半互相耗着。
16. 选型速查表
| 任务长什么样 | 顺手的工具 |
|---|---|
| CPU 密集(计算、编解码、图像处理) | multiprocessing / ProcessPoolExecutor |
| 同步 I/O 密集(阻塞的文件操作、数据库驱动) | threading / ThreadPoolExecutor |
| 异步 I/O 密集(爬虫、高并发 Web 服务) | asyncio 协程 |
| 协程里必须调用阻塞库 | 用 asyncio.to_thread / run_in_executor 包一层 |
到这里,操作系统层面的并发工具就齐了。但你多半已经注意到:速查表里"异步"一栏反复出现的 asyncio、协程、事件循环,一个都还没拆开——尤其那个贯穿始终的事件循环,它凭什么能用一个线程扛住成千上万的并发任务?下一章,我们就掀开这台发动机的盖子。
下一章:19 · 事件循环