主题
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);ProcessPoolExecutor3.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 重新导入了主模块。
- 现象:多线程跑 CPU 不提速 → 命令:
边界
- 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 重写