跳到正文

08 并发与异步编程 ​

一句话:并发的收益来自「等待时让 CPU 干别的」,所以先判断瓶颈是 I/O 还是 CPU;CPython 的 GIL 让纯 Python 计算无法靠线程并行,3.13+ 的 free-threading 构建取消了 GIL,但它是可选构建,不是默认行为。

适用:CPython 3.11+。free-threading 为 3.13 实验性、3.14 起官方支持的独立构建;版本行为以官方文档为准。

为什么需要 ​

一个请求要等模型 API 2 秒,十个请求串行就是 20 秒;一个纯计算任务占满 CPU,开四个线程却几乎不提速。两者都叫「并发」,但瓶颈不同:I/O 密集的线程在等待时会释放 GIL,其他线程可以继续跑;CPU 密集的线程要抢同一把 GIL,只能轮流执行。选错模型,代码更复杂、速度不变,还多出锁和序列化问题。

核心机制 ​

GIL:CPython 的全局解释器锁 ​

GIL 保证同一时刻只有一个线程执行 Python 字节码。它主要保护 CPython 的解释器状态和引用计数,不是语言规范的一部分。

  • I/O 等待时释放:文件、网络、time.sleep 等阻塞调用会让出 GIL,所以多线程对 I/O 有效。
  • 部分 C 扩展会释放:NumPy、PyTorch 的大块计算可能在 C 层释放 GIL,是否释放取决于具体算子,不能假设。
  • 不会让复合操作原子:x += 1 是读-改-写三步,多线程仍可能丢更新,必须加锁。

free-threading:取消 GIL 的可选构建 ​

PEP 703 在 3.13 引入实验性的 free-threaded 构建(python3.13t / --disable-gil);PEP 779 在 3.14 将其列为官方支持的构建,但仍不是默认解释器。要点:

  • 用独立二进制运行,例如 python3.14t;sys._is_gil_enabled() 可查看当前是否启用 GIL(3.13+)。
  • 纯 Python CPU 密集任务可以用线程真正并行,但单线程性能可能有回退,内存占用也可能上升。
  • C 扩展必须声明自己支持无 GIL 运行;未适配的扩展可能重新启用 GIL 或无法加载。升级前要验证依赖链,不要假设「换个解释器就自动变快」。
  • 即使适配,仍要加锁:没有 GIL 不等于没有数据竞争,共享可变状态依然需要同步。用 python3.14t -c "import sys; print(sys._is_gil_enabled())" 查看当前状态。

多线程:适合 I/O,不适合纯计算 ​

线程共享内存,通信便宜;代价是竞态。多个线程修改同一结构时用 threading.Lock 保护临界区,或改用 queue.Queue 做生产者/消费者。GIL 不替代锁。线程池写法见下面的最小上手。

多进程:适合纯 Python CPU 计算 ​

每个进程有独立解释器和独立 GIL,能真正并行。代价是进程启动、序列化、内存复制。

python
from concurrent.futures import ProcessPoolExecutor

def cpu_work(n: int) -> int:
    return sum(i * i for i in range(n))

if __name__ == "__main__":          # Windows/macOS 的 spawn 启动方式需要
    with ProcessPoolExecutor(max_workers=4) as pool:
        print(list(pool.map(cpu_work, [2_000_000] * 4)))

函数和参数必须可 pickle;传大数组会复制,可能吃掉并行收益。大数据考虑 multiprocessing.shared_memory 或把计算下沉到 C 库。

asyncio:单线程协作式并发 ​

事件循环在单线程里轮流推进协程;协程执行到 await 时挂起,把控制权交还循环,I/O 就绪后再恢复。没有线程切换开销,适合成百上千个并发 I/O。

python
import asyncio

async def task(name: str, delay: float) -> str:
    await asyncio.sleep(delay)      # 必须用异步版本
    return name

async def main() -> list[str]:
    return await asyncio.gather(task("a", 0.2), task("b", 0.2))

print(asyncio.run(main()))          # ['a', 'b'],总耗时约 0.2s

关键语法:

概念写法说明
定义协程async def f():调用返回协程对象,不立即执行
让出await x挂起当前协程
并发等待asyncio.gather(*coros)等全部完成;一个失败默认不影响其他
结构化并发async with asyncio.TaskGroup()3.11+,一个失败取消整组
超时async with asyncio.timeout(30):3.11+;旧代码用 wait_for
限流asyncio.Semaphore(5)控制同时进行的请求数
包装阻塞调用await asyncio.to_thread(fn, ...)3.9+,把同步函数丢进线程

最大的坑是同步阻塞:time.sleep、同步 requests、CPU 循环都会卡住整个事件循环,所有协程一起变串行。异步代码里只能用异步库,或用 to_thread 隔离。

选择判据 ​

任务特征首选原因
纯 Python CPU 计算ProcessPoolExecutor;依赖都支持时可用 free-threading绕过 GIL,真正并行
C 扩展计算(NumPy/PyTorch)先用库自身的并行;再考虑线程大算子可能释放 GIL,库内通常已多线程
几十个 I/O 请求ThreadPoolExecutor代码简单,阻塞库也能用
成百上千个 I/O 连接asyncio单线程调度,内存和切换开销低
混合负载进程池做 CPU,asyncio 做 I/O,分层隔离不要把 CPU 计算放进事件循环
任务很短或并发很小串行并发本身有启动和协调开销

先测量再选:time.perf_counter() 对比串行/并发,cProfile 看热点,py-spy 看线程/进程在等什么。

最小上手 ​

保存为 concurrency.py 后运行,两个耗时都应接近 0.2 秒:

python
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor

def io_task(i: int) -> int:
    time.sleep(0.2)
    return i

async def coro(i: int) -> int:
    await asyncio.sleep(0.2)
    return i

async def main() -> list[int]:
    return await asyncio.gather(*(coro(i) for i in range(8)))

if __name__ == "__main__":
    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=8) as pool:
        print(list(pool.map(io_task, range(8))))
    print("threads", round(time.perf_counter() - start, 2))

    start = time.perf_counter()
    print(asyncio.run(main()))
    print("async", round(time.perf_counter() - start, 2))

生产动作 ​

  • 线程池默认值:3.8+ ThreadPoolExecutor 默认 min(32, (os.cpu_count() or 1) + 4);ProcessPoolExecutor 3.12 默认 os.cpu_count(),3.13 起与 os.process_cpu_count() 相关。默认值随版本变化,以官方文档为准。
  • 进程池:Windows/macOS 用 spawn,入口必须 if __name__ == "__main__":;打包成可执行文件时调用 multiprocessing.freeze_support();参数和返回值尽量小。
  • asyncio:每个请求设超时(asyncio.timeout);用 Semaphore 限流;asyncio.run(main(), debug=True) 打开调试;不要在协程里调用 asyncio.run。
  • free-threading:先确认 sys._is_gil_enabled()、依赖是否有兼容 wheel、单线程性能是否可接受;不要在生产默认切换。
  • 排障三段:
    • 现象:多线程跑 CPU 不提速 → 命令:sys._is_gil_enabled()、对比单线程/多线程耗时 → 判断:是否纯 Python 计算且仍在 GIL 构建上。
    • 现象:asyncio 并发全变串行 → 命令:搜索 time.sleep、requests、同步 IO、CPU 循环 → 判断:是否阻塞了事件循环。
    • 现象:Windows 上进程池卡住/递归创建进程 → 命令:检查是否有 __main__ 保护 → 判断:spawn 重新导入了主模块。

边界 ​

  • GIL 不是安全边界:它不防止数据竞争,也不隔离恶意代码;锁和不可变数据仍是必需的。
  • 线程不是只对 I/O 有用:如果 C 扩展释放 GIL,线程也能并行计算;前提是可验证。
  • asyncio 不会让 CPU 计算变快:它只减少等待浪费;CPU 密集仍要进程或 free-threading。
  • 多进程不是免费的:内存、启动、序列化、跨平台启动方式都有成本;小任务用进程池可能更慢。
  • free-threading 不是默认替代品:3.13/3.14 都是独立构建,生态兼容性和性能要实测,不能把「无 GIL」当成无条件收益。

讲清楚 ​

并发的第一步不是选 API,而是判断瓶颈:I/O 等待多,就让等待重叠;CPU 计算满,就换并行执行单元。CPython 的 GIL 让线程只能轮流执行字节码,所以纯 Python 计算要用多进程;I/O 等待时线程会释放 GIL,所以几十个请求用线程池最简单,成百上千个连接用 asyncio 更省资源。3.13 起有 free-threading 可选构建,3.14 起官方支持,但它不是默认解释器,C 扩展兼容性和单线程性能都要实测。asyncio 最大的坑是同步阻塞卡死事件循环;进程池最大的坑是 Windows 的 spawn 和序列化开销。

适用 CPython 3.11+(free-threading 见 3.13/3.14) | 初稿 2026-08,2026-10 重写

把一门技术讲透到既懂也会用