K 的一隅

Python Python 语言核心

GIL、I/O 与 CPU:线程和进程各走哪条路

CPython 的 GIL 如何限制 CPU 并行;threading 适合 I/O 等待、multiprocessing 适合 CPU 密集;与 asyncio 的分工边界。

9 分钟阅读 更新于 2026-07-28
本文目录

你写了一个脚本,把十张图片各自 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 任务,汇总结果:

python
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 真正并行占用多核。

multiprocessingProcessPoolExecutor 把任务分到子进程:

python
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。

python
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 加锁更不易漏:

python
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.QueueManager;语义类似,但对象必须可 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」稳得多。