第 17 章 多线程与多进程实战
点题:搞清 GIL 这道墙,知道 CPU 密集该上进程、IO 密集该上线程,并学会用 concurrent.futures 把并发当工具而不是负担。
老哥,你在 Go 里写并发,是不是闭着眼就 go func() 一把梭?一个请求进来,起十个 goroutine 去拉下游、去算账,调度器帮你把核铺满,基本不用操心「这个活儿到底占不占 CPU」。那我问你:如果我把这套心智直接搬进 Python,开一百个 threading.Thread 去跑一个密码破解、一个视频转码,你猜它能跑满你八核机器吗?
老哥:……不能?我听人说过 Python 有个什么 GIL。
墨叔:对,GIL 就是这道墙。但墙也不是什么活都挡。这章我们把这件事说透:什么时候该用线程、什么时候必须换进程、怎么用高级封装少写样板、进程之间又怎么说话。你带着 Go 那套 goroutine+channel 的镜子来照,会照得很清楚。
一、threading 到底是什么,怎么用
先说清楚 GIL 是什么。GIL 是 Global Interpreter Lock,全局解释器锁。CPython 里,任何一条字节码要执行,都得先拿到这把锁。也就是说,同一时刻,一个进程里只有一个线程在真正跑 Python 字节码。注意措辞:是「一个线程在跑 Python 字节码」,不是「一个线程在跑」——这点后面很关键。
那线程还有个屁用?有。因为 GIL 不是死攥着的。线程在「等 IO」的时候——等网络响应、等磁盘、等数据库——它会主动把 GIL 让出去,别的线程就能趁机进来了。这就像一家餐厅只有一个厨师(GIL),但点单、上菜、结账这些「等外部」的活儿,可以由多个服务员轮流干;真正「炒菜」这件事,同一时间只能一个人掌勺。所以你知道结论了吧:
- IO 密集(大部分时间在等网络/磁盘):多线程有用,能显著提速。
- CPU 密集(大部分时间在算):多线程基本没用,甚至因锁竞争更慢。
最小可运行例子,一个并发抓下游接口的场景(真实项目里把 time.sleep 换成 requests.get 即可):
import threading
import time
def fetch_user(uid: int) -> None:
# 真实项目:resp = requests.get(f"https://api/internal/user/{uid}", timeout=3)
time.sleep(0.2) # 模拟 200ms 网络往返
print(f"uid={uid} done")
threads = [threading.Thread(target=fetch_user, args=(uid,)) for uid in range(10)]
for t in threads:
t.start()
for t in threads:
t.join() # 等最后一个线程收工start() 是点火,join() 是等它回来。十个线程并发等网络,总耗时约 0.2 秒而不是 2 秒——这就是 IO 密集下多线程的价值。
老哥:那 print 在子线程里不会乱套吗?
墨叔:标准输出本身是带锁的,不会把两行字符揉在一起(虽然多进程写同一文件另说)。但顺带一句,多线程最坑的不是 print,是共享可变状态。你在 Go 里被 go run -race 训练出了「共享内存要用锁或 channel」的本能,这点到了 Python 一字不差:多个线程改同一个 list、同一个 dict,照样要 threading.Lock()。Python 没有 Go 那种编译期帮你盯 race 的工具,所以更得靠自觉。
from threading import Lock
counter_lock = Lock()
hit_count: dict[str, int] = {}
def record(name: str) -> None:
with counter_lock: # 等同 Go 的 mu.Lock()/Defer 释放
hit_count[name] = hit_count.get(name, 0) + 1with 锁等于 Go 的 mu.Lock() 加 defer mu.Unlock(),异常也会自动释放——这点跟我们第 5 章讲的上下文管理器是一回事。
二、什么场景该用它,对照 Go 怎么选
现在拿 Go 当镜子。Go 的 goroutine 是 M:N 调度,GMP 模型,几十万个 goroutine 分摊到操作系统线程上,底层没有一把全局锁挡着,所以不管是算还是等,开 goroutine 基本都能吃到多核。Python 的线程被 GIL 卡在 CPU 上,这是两者最本质的差异。
所以决策树是这样的:
- 活儿主要是等(HTTP 调用、DB 查询、文件读写):Python 用
threading或asyncio都行;对照 Go 就是直接go。 - 活儿主要是算(解析大报文、图像/音视频处理、加解密、数值计算):Python 线程救不了你,必须
multiprocessing起多个进程,每个进程有自己独立的 GIL,才能真正并行吃满核;对照 Go 仍然是go,因为 Go 没有 GIL 这层限制。 - 活儿是算 + 等混着:把「算」丢进进程池,「等」用线程或异步,各司其职。
这就是第 15 章那张决策树落地的样子。你现在该明白为什么说「Python 并发要先问 GIL」,而 Go 不用——因为 Go 生来就没有这道墙。
老哥:那 multiprocessing 是不是就完美平替 goroutine 了?
墨叔:差得远。进程比 goroutine 重太多了。一个 goroutine 初始栈才几 KB,随便起几万个;一个 Python 进程默认就吃掉几十 MB 内存,启动还要 fork/copy,通信得走队列或管道,序列化开销实打实。所以 Python 里「能不开进程就不开」,一旦要开,通常走进程池复用一个固定数量的进程,而不是来一个活儿 fork 一个——后面第三节的 ProcessPoolExecutor 就是干这个的。
三、用它的收益与代价,以及高级封装
先说为什么 GIL 存在。C 扩展(NumPy、OpenSSL 这些)大多不是线程安全的,GIL 让它们可以放心地假设「同一时刻只有一个 Python 线程在动」,省掉了海量加锁。代价就是上面说的 CPU 并行受限。这是历史包袱换来的简单与生态,不是设计偷懒。所以别骂 GIL,要理解它是「用单核并发的上限,换了 C 生态的繁荣」。
日常写并发,别再裸用 Thread / Process 了。标准库 concurrent.futures 给了两个高级封装:线程池和进程池,把「提交任务—拿结果」这件事变得跟调用函数差不多。
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_user(uid: int) -> tuple[int, float]:
time.sleep(0.2)
return uid, uid * 1.0 # 模拟返回 (uid, 余额)
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(fetch_user, uid) for uid in range(20)]
for fut in as_completed(futures): # 谁先完成先处理谁
uid, balance = fut.result()
print(f"uid={uid} balance={balance}")as_completed 像 Go 里 for range ch 收多个 goroutine 的结果,谁先好先收谁。pool.submit 返回一个 Future,调 .result() 拿到返回值,异常也会在这里抛出——比你自己管 list 收集干净太多。
CPU 密集换进程池,接口一模一样:
import math
from dataclasses import dataclass
from concurrent.futures import ProcessPoolExecutor
@dataclass
class Order:
order_id: str
amount: float
def risk_score(order: Order) -> tuple[str, float]:
# 模拟 CPU 密集:对金额做大量数值运算
acc = 0.0
for _ in range(300_000):
acc += math.log1p(abs(order.amount) + 1.0)
return order.order_id, acc
orders = [Order(f"o{i}", float(i)) for i in range(12)]
with ProcessPoolExecutor(max_workers=4) as pool:
for oid, score in pool.map(risk_score, orders):
print(f"{oid} -> {score:.2f}")pool.map 保序返回,对应 Go 里「等一组 goroutine 全部算完」。注意 risk_score 和它引用的 Order 必须能被 pickle 序列化——进程间传参靠的是 pickle,所以闭包捕获的局部变量、lambda 不一定能跨进程,这点比 goroutine 传值严格得多。
进程间通信。线程共享内存,进程不共享。要通信得走专门的通道:
from multiprocessing import Process, Queue
def worker(task_q: Queue, result_q: Queue) -> None:
for n in iter(task_q.get, None): # 读到 None 作为“毒丸”退出
result_q.put(n * n)
if __name__ == "__main__":
tasks, results = Queue(), Queue()
procs = [Process(target=worker, args=(tasks, results)) for _ in range(3)]
for p in procs:
p.start()
for i in range(9):
tasks.put(i)
for _ in range(3):
tasks.put(None) # 每个 worker 发一个毒丸
for _ in range(9):
print(results.get())
for p in procs:
p.join()Queue 就是 Python 版的 channel,但它是基于管道+锁的,跨进程安全,且自带「毒丸」式的退出约定。if __name__ == "__main__" 是 Windows 上多进程的硬性要求——因为 Windows 靠 spawn 重新导入模块,没有这层保护会递归起进程。Linux 默认 fork 没那么娇气,但为了跨平台可移植,永远写上。
如果你要共享的是「一个可变字典/列表」,用 Manager:
from multiprocessing import Manager, Process
def fill(shared: dict, key: str, val: int) -> None:
shared[key] = val
if __name__ == "__main__":
with Manager() as mgr:
registry = mgr.dict()
procs = [Process(target=fill, args=(registry, f"k{i}", i)) for i in range(5)]
for p in procs: p.start()
for p in procs: p.join()
print(dict(registry))Manager 背后是起了一个独立的管理进程,所有访问都走 RPC,方便但慢且有单点。所以能传值就别传共享状态,这点跟 Go 「尽量不要共享内存,用 channel 通信」的格言异曲同工。
四、一个贴近项目的例子
给你一个真实场景:风控服务要跑一批历史订单,流程是「拉用户画像(IO)→ 算风险分(CPU)→ 汇总写库(IO)」。IO 部分用线程池并发,CPU 部分用进程池并行,串起来像这样:
import time, math
from dataclasses import dataclass
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
@dataclass
class Order:
order_id: str
user_id: int
amount: float
def fetch_profile(user_id: int) -> dict:
time.sleep(0.05) # 模拟下游接口延迟
return {"user_id": user_id, "vip": user_id % 3 == 0}
def compute_risk(order: Order, profile: dict) -> float:
base = order.amount * (1.5 if profile["vip"] else 1.0)
acc = 0.0
for _ in range(200_000): # 模拟 CPU 密集评分
acc += math.log1p(base + 1.0)
return acc
def pipeline(orders: list[Order]) -> dict[str, float]:
# 第一层:并发拉画像
with ThreadPoolExecutor(max_workers=8) as io:
profiles = list(io.map(lambda o: fetch_profile(o.user_id), orders))
# 第二层:并行算分
with ProcessPoolExecutor(max_workers=4) as cpu:
scores = list(cpu.map(compute_risk, orders, profiles))
return {o.order_id: s for o, s in zip(orders, scores)}
if __name__ == "__main__":
orders = [Order(f"o{i}", i, float(i * 10)) for i in range(20)]
result = pipeline(orders)
print(f"评分完成,共 {len(result)} 笔")老哥:这个例子我悟了——IO 用线程、CPU 用进程,分层串起来就是 Python 版的流水线。
墨叔:对,而且注意它没用 async,纯多线程多进程就把事办了。很多 Go 工程师一听并发就只想到协程,其实 Python 的线程/进程池对「一批独立任务」这种场景,写起来比 async 还直白。什么时候该上 async?那是第 16、17 章异步生态的范畴,当你的任务是「海量等待中的连接」而不是「一批固定任务」时再考虑。
最后给你一句 senior 的心法:选并发模型前先问自己两个问题——「这个活主要在等还是在算?」「结果要保序还是谁快谁先?」想清楚这两个,threading / multiprocessing / asyncio 的取舍自然就出来了,而不是上来就 go func 式地无脑并发。
五、把 Python 并发和 Go 再照一遍镜子,以及几个致命坑
老哥,你大概率会冒出一个念头:「既然多线程被 GIL 卡、多进程又这么重,那我是不是该把所有并发都换成 multiprocessing,一劳永逸?」
墨叔:千万别。我见过有人拿进程池去并发发一百个 HTTP 请求,结果进程启动 + pickle 传参 + 收结果的开销,比单线程顺序发还慢。记住:进程池是给 CPU 密集准备的重型武器,拿来打蚊子只会砸自己的脚。IO 密集请老老实实用线程或异步。
再给你几个会要命的坑,都是血泪教训:
第一,忘了 join 主线程就退出了。主进程/主线程结束时会直接带走所有非守护线程,你起的线程可能活活被掐死在半路。所以 start() 之后务必 join() 等它们收工,或者把线程设成需要等待的模式。这跟 Go 里 wg.Wait() 等一组 goroutine 是一个道理——只是 Python 不会编译报错提醒你,漏了就静默丢活儿。对照一下:Go 的 go func() 若主函数 main 直接 return,goroutine 也会被杀,但 Go 社区有 sync.WaitGroup 这种一等公民工具;Python 这边全靠你自觉 join。
第二,死锁来自「满队列」。用 Queue 做进程间通信时,如果生产方往里塞、消费方没及时取,队列塞满后 put 会阻塞,两边互相等待就死锁了。正确做法是控制队列长度、或用 put_nowait + 异常处理。这跟 Go 里「向已满的无缓冲 channel 写会阻塞」是同一类问题,Go 的 select+default 能优雅兜底,Python 这边你得自己设计退出条件(比如我们第三节用的「毒丸」None)。
第三,进程池别在池里又开池。在 ProcessPoolExecutor 的 worker 内部再起子进程,很多平台(尤其 Windows spawn)会直接挂掉或极慢。并发层级要摊平,不要嵌套。
第四,守护线程(daemon)慎用。设了 daemon=True 的线程会随主线程「说走就走」,不执行清理、不 flush、不 finally。它适合「可有可无的后台心跳」之类场景,绝不适合「正在写文件、正在提交事务」这种需要干净收尾的活儿。Go 没有 daemon 线程这个概念,goroutine 都是平等公民,所以需要你自己用 context 取消来协调退出——两种模型思维不同,但「关键活儿必须确保收尾」这条原则没有区别。
最后给你一张对照表,贴在你工位上:
| 你的意图 | Go 怎么做 | Python 怎么做 |
|---|---|---|
| 等网络/磁盘(IO 密集) | go + 等 channel | ThreadPoolExecutor 或 asyncio |
| 算密集型(吃满多核) | go(天生多核) | ProcessPoolExecutor / multiprocessing |
| 等一组任务全完成 | sync.WaitGroup | executor.map / future.result() 后 join |
| 任务间传数据 | chan | multiprocessing.Queue |
| 共享可变状态 | sync.Mutex / atomic | threading.Lock(仅同进程内) |
| 请求级上下文透传 | context.Context | contextvars |
你会发现,除了「CPU 密集必须换进程」这道坎,其余心智几乎能一一映射。把这表吃透,你从 Go 切到 Python 并发就不会再「凭感觉开 goroutine」了。
【思考题】
- 你们服务里有个接口,单次请求要串行调用三个内部 RPC(各约 80ms),QPS 不高但 P99 很慢。用 Python 重写时,你会用线程池、进程池还是 async?为什么?如果其中一个 RPC 是本地 CPU 密集的特征计算(约 300ms),你的方案会变吗?
- 用 multiprocessing 的 Queue 在 Windows 上跑,为什么必须把「启动进程的入口逻辑」放在
if __name__ == "__main__":下面?Linux 上 fork 和 Windows 上 spawn 的本质区别是什么? - 进程池里的 worker 函数引用了一个模块级的全局配置字典(比如各种阈值),worker 能直接读到最新值吗?如果主进程在提交任务前修改了这个字典,子进程会看到修改吗?为什么,又该怎么正确传进去?
【参考答案】
- 三个 RPC 都是 IO 等待(各 80ms),总串行约 240ms,瓶颈在「等」。正确做法是并发发起这三个调用——Python 里线程池(
ThreadPoolExecutor提交三个fetch,用as_completed或一次性map)就够,因为 GIL 在等网络时会释放,三路并发能把总耗时压到约 80ms。这里不该用进程池,因为没有任何 CPU 密集计算,起进程反而增加 fork 与 IPC 开销,得不偿失;也不必上 async,任务数量固定、逻辑简单,线程池代码更直白。若其中一个 RPC 其实是本地 CPU 密集计算(300ms),那它不能放进线程池(GIL 会让它独占解释器、拖慢并发),应当把这段计算单独丢进ProcessPoolExecutor,而另外两个真正的网络 RPC 仍用线程池并发;也就是「算的交给进程、等的交给线程」的分层做法,和第四节的例子同构。 - Windows 没有 fork 系统调用,多进程靠 spawn:父进程会重新启动一个 Python 解释器、重新 import 你的模块来重建子进程环境。如果你把「启动进程」的代码写在模块顶层(不在
if __name__ == "__main__":保护下),子进程一 import 这个模块就会再次执行启动代码,从而无限递归地产生进程,直接崩掉。放进if __name__ == "__main__":下,是因为被 spawn 重新导入时__name__不是"__main__",这段启动逻辑不会执行,只在最原始的父进程里跑一次。Linux 默认 fork:子进程是父进程的完整内存副本,不会重新 import 模块,所以顶层写启动逻辑通常也能跑;但 fork 有「只复制被写入的页、但可能复制半个锁状态」等坑(比如父进程持有一个 C 库锁时 fork 出的子进程会死锁)。结论:无论什么平台,多进程启动代码一律包在if __name__ == "__main__":里,这是可移植性的铁律。 - 不会看到。原因正是上面说的:多进程(尤其 spawn/fork 后的写时复制)下,子进程有独立的内存空间。即使
fork在创建瞬间复制了父进程的内存,父进程在 fork 之后(也就是ProcessPoolExecutor已经起好 worker 之后)再修改全局字典,子进程的副本并不会自动同步——它们之间没有共享内存。结果就是 worker 读到的是它自己那一份旧值,甚至可能是进程池创建那一瞬间的快照。正确做法:把配置作为参数显式传入 worker(通过pool.map(worker, items, itertools.repeat(config))或functools.partial),让它在提交任务时随参数 pickle 进子进程;或者用一个Initializer(ProcessPoolExecutor(initializer=init_fn, initargs=(config,)))在每个 worker 启动时注入一次。永远不要依赖「改了父进程的全局变量、子进程自动生效」这种错觉——进程之间,传值优先于共享状态。