Python 并发与并行完全指南:从单线程到多核的演进之路

先回答一个根本性问题:为什么 Python 有这么多并发方案?

如果你从 Java 过来,你可能会困惑——Java 就是 Thread + ExecutorService + CompletableFuture 一条路走到底,为什么 Python 搞出了 threadingmultiprocessingasyncioconcurrent.futures、甚至 concurrent.interpreters 这么多东西?

原因在于 Python 有一个 Java 没有的历史包袱:GIL(Global Interpreter Lock)。这把锁让 Python 的多线程无法真正并行执行 CPU 计算,所以 Python 社区不得不发展出多种方案来绕过或解决这个限制。每种方案各有适用场景,不是互相替代关系,而是互相补充。

本文会从最底层的概念开始,逐步讲清楚每种方案是什么、为什么存在、适合什么场景、以及未来会怎么演进。


第一章:并发与并行——两个不同的事情

这两个词在日常对话中经常混用,但在编程领域它们有精确的含义,搞混了后面所有东西都会糊。

并发(Concurrency)

并发是指多个任务在重叠的时间段内推进,但不一定在同一时刻执行。

延续上次的厨师比喻:一个厨师同时做三道菜。他不是真的同时切三种菜,而是——切完鱼放一边腌着,去炒青菜,青菜下锅后趁空档去调酱汁。三道菜在同一个时间段内都在推进,但任何一个瞬间厨师只在做一件事。

并发的核心价值是高效利用等待时间。它特别适合 I/O 密集型任务——网络请求、文件读写、数据库查询。这类任务大部分时间都在等外部响应,CPU 其实很闲。

并行(Parallelism)

并行是指多个任务在同一时刻真的同时执行,这需要多个执行单元(多个 CPU 核心)。

还是厨房比喻:你雇了四个厨师,每人一个灶台,真的同时在炒四道菜。这是物理意义上的同时执行。

并行的核心价值是缩短计算时间。它特别适合 CPU 密集型任务——数据处理、科学计算、图像渲染。这类任务几乎没有等待,CPU 一直在忙,唯一的加速方法就是多个核心一起算。

Java 对比

Java 的 Thread 天然支持并行——JVM 的线程是操作系统级线程,多个线程可以跑在不同核心上,真正同时执行 Java 字节码。所以在 Java 里,多线程既是并发的工具也是并行的工具。

Python 因为 GIL 的存在,情况复杂得多:threading 只能做并发不能做并行(对于 Python 代码而言),要做并行得用 multiprocessing 或其他方案。这就是为什么 Python 的并发工具箱比 Java 复杂。


第二章:GIL——理解 Python 并发一切问题的钥匙

GIL 是 CPython 解释器里的一把全局互斥锁。它的规则极其简单:同一时刻,整个 Python 进程中只有一个线程可以执行 Python 字节码

GIL 为什么存在

CPython 使用引用计数(reference counting)来管理对象的内存生命周期。每个 Python 对象内部都有一个计数器,记录"有多少个引用指向我"。当计数归零,对象立即被释放。

a = [1, 2, 3]    # 列表对象的引用计数 = 1
b = a             # 引用计数 = 2
del a             # 引用计数 = 1
del b             # 引用计数 = 0 → 对象被释放

这个引用计数的加减操作不是原子的。如果两个线程同时修改同一个对象的引用计数——比如线程 A 在执行 del a 让计数减一,线程 B 同时在做 c = a 让计数加一——就会产生竞态条件。最坏的情况是计数错误地变成零,对象被提前释放,而还有引用指向它的线程就会访问到已释放的内存,导致段错误。

GIL 用最粗暴的方式解决了这个问题:既然对引用计数的操作不安全,那就保证任何时刻只有一个线程在跑 Python 代码,这样引用计数的修改天然是串行的,不需要对每个对象单独加锁。

GIL 带来的后果

对 I/O 密集任务几乎没有影响。 当线程执行 I/O 操作(比如 socket.recv())时,CPython 会在进入系统调用前主动释放 GIL,让其他线程有机会运行。等 I/O 完成后再重新获取 GIL。所以多线程做网络爬虫、并发 HTTP 请求之类的任务,效果是正常的。

对 CPU 密集任务是灾难性的。 如果多个线程都在做纯 Python 计算(比如数学运算、列表操作),它们会不断争抢 GIL,在微观上变成串行执行。更糟糕的是,GIL 的获取和释放本身有开销,所以多线程做 CPU 密集任务有时甚至比单线程更慢。

用 Java 的视角来理解:想象 JVM 有一把全局锁,任何线程执行任何一行 Java 代码前都必须先获取这把锁。那么即使你有十个线程跑在十个核心上,实际上同一时刻只有一个线程在执行 Java 代码。这就是 GIL 对 Python 的效果。

GIL 的补充说明

有一个经常被忽略的细节:GIL 只锁 Python 字节码的执行。如果 C 扩展在执行纯 C 代码时主动释放了 GIL,那么这段 C 代码可以和其他线程真正并行。这就是为什么 NumPy 的矩阵运算虽然从 Python 层面调用,但实际上可以利用多核——因为 NumPy 的核心计算是在 C 层面执行的,而且它会释放 GIL。


第三章:Python 的四种并发/并行方案

理解了 GIL 之后,Python 的四种方案就各归其位了。

方案一:threading——在 GIL 下的协作式多线程

threading 模块提供操作系统级线程。每个线程是真实的 OS 线程,由操作系统调度。但因为 GIL,同一时刻只有一个线程能执行 Python 字节码。

适用场景:I/O 密集型并发。 比如同时发送 100 个 HTTP 请求、同时读写多个文件、同时等待多个数据库查询返回。在等待 I/O 的时候,GIL 被释放,其他线程可以运行。

不适用场景:CPU 密集型任务。 多线程跑纯 Python 计算不会比单线程快,反而可能更慢。

import threading
import requests

def fetch_url(url):
    response = requests.get(url)
    print(f"{url}: {response.status_code}")

urls = ["https://httpbin.org/delay/1"] * 5

# 创建五个线程,每个线程发一个请求
threads = [threading.Thread(target=fetch_url, args=(url,)) for url in urls]
for t in threads:
    t.start()            # 启动线程
for t in threads:
    t.join()             # 等待所有线程完成

# 五个请求并发执行,总耗时约 1 秒而不是 5 秒
# 因为在等待 HTTP 响应时,GIL 被释放,其他线程可以运行

和 Java 多线程的关键区别: Java 的多线程可以真正并行执行 Java 代码,Python 的 threading 在执行 Python 代码时是伪并行。但两者在处理 I/O 等待方面的效果是类似的。

方案二:multiprocessing——用多进程绕过 GIL

既然 GIL 是进程级别的锁,那最直接的绕过方式就是开多个进程。每个进程有自己的 Python 解释器、自己的 GIL、自己的内存空间。多个进程可以真正并行执行 Python 代码。

适用场景:CPU 密集型并行计算。 数据处理、科学计算、批量图片处理等等。

代价: 进程间不共享内存,数据传递需要序列化(pickle)和反序列化,开销比线程间通信大得多。创建进程本身的开销也比创建线程大。

from multiprocessing import Pool

def heavy_computation(n):
    """一个 CPU 密集的计算"""
    return sum(i * i for i in range(n))

# 创建一个包含 4 个 worker 进程的进程池
with Pool(4) as pool:
    # 把任务分发到 4 个进程并行执行
    results = pool.map(heavy_computation, [10_000_000] * 4)

# 4 个进程各自有独立的 GIL,可以真正并行计算
# 总耗时约为单进程的 1/4(在 4 核 CPU 上)

和 Java 的类比: 这有点像 Java 的 ProcessBuilder 开子进程,不过 Python 的 multiprocessing 封装更友好,API 和 threading 非常相似,切换成本很低。

方案三:asyncio——单线程事件驱动并发

asyncio 是你之前已经学过的那套体系——单线程、事件循环、协程、await。它完全不涉及多线程或多进程,而是在一个线程内通过协作式调度来实现并发。

适用场景:大量 I/O 并发,特别是网络 I/O。 当你需要同时管理成千上万个网络连接时,asynciothreading 更高效,因为没有线程创建/切换的开销,也没有 GIL 竞争。

不适用场景:CPU 密集型任务(除非配合线程池或进程池)。

import asyncio
import aiohttp

async def fetch_url(session, url):
    async with session.get(url) as response:
        return await response.text()

async def main():
    async with aiohttp.ClientSession() as session:
        # asyncio.gather 让多个协程并发执行
        tasks = [fetch_url(session, f"https://httpbin.org/delay/1") for _ in range(100)]
        results = await asyncio.gather(*tasks)
        # 100 个请求并发执行,总耗时约 1-2 秒

asyncio.run(main())

和 Java 的类比: asyncio 在概念上类似于 Java 的 NIO(Non-blocking I/O)和 CompletableFuture,或者更准确地说,类似于 Netty 框架的 EventLoop 模型。Java 21 的 Virtual Threads(Project Loom)在思路上也很接近——大量轻量级并发单元通过协作式调度来实现高效 I/O 并发。

asyncio 与 threading 的本质区别:

threading抢占式的——操作系统决定什么时候切换线程,你的代码在任何位置都可能被打断。这带来了竞态条件的风险,需要用锁来保护共享数据。

asyncio协作式的——只有你显式写了 await 的地方,控制权才会被让出。在两个 await 之间,你的代码不会被任何其他协程打断。这大大降低了并发编程的心智负担——你不需要担心"这行代码执行到一半另一个协程插进来了"。

不过这个优点也是一个限制:如果某个协程在两个 await 之间做了大量计算,所有其他协程都得等着。

方案四:concurrent.futures——统一的高层接口

concurrent.futures 是 Python 对多线程和多进程的高层抽象。它提供了一个统一的 Executor 接口,你可以在 ThreadPoolExecutor(线程池)和 ProcessPoolExecutor(进程池)之间轻松切换,甚至只需要改一行代码。

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def task(n):
    return sum(i * i for i in range(n))

# I/O 密集型 → 用线程池
with ThreadPoolExecutor(max_workers=10) as executor:
    futures = [executor.submit(task, 1000) for _ in range(10)]
    results = [f.result() for f in futures]

# CPU 密集型 → 改一行就切换到进程池
with ProcessPoolExecutor(max_workers=4) as executor:
    futures = [executor.submit(task, 10_000_000) for _ in range(4)]
    results = [f.result() for f in futures]

和 Java 的直接对应: concurrent.futures 就是 Python 版的 java.util.concurrentExecutor 对应 Java 的 ExecutorServiceFuture 对应 Java 的 Future<T>ThreadPoolExecutorProcessPoolExecutor 对应 Java 的 Executors.newFixedThreadPool() 等工厂方法。如果你对 Java 的 ExecutorService 很熟,这个模块你会非常快上手。


第四章:怎么选——决策流程

面对一个具体任务,怎么选择方案?可以用下面这个思考流程。

第一步:判断任务是 I/O 密集还是 CPU 密集。

I/O 密集意味着任务大部分时间在等待外部响应——网络请求、文件读写、数据库查询。CPU 密集意味着任务大部分时间在做计算——数据处理、数学运算、图像处理。很多真实任务是混合的,需要判断瓶颈在哪。

第二步:对于 I/O 密集型任务。

如果并发量不大(几十个),threadingThreadPoolExecutor 就够了,简单直接。如果并发量很大(成百上千个),用 asyncio,它在高并发 I/O 场景下资源消耗更低。但要注意,使用 asyncio 意味着你调用的库也必须支持异步——你需要 aiohttp 而不是 requests,需要 asyncpg 而不是 psycopg2。如果你依赖的库只有同步版本,threading 反而更实际。

第三步:对于 CPU 密集型任务。

multiprocessingProcessPoolExecutor,这是目前最成熟的方案。对于数值计算,优先考虑用 NumPy / Pandas 等库——它们的核心操作在 C 层面执行并释放 GIL,天然可以配合多线程使用。

第四步:对于混合型任务。

可以组合使用。比如一个 Web 应用的请求处理是 I/O 密集的(等数据库、等下游 API),用 asyncio 做主循环。但其中某个步骤需要做 CPU 密集计算,就用 asyncio.to_thread() 把它丢到线程池,或者用 loop.run_in_executor(ProcessPoolExecutor(), ...) 丢到进程池。


第五章:asyncio 和 threading 的实际配合

上一份文档里讲过 iterate_in_threadpool 的原理。这里补充一个更通用的场景:在 asyncio 程序里调用同步阻塞代码。

asyncio.to_thread():最简单的桥接方式

Python 3.9 引入了 asyncio.to_thread(),专门用于在 asyncio 程序中安全地调用同步函数。

import asyncio
import time

def blocking_io():
    """模拟一个同步阻塞的 I/O 操作"""
    time.sleep(2)
    return "data from slow API"

async def main():
    # 错误做法:直接在协程里调用阻塞函数
    # result = blocking_io()  # 这会冻结事件循环 2 秒!

    # 正确做法:把阻塞函数丢到线程池
    result = await asyncio.to_thread(blocking_io)
    # 事件循环不会被阻塞,其他协程可以正常运行

asyncio.run(main())

这背后的原理和 iterate_in_threadpool 完全一样:阻塞操作在线程池里执行,await 等待线程池返回结果,期间事件循环线程不受影响。

loop.run_in_executor():更灵活的版本

如果你需要控制线程池或进程池的具体配置,或者需要用进程池来跑 CPU 密集任务,可以用 run_in_executor()

import asyncio
from concurrent.futures import ProcessPoolExecutor

def cpu_heavy(n):
    """CPU 密集任务"""
    return sum(i * i for i in range(n))

async def main():
    loop = asyncio.get_running_loop()

    # 用进程池执行 CPU 密集任务
    with ProcessPoolExecutor(max_workers=4) as pool:
        result = await loop.run_in_executor(pool, cpu_heavy, 10_000_000)
        print(result)

asyncio.run(main())

这种组合模式在实际工程中非常常见,特别是在 FastAPI / Starlette 这样的异步 Web 框架里。


第六章:三种方案的内部机制对比

为了加深理解,这里从底层机制的角度做一个对比。

threading 的执行模型

当你创建一个 threading.Thread 时,CPython 会创建一个真实的操作系统线程(通过 POSIX pthread 或 Windows Thread API)。多个 OS 线程确实在操作系统层面被调度到不同的 CPU 核心上。但每个线程在执行 Python 字节码之前,必须先获取 GIL。GIL 的获取和释放大约每 5 毫秒轮转一次(在 Python 3.2 之后的实现),确保每个线程都有机会运行。

所以从操作系统的视角看,Python 的多线程是真正的多线程。但从 Python 字节码执行的视角看,它们是被 GIL 串行化的。这就解释了为什么 I/O 操作不受影响(因为 I/O 等待期间 GIL 被释放),而 CPU 计算无法并行。

multiprocessing 的执行模型

当你创建一个 multiprocessing.Process 时,CPython 会 fork(Linux)或 spawn(Windows/macOS)一个全新的操作系统进程。新进程有自己完整的 Python 解释器实例、自己的 GIL、自己的内存空间。多个进程可以真正在不同核心上并行执行 Python 字节码。

代价是进程间通信必须通过 IPC 机制:管道(Pipe)、队列(Queue)、共享内存(shared memory)。数据在进程间传递时需要被序列化(通常用 pickle),接收方再反序列化。对于大量数据传递,这个开销可能很大。

asyncio 的执行模型

asyncio 完全不创建额外的线程或进程。它在一个线程里运行一个事件循环。协程是 Python 层面的轻量级对象,不是 OS 线程。切换协程只需要保存和恢复 Python 栈帧,开销极小(微秒级),而线程切换是操作系统级别的上下文切换(通常在几微秒到几十微秒之间)。

这意味着 asyncio 可以轻松管理上万个并发协程,而创建上万个线程对操作系统来说是很大的负担。


第七章:线程安全和常见陷阱

GIL 不等于线程安全

一个极其常见的误解是"因为有 GIL,Python 多线程不需要考虑线程安全"。这是错的。

GIL 保证的是"同一时刻只有一个线程执行字节码",但一个 Python 语句可能对应多条字节码指令。线程切换可以发生在任意两条字节码指令之间。

import threading

counter = 0

def increment():
    global counter
    for _ in range(1_000_000):
        counter += 1
        # counter += 1 实际上是三步:
        # 1. 读取 counter 的值(LOAD_GLOBAL)
        # 2. 加 1(BINARY_ADD)
        # 3. 写回 counter(STORE_GLOBAL)
        # 线程切换可能发生在这三步的任何间隙

threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)
# 结果几乎不可能是 2,000,000
# 因为两个线程会互相覆盖对方的写入

正确的做法是用锁:

lock = threading.Lock()

def increment_safe():
    global counter
    for _ in range(1_000_000):
        with lock:           # 获取锁
            counter += 1     # 这三步字节码在锁的保护下原子执行
                             # 离开 with 块时自动释放锁

asyncio 的"安全假象"

asyncio 的协作式调度让很多竞态条件消失了——因为在两个 await 之间代码不会被打断。但如果两个协程在 await 前后读写同一个共享状态,竞态条件仍然可能发生:

balance = 100

async def withdraw(amount):
    global balance
    current = balance        # 读
    await asyncio.sleep(0)   # 这里让出控制权!另一个协程可能插进来
    balance = current - amount   # 写——但 current 可能已经过期了

# 如果两个协程同时 withdraw(80),可能都读到 balance=100
# 然后各自写回 20,最终 balance=20 而不是应该的 -60 或拒绝

所以即使在 asyncio 里,涉及"await 前读、await 后写"的模式也需要用 asyncio.Lock 保护。


第八章:Python 并发的未来

Python 的并发生态正在经历近十年来最大的变革。有三个正在推进的方向。

方向一:Free-threaded Python(无 GIL 构建)

这是最受关注的变化。Python 3.13 首次引入了实验性的 free-threaded 构建(PEP 703),在 Python 3.14 中已经脱离实验状态,成为正式支持的构建选项(虽然还不是默认构建)。

Free-threaded Python 的核心改变是移除 GIL,让多线程可以真正并行执行 Python 字节码。为了在没有 GIL 的情况下保证引用计数的安全,CPython 团队实现了一种叫"偏向引用计数"(biased reference counting)的方案——对象的创建者线程用快速的非原子操作更新引用计数,其他线程则用原子操作。同时,内置类型(dict、list、set)内部也加了细粒度锁来保护并发修改。

目前的状态是:free-threaded 构建需要通过安装时的特定选项启用(安装后的可执行文件通常带 t 后缀,比如 python3.14t),它不是默认行为。在 Python 3.14 中,单线程代码的性能损失已经降到大约 5-10%(3.13 时约为 40%,改善巨大)。

对你作为 AI 应用工程师的影响:当 free-threaded Python 成熟后,你将可以在 Python 中直接用多线程实现真正的并行 CPU 计算,而不需要绕道 multiprocessing。这对于 AI 应用中的数据预处理、特征工程等 CPU 密集环节可能有很大帮助。但目前很多第三方库(特别是带 C 扩展的库)还没有完全适配 free-threaded 构建,在生产环境中使用需要谨慎。

方向二:Sub-interpreters(子解释器)

Python 3.14 通过 PEP 734 在标准库中正式引入了 concurrent.interpreters 模块。子解释器是同一个进程中的多个独立 Python 解释器实例——每个子解释器有自己的 GIL、自己的模块状态、自己的 __main__ 命名空间,但共享同一个进程的内存空间。

你可以把子解释器理解为"比线程重一点、比进程轻一点"的并行方案。它和 multiprocessing 类似,都能实现真正的多核并行,但因为在同一个进程内,创建开销更小,通信效率更高。

# Python 3.14+ 的子解释器 API
from concurrent.futures import InterpreterPoolExecutor

def compute(n):
    return sum(i * i for i in range(n))

# 类似 ThreadPoolExecutor 的 API,但每个 worker 跑在独立的子解释器里
with InterpreterPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(compute, [10_000_000] * 4))
    # 四个子解释器各有自己的 GIL,可以真正并行

这个方向的设计思路受到了 Erlang 的进程模型和 Go 的 goroutine 的启发——隔离的执行环境通过消息传递来通信,而不是共享可变状态。

方向三:asyncio 的持续演进

asyncio 本身也在持续改进。Python 3.12 引入了 TaskGroup 来实现结构化并发,Python 3.11 引入了 asyncio.TaskGroup 和异常组(ExceptionGroup)来更好地处理并发任务中的错误传播。未来的方向是让 asyncio 与子解释器更好地配合——比如在 asyncio 事件循环中,把 CPU 密集的子任务分发到子解释器里并行执行。

这三个方向的关系

它们不是互相替代的。更准确地说,未来 Python 的并发体系会是:

asyncio 继续作为 I/O 并发的首选方案,它在管理大量网络连接方面的优势不会改变。

对于 CPU 密集的并行需求,开发者将有三个选择:free-threaded Python 的多线程(最简单,但需要自己管理线程安全)、子解释器(隔离性好,API 友好,通信有一定限制)、multiprocessing(最成熟,但进程间通信开销最大)。

在实际应用中,最可能出现的模式是混合使用:asyncio 管理 I/O,子解释器或 free-threaded 多线程处理 CPU 密集部分。


第九章:总结——一张决策地图

选择方案的简明规则

I/O 密集 + 并发量适中(几十个): 使用 threadingThreadPoolExecutor。简单、直接、大多数库都支持。

I/O 密集 + 并发量大(成百上千): 使用 asyncio。资源消耗更低,但需要异步生态的库支持。

CPU 密集 + 需要并行: 使用 multiprocessingProcessPoolExecutor。目前最成熟的真并行方案。

在 asyncio 中遇到同步阻塞代码: 使用 asyncio.to_thread()loop.run_in_executor() 桥接到线程池或进程池。

Python 3.14+ 且依赖库已适配: 可以开始尝试子解释器(InterpreterPoolExecutor)作为 ProcessPoolExecutor 的更轻量替代。

核心概念速查

GIL 让 Python 多线程无法并行执行 Python 字节码,但不影响 I/O 等待。Free-threaded Python 正在逐步解除这个限制。

并发是多个任务交替推进(一个厨师做三道菜),并行是多个任务同时执行(三个厨师各做一道菜)。

threading 提供并发但不提供并行(对 Python 代码而言)。multiprocessing 提供真正的并行。asyncio 在单线程内提供高效的 I/O 并发。concurrent.futures 是线程池和进程池的统一高层接口。

线程安全不等于 GIL。 多线程修改共享状态仍然需要锁。asyncio 的协作式调度降低了竞态风险,但"await 前读、await 后写"的模式仍然不安全。

async 负责调度协议,线程池/进程池负责隔离阻塞代码。 两者是协作关系,不是替代关系。