本文目录
你写了一个脚本,把十张图片各自 resize 一遍——直觉上「开十个线程应该快十倍」。跑起来却发现处理器占用率顶在单核附近,总耗时和单线程差不多。这不是线程接口坏了,而是 CPython 里还有一个全局互斥锁 GIL:同一时刻只有一个线程在执行 Python 字节码。分清 I/O 等待与 CPU 计算谁占主导,再决定用线程、进程还是前面几篇讲的事件循环,比死记线程池参数名有用。
异步四章讲的是单线程协作式调度;这一篇回到操作系统提供的线程与进程,回答「什么时候该离开 asyncio、改用 Thread 或 Process」。二者不是替代关系:高并发网络服务里 asyncio 往往占主导,但图像处理、科学计算、调用阻塞式驱动时,线程与进程仍是标准工具箱里的选项。
GIL:字节码层面的单车道
GIL(Global Interpreter Lock)是 CPython 解释器里的锁,保护对象引用计数等内部结构。任何线程要跑 Python 代码,必须先拿到 GIL;执行若干字节码或时间片到期后释放,让别的线程有机会抢锁。
可以把 GIL 想成单车道收费站:多辆车都在路上,但过收费站同一时刻只允许一辆。车在后段服务区等待(阻塞 I/O)时,收费站会把路让给别的车;若每辆车都在收费站里磨蹭(纯 Python 算数),多开几条车道也排不上并行。
这意味着:
| 场景 | 多线程的实际效果 |
|---|---|
| CPU 密集(压缩、矩阵运算、纯 Python 循环) | 多线程几乎不能并行算,还可能因抢锁更慢 |
| I/O 密集(读盘、等网络、等数据库) | 线程在等 I/O 时会释放 GIL,其他线程可继续跑 Python 代码 |
所以 GIL 不是「禁止并发」,而是禁止多线程并行执行 Python 字节码。等 socket 的那段时间,别的线程可以干活——这正是 Thread 在 I/O 场景仍值得用的原因。
CPython 保留 GIL 有历史与实现上的权衡:引用计数与大量 C 扩展 API 假设了这种互斥。PyPy、Jython 等实现没有同样的 GIL,但生态与教程默认仍以 CPython 为准;讨论线程是否「有用」,要先限定在 CPython 上。
Thread:共享内存,适合等 I/O
threading 模块里的 Thread 共享同一进程的地址空间:全局变量、对象引用都在一起,传数据便宜,但要自己处理竞态(Lock、Queue 等)。两个线程同时改同一个 list 而不加锁,可能丢元素或读到半更新状态——并发正确性要显式设计。
典型用法:主线程派发多个 I/O 任务,汇总结果:
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.request import urlopen
URLS: tuple[str, ...] = (
"https://example.com",
"https://www.python.org",
"https://docs.python.org/3/",
)
def fetch_size(url: str) -> tuple[str, int]:
with urlopen(url, timeout=10) as resp:
data = resp.read()
return url, len(data)
def main() -> None:
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(fetch_size, url) for url in URLS]
for fut in as_completed(futures):
url, size = fut.result()
print(f"{url} -> {size} bytes")
if __name__ == "__main__":
main()每个 fetch_size 大部分时间在等网络;等待期间 GIL 让出,线程池里别的任务可以推进。若把 fetch_size 换成纯 Python 里算斐波那契,多 Thread 就不会带来线性加速。
Process:独立解释器,绕开 GIL
Process 是操作系统级的进程:各自有独立的 Python 解释器与内存空间,每个进程有自己的 GIL,因此 CPU 密集任务可以用多 Process 真正并行占用多核。
multiprocessing 或 ProcessPoolExecutor 把任务分到子进程:
from __future__ import annotations
from concurrent.futures import ProcessPoolExecutor
def heavy(n: int) -> int:
total = 0
for i in range(n):
total += i * i
return total
def main() -> None:
chunks = (5_000_00, 5_000_00, 5_000_00, 5_000_00)
with ProcessPoolExecutor() as pool:
for value in pool.map(heavy, chunks):
print(value)
if __name__ == "__main__":
main()代价也明确:进程间不共享普通 Python 对象,数据要用 pickle 序列化经管道传递;启动与内存开销比 Thread 大。Windows 上还要注意 if __name__ == "__main__" 守卫,否则 spawn 模式可能递归创建子进程。适合把「一大块算力」切分后各自独立算,而不是频繁传巨大对象。
决策:先问工作是在等还是在算
可以按下面顺序选路:
任务是否大量占用 CPU(纯 Python 计算)?
是 → multiprocessing / ProcessPoolExecutor
否 → 是否大量并发 I/O、且能写成 async/await?
是 → asyncio(单线程事件循环,见系列 23–26)
否 → threading / ThreadPoolExecutor| 机制 | 并行 Python 字节码 | 典型场景 |
|---|---|---|
| Thread | 否(受 GIL) | 阻塞式 I/O、调用已释放 GIL 的 C 扩展 |
| Process | 是(多解释器) | CPU 密集、需隔离崩溃 |
| asyncio | 单线程协作 | 高并发网络 I/O、可 await 的 API |
扩展库若在 C 层长时间计算且释放 GIL(如 NumPy 部分运算、hashlib),多 Thread 也能利用多核——那是 C 代码在并行,不是 Python 字节码在并行。读库文档时留意「是否释放 GIL」比背线程池大小更有用。
与 asyncio 的衔接
系列前面讲过:事件循环在单线程里交错执行协程,适合成千上万连接、每个都在等 I/O。Thread 适合 legacy 阻塞 API(老式数据库驱动、requests)包一层 asyncio.to_thread 或线程池,避免卡住 loop。
import asyncio
async def main() -> None:
result = await asyncio.to_thread(pow, 2, 1000)
print(result)
asyncio.run(main())asyncio.to_thread 把阻塞函数丢进默认 Thread 池,协程 await 完成后再继续——这是并发模型组合,不是二选一。线上常见分工:网络入口用 asyncio,个别阻塞调用 to_thread,重 CPU 段落丢 Process 池或独立 worker 进程。
线程安全:Lock 与 Queue
共享可变状态时,用 threading.Lock 保护临界区,或用 queue.Queue 在线程间传消息,比手写 list 加锁更不易漏:
from __future__ import annotations
import threading
from queue import Queue
results: Queue[int] = Queue()
def worker(n: int) -> None:
total = sum(range(n))
results.put(total)
threads = [threading.Thread(target=worker, args=(1000,)) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
while not results.empty():
print(results.get())Process 侧若需汇总,常用 multiprocessing.Queue 或 Manager;语义类似,但对象必须可 pickle。选 Thread 还是 Process,先看数据要不要跨进程拷贝、有没有共享大数组的需求。
与容器、部署的同一条线
容器里跑 Gunicorn + 多 worker 进程,是 Process 模型在 Web 层的典型用法:每个 worker 独立解释器,崩溃不拖垮全局。单个 worker 里若用线程处理阻塞 I/O,仍受 GIL 约束,但多个 worker 可占满多核。asyncio 入口(Uvicorn async worker)则是单进程内高并发连接,与多 worker 进程可以组合:进程横向扩展,协程纵向并发连接。
排错线程问题时,先问「线程在等什么」:若 strace 或日志显示大量 time spent in read/connect,线程模型合理;若 profile 显示纯 Python 函数占满,应减线程、加进程或换算法。Jupyter 里多线程也常让人困惑,因为内核与用户代码交互特殊;脚本与服务里的结论仍以 CPython GIL 规则为准。
常见误解
| 误解 | 实际 |
|---|---|
| Python 多线程完全没用 | I/O 型任务里 Thread 仍常见 |
| 开线程就能跑满所有 CPU 核做纯 Python 计算 | CPython 里应换 Process |
| asyncio 可以替代一切并发 | 阻塞库、CPU 密集段仍需线程或进程 |
| 线程数等于核数一定最快 | I/O 任务可大于核数;CPU 任务线程过多可能更慢 |
选型时把「并行字节码」和「并行等待 I/O」分开写进设计文档,评审时一眼能看出瓶颈假设。代码里用注释标明「此处线程池因 legacy 阻塞驱动」比裸 ThreadPoolExecutor 好维护。性能回归时用同一套基准脚本对比版本,避免仅凭体感换模型。
怎么验证选路对不对
粗测即可:用 time.perf_counter() 包一层,分别跑单线程、线程池、进程池,看 wall time 与 CPU 利用率。I/O 任务线程池应明显快于串行;纯 Python 循环则进程池才有机会接近核数倍加速。profiler(cProfile)能告诉你是卡在 Python 循环还是在等 I/O,比凭感觉开八个 Thread 靠谱。
profiler 显示热点在 C 扩展且文档写明释放 GIL 时,多线程仍可能有效;若热点在纯 Python for 循环,优先算法、Process 或 Cython/NumPy 向量化,而不是再加线程。Web 服务里「async 入口 + 线程池跑阻塞函数 + 多 worker 进程」三层组合很常见,每层解决不同等待形态,不必强行只留一种。
学习路径上,先掌握 threading 与 multiprocessing 的基本 API,再在 asyncio 项目里用 to_thread 包一层阻塞调用,比一上来混用三种模型更清晰。标准库 concurrent.futures 统一了 Thread 与 Process 池的 submit、map 接口,换模型时改动面更小。记住 GIL 管的是 Python 字节码,不管操作系统线程本身;线程在等 I/O 时操作系统仍可调度其他线程,只是其他线程要等 GIL 才能跑 Python 逻辑。
面试与代码审查里常问「为什么 Python 多线程慢」——准确答法应分场景:CPU bound 慢,I/O bound 不一定。能把 GIL、释放时机、Process 绕开三条说清楚,比背线程池默认大小更有说服力。写服务时把阻塞调用边界标在模块 docstring 或架构图里,后续换 async 或进程池时有据可查。
multiprocessing 在 Linux 上默认 fork,子进程继承父进程内存快照;Windows 用 spawn,必须保护 main 入口。部署到容器时注意 worker 数量与 CPU quota 对齐,进程过多反而上下文切换开销上升。线程池 max_workers 对 I/O 任务可大于核数,对 CPU 任务通常接近核数即可,具体仍要 benchmark 验证。
系列至此把 asyncio 与线程、进程三条并发线都点过:单线程协程、多线程共享、多进程隔离。实际项目里混用很常见,关键是文档化边界与测量瓶颈,而不是追求单一范式。下一批工程主题会讲打包与仓库工具,并发选型与发布流程一样,都是可重复的工程习惯。
收束
GIL 把 CPython 里 Thread 的 CPU 并行挡在门外,但没挡 I/O 并发。Process 用多解释器换真并行,适合算力任务。Thread、Process、asyncio 解决的是不同层的等待与并行;先看清瓶颈在 I/O 还是在 CPU,再选 API,比默认「一律多线程」或「一律 async」稳得多。