跳转至

Python 并发:GIL、线程与进程

Python 的并发选型比 C++(见C++ 并发笔记)简单得多——因为 GIL 替你砍掉了一半选项。核心是搞清楚什么时候用线程、什么时候用进程、什么时候上异步。

GIL:全局解释器锁

CPython 里同一时刻只有一个线程在执行 Python 字节码。这意味着:

  • 多线程无法让 CPU 密集代码变快(甚至更慢,因为抢锁);
  • 但IO 等待时会释放 GIL(socket/read/sleep 等)——所以多线程对 IO 密集任务依然有效;
  • C 扩展(numpy、hashlib、zlib)可以在计算时主动释放 GIL,所以"调库算"也常常吃得到多核。
import sys
print(sys.version)        # 3.13+ 可选 free-threading 构建(见文末)

选型决策表

任务类型 首选 原因
IO 密集,量大(几百上千连接) asyncio 单线程万级并发,切换开销最小
IO 密集,量小 / 依赖同步库 threading(线程池) 简单,同步库直接用
CPU 密集 multiprocessing(进程池) 每进程独立 GIL,真并行
CPU 密集但是纯数组计算 numpy / C 扩展 底层释放 GIL + 向量化,比进程池更值

threading:线程与线程池

from concurrent.futures import ThreadPoolExecutor, as_completed

def fetch(url: str) -> int:
    ...  # requests.get(url).status_code

urls = [f"https://example.com/{i}" for i in range(50)]

# 线程池:IO 任务的默认答案
with ThreadPoolExecutor(max_workers=10) as pool:
    futures = {pool.submit(fetch, u): u for u in urls}
    for fut in as_completed(futures):        # 谁先完成先处理谁
        print(futures[fut], fut.result())

# 一行版:
with ThreadPoolExecutor(10) as pool:
    results = list(pool.map(fetch, urls))    # 顺序对应输入

需要锁的场景(共享可变状态)与 C++ 相同的套路:

import threading

counter = 0
lock = threading.Lock()

def bump() -> None:
    global counter
    for _ in range(100_000):
        with lock:                # += 不是原子操作
            counter += 1

threads = [threading.Thread(target=bump) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)                    # 400000(没锁就是随机值)

先想想能不能不加锁

用 queue.Queue 传递数据(生产者-消费者)、或每线程独立数据最后合并,比共享+加锁可靠得多。Python 里锁的开销也大——GIL 本身就是一把大锁。

multiprocessing:真并行

from concurrent.futures import ProcessPoolExecutor
import time

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

if __name__ == "__main__":        # ← Windows/spawn 模式下【必须】有这行!
    nums = [10_000_000] * 4

    t0 = time.perf_counter()
    with ProcessPoolExecutor() as pool:
        results = list(pool.map(cpu_heavy, nums))
    print(f"4 进程: {time.perf_counter() - t0:.2f}s")   # 理想 ≈ 串行/核数,实际含启动开销

    t0 = time.perf_counter()
    serial = [cpu_heavy(n) for n in nums]
    print(f"串行:   {time.perf_counter() - t0:.2f}s")   # ≈ 4 倍

Windows 用户的头号坑:if __name__ == '__main__'

Windows 创建进程用 spawn(不是 fork):子进程会重新 import 主模块。没有 main 守卫,import 时又创建进程 → 无限递归启动,直接报错:

RuntimeError: An attempt has been made to start a new process before the
current process has finished its bootstrapping phase.

规则:进程相关代码全部包进 main 守卫;传给子进程的函数/数据必须可 pickle(lambda、嵌套函数、打开的文件句柄都不行)。

进程间通信

import multiprocessing as mp

if __name__ == "__main__":
    q = mp.Queue()

    def producer():
        for i in range(5):
            q.put(f"item-{i}")
        q.put(None)                       # 结束哨兵

    p = mp.Process(target=producer)
    p.start()
    while (item := q.get()) is not None:
        print(item)
    p.join()
    # 共享内存:mp.Value('i', 0)、mp.Array('d', 100);大数据量先想想 pickle 开销

进程间所有数据都走 pickle 序列化——传 1GB 的 DataFrame 来回搬,省下的计算时间可能全赔进去。方案:让每个子进程自己读数据、或用共享内存、或干脆 numpy。

concurrent.futures:统一接口

线程池和进程池 API 完全一致,切换只换一个类名——先用线程池写,测出 CPU 瓶颈换进程池:

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def run(pool_cls):
    with pool_cls(max_workers=8) as pool:
        return list(pool.map(task, items))

run(ThreadPoolExecutor)     # IO 密集
run(ProcessPoolExecutor)    # CPU 密集

亲手量一次:线程 ≠ 并行

# 同一份 CPU 任务,4 线程几乎不比串行快,4 进程 ≈ 4 倍
import time, threading
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def burn(_):
    return sum(i * i for i in range(2_000_000))

if __name__ == "__main__":
    t0 = time.perf_counter(); [burn(i) for i in range(4)]
    print(f"串行:   {time.perf_counter()-t0:.2f}s")
    t0 = time.perf_counter()
    with ThreadPoolExecutor(4) as p: list(p.map(burn, range(4)))
    print(f"4 线程: {time.perf_counter()-t0:.2f}s")     # ≈ 串行(GIL)
    t0 = time.perf_counter()
    with ProcessPoolExecutor(4) as p: list(p.map(burn, range(4)))
    print(f"4 进程: {time.perf_counter()-t0:.2f}s")     # ≈ 串行 / 4

(这段在本机 Python 3.13 实测:串行 1.63s、4 线程 1.67s、4 进程 0.81s——线程与串行几乎一样慢,GIL 实锤;进程约 2 倍而不是理论的 4 倍,差距是 spawn 启动开销与整机负载。)

任务要够大才配用进程池

Windows 的 spawn 启动一个进程要重导入模块(实测约 0.1–0.3s/个)。任务太小(毫秒级)时启动开销会吃光并行收益甚至倒赔——本篇 benchmark 初版用 150 万元素/任务,进程版反而比串行慢,放大到 800 万才体现优势。细碎任务留在单进程里批处理,或改用线程/异步。

常见坑清单

坑 说明
Windows 下没写 main 守卫 spawn 重新 import → 递归起进程,见上文
进程池传 lambda/闭包 不可 pickle,PicklingError;改成模块级函数
多线程改共享变量不加锁 += 等复合操作不是原子的
子进程异常被吞 future.result() 会重新抛出——记得取结果
daemon 线程写文件 主进程退出 daemon 线程被硬杀,写到一半
进程池里再开进程池 嵌套 spawn 容易死锁/爆炸,避免
用多线程跑 CPU 任务求提速 GIL:没用,换进程或 numpy

3.13+:free-threading 展望

PEP 703 的免 GIL 构建(python3.13t)已可实验性使用——多线程终于能真并行,代价是单线程性能折损和生态兼容期。2026 年的现实建议:现有项目照旧按 GIL 心智模型写,等主流发行版与 C 扩展生态跟进再考虑切换。

延伸:IO 量大到线程池都不够时,上asyncio;写完记得配测试与调试里的 pytest 把并发代码钉住。

评论