Python 并发模型:GIL、threading、multiprocessing、asyncio

本文适合谁:有 Java 多线程(ThreadPoolExecutor、CompletableFuture)经验的工程师,想理解 Python 并发模型的开发者。读完本篇,你能为 AI 应用的不同场景选择正确的并发方案。

批量调用 LLM API 是 AI 应用中最常见的性能瓶颈场景之一。假设需要对 1000 条文本并发调用 Embedding API,串行执行需要 500 秒,而正确使用并发后可以压缩到 5 秒以内。但 Python 的并发模型选择不当,可能毫无效果,甚至更慢。

理解 Python 并发的关键,是先理解它的根本限制:GIL。

1.1 Python 的 GIL:为什么多线程跑不了真并行

在这里插入图片描述

threading、multiprocessing、asyncio 的适用场景与 GIL 影响对比

GIL(Global Interpreter Lock,全局解释器锁)是 CPython(Python 最常用的官方解释器实现)中的一把互斥锁(同一时刻只允许一个线程持有的锁),确保同一时刻只有一个线程在执行 Python 字节码(字节码:Python 源代码被编译后的中间指令,由解释器逐条执行)。

这意味着:即便创建了 8 个线程、运行在 8 核 CPU 上,CPU 密集型任务也无法并行执行——8 个线程轮流持有 GIL,实际上等同于单线程串行。

import threading
import time

def cpu_bound_task(n: int) -> int:
    """CPU 密集型:纯计算,GIL 不会释放"""
    result = 0
    for i in range(n):
        result += i * i
    return result

# 实验:单线程 vs 多线程执行 CPU 密集型任务
N = 10_000_000

start = time.time()
cpu_bound_task(N)
cpu_bound_task(N)
serial_time = time.time() - start

start = time.time()
t1 = threading.Thread(target=cpu_bound_task, args=(N,))
t2 = threading.Thread(target=cpu_bound_task, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
thread_time = time.time() - start

print(f"Serial:    {serial_time:.2f}s")
print(f"2 Threads: {thread_time:.2f}s")  # 几乎一样甚至更慢(线程切换开销)

GIL 的例外:当线程执行 IO 操作(网络请求、文件读写、sleep)时,GIL 会被释放。因此多线程对 IO 密集型任务有效——一个线程等待网络响应时,其他线程可以运行。

调用 LLM API 本质上是网络 IO,这正是多线程和 asyncio 在此场景下有效的原因。

1.2 三种并发模型适用场景对照

否,纯计算

少量,逻辑复杂

大量,IO 为主

任务类型判断

是否涉及 IO 等待?

是否需要共享内存?

并发任务数量?

multiprocessing.Pool
+ shared memory

multiprocessing
ProcessPoolExecutor

threading
ThreadPoolExecutor

asyncio
asyncio.gather

适合:并发 LLM API 调用
10-50 并发

适合:高并发 LLM API 调用
50-500 并发

适合:数据预处理
numpy/pandas 批处理

并发模型 GIL 影响 适用场景 线程/进程开销 通信方式
threading 受限(IO 任务有效) IO 密集:LLM API 调用、文件读写 共享内存
multiprocessing 无限制 CPU 密集:numpy 计算、数据预处理 Queue/Pipe/SharedMemory
asyncio 受限(单线程事件循环) 大量并发 IO:高并发 API 调用 极低 协程间直接传递

1.3 threading:IO 密集型的简单方案

1.3.1 ThreadPoolExecutor 用法

from concurrent.futures import ThreadPoolExecutor, as_completed
import openai
import time

client = openai.OpenAI()

def call_embedding_api(text: str) -> tuple[str, list[float]]:
    """单次 Embedding API 调用(IO 密集,GIL 在等待时释放)"""
    response = client.embeddings.create(
        input=text,
        model="text-embedding-3-small",
    )
    return text, response.data[0].embedding

def batch_embed_threading(texts: list[str], max_workers: int = 10) -> dict[str, list[float]]:
    """
    使用线程池并发调用 Embedding API。
    max_workers 建议设为 API 并发限制的 70%,避免触发 429(HTTP 状态码 429 Too Many Requests,即请求过于频繁被服务端限流拒绝)。
    """
    results = {}
    errors = []

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        # submit 返回 Future 对象,as_completed 在任意 Future 完成时触发
        future_to_text = {executor.submit(call_embedding_api, text): text for text in texts}

        for future in as_completed(future_to_text):
            original_text = future_to_text[future]
            try:
                text, embedding = future.result()
                results[text] = embedding
            except Exception as e:
                errors.append((original_text, str(e)))
                print(f"Error embedding '{original_text[:30]}': {e}")

    if errors:
        print(f"Completed with {len(errors)} errors out of {len(texts)} total")

    return results


# 性能测试
texts = [f"Sample text number {i}" for i in range(50)]

start = time.time()
embeddings = batch_embed_threading(texts, max_workers=10)
elapsed = time.time() - start
print(f"Embedded {len(texts)} texts in {elapsed:.2f}s ({len(texts)/elapsed:.1f} texts/s)")

1.3.2 线程安全注意事项

import threading
from collections import defaultdict

# 多线程共享状态需要加锁
class ThreadSafeTokenCounter:
    """线程安全的 token 计数器"""

    def __init__(self):
        self._total = 0
        self._by_model: dict[str, int] = defaultdict(int)
        self._lock = threading.Lock()

    def add(self, model: str, tokens: int) -> None:
        with self._lock:  # 确保原子性更新
            self._total += tokens
            self._by_model[model] += tokens

    @property
    def total(self) -> int:
        with self._lock:
            return self._total

    def get_by_model(self) -> dict[str, int]:
        with self._lock:
            return dict(self._by_model)  # 返回副本,避免外部修改

1.4 multiprocessing:CPU 密集型(数据预处理)

1.4.1 ProcessPoolExecutor 用法

from concurrent.futures import ProcessPoolExecutor
import numpy as np
from typing import Any

def preprocess_document(doc: dict) -> dict:
    """
    CPU 密集型:文本预处理(分词、清洗、特征提取)。
    在独立进程中运行,完全绕过 GIL。
    注意:参数和返回值必须可以被 pickle 序列化(pickle:Python 将对象转换为字节流以便跨进程传输的机制,基本类型、列表、字典等都支持,自定义函数等有限制)。
    """
    import re  # 在子进程中重新导入,避免共享状态问题

    text = doc.get("content", "")

    # 模拟 CPU 密集型操作
    cleaned = re.sub(r"[^\w\s]", " ", text.lower())
    words = cleaned.split()

    # numpy 计算(实际场景可能是向量化、TF-IDF 等)
    word_lengths = np.array([len(w) for w in words])

    return {
        "id": doc["id"],
        "word_count": len(words),
        "avg_word_length": float(word_lengths.mean()) if len(word_lengths) > 0 else 0,
        "unique_words": len(set(words)),
    }

def batch_preprocess_multiprocess(
    documents: list[dict],
    max_workers: int | None = None,  # None = CPU 核心数
) -> list[dict]:
    """使用进程池并行预处理文档"""
    with ProcessPoolExecutor(max_workers=max_workers) as executor:
        results = list(executor.map(preprocess_document, documents))
    return results


# 进程间通信:Queue 和 Pipe
import multiprocessing as mp

def producer(queue: mp.Queue, items: list) -> None:
    """生产者:将数据放入队列"""
    for item in items:
        queue.put(item)
    queue.put(None)  # 哨兵值,通知消费者结束

def consumer(queue: mp.Queue, result_list: list) -> None:
    """消费者:从队列取数据处理"""
    while True:
        item = queue.get()
        if item is None:
            break
        result_list.append(item * 2)  # 示例处理

def run_producer_consumer():
    queue = mp.Queue(maxsize=100)
    manager = mp.Manager()
    results = manager.list()  # 进程间共享的列表

    p_producer = mp.Process(target=producer, args=(queue, list(range(10))))
    p_consumer = mp.Process(target=consumer, args=(queue, results))

    p_producer.start()
    p_consumer.start()
    p_producer.join()
    p_consumer.join()

    return list(results)

1.5 concurrent.futures:统一接口,一行切换

concurrent.futures 提供了 ThreadPoolExecutorProcessPoolExecutor 的统一接口,切换底层实现只需修改一行:

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, Executor
from typing import Callable, TypeVar

T = TypeVar("T")
R = TypeVar("R")

def parallel_map(
    func: Callable[[T], R],
    items: list[T],
    max_workers: int = 4,
    use_processes: bool = False,  # True=进程池,False=线程池
) -> list[R]:
    """
    通用并行 map:一行切换线程池和进程池。
    - IO 密集型(LLM API 调用):use_processes=False
    - CPU 密集型(数据预处理):use_processes=True
    """
    ExecutorClass = ProcessPoolExecutor if use_processes else ThreadPoolExecutor

    with ExecutorClass(max_workers=max_workers) as executor:
        return list(executor.map(func, items))

# 使用示例
# IO 密集型:线程池
embeddings = parallel_map(call_embedding_api, texts, max_workers=10, use_processes=False)

# CPU 密集型:进程池
processed = parallel_map(preprocess_document, documents, max_workers=4, use_processes=True)

1.6 asyncio + 线程池:同步代码接入 async 的正确姿势

实际项目中,常遇到需要在 async 函数中调用同步阻塞 API(如旧版 SDK、数据库驱动)的情况。直接调用同步函数会阻塞事件循环,导致整个 async 应用卡住。

正确做法是用 loop.run_in_executor() 将同步调用放到线程池中执行:

import asyncio
from concurrent.futures import ThreadPoolExecutor
import openai

# 同步版 LLM 调用(旧版 SDK 或不支持 async 的库)
sync_client = openai.OpenAI()

def sync_llm_call(prompt: str) -> str:
    """同步阻塞的 LLM 调用"""
    response = sync_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
    )
    return response.choices[0].message.content

# 在 async 上下文中调用同步函数的正确方式
async def async_wrapper(prompt: str) -> str:
    """
    用 run_in_executor 将同步调用放到线程池,
    不阻塞事件循环,其他协程可以继续运行。
    """
    loop = asyncio.get_running_loop()  # 在 async 函数内应使用 get_running_loop()
    # None 表示使用默认线程池(也可以传入自定义 ThreadPoolExecutor)
    return await loop.run_in_executor(None, sync_llm_call, prompt)


# 高并发 asyncio 方案(使用原生 async SDK)
async_client = openai.AsyncOpenAI()

async def async_llm_call(prompt: str) -> str:
    """原生异步调用:最高效"""
    response = await async_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
    )
    return response.choices[0].message.content

async def batch_llm_asyncio(
    prompts: list[str],
    concurrency_limit: int = 20,
) -> list[str]:
    """
    asyncio 并发调用 LLM API,带并发限制避免触发限流。
    Semaphore(信号量)是 asyncio 的并发控制原语,通过计数器限制同时运行的任务数。
    """
    semaphore = asyncio.Semaphore(concurrency_limit)

    async def limited_call(prompt: str) -> str:
        async with semaphore:
            return await async_llm_call(prompt)

    # asyncio.gather 并发执行所有任务,等待全部完成
    tasks = [limited_call(p) for p in prompts]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    # 处理部分失败
    final_results = []
    for i, result in enumerate(results):
        if isinstance(result, Exception):
            print(f"Task {i} failed: {result}")
            final_results.append(None)
        else:
            final_results.append(result)

    return final_results

1.7 实战对比:批量并发调用 LLM API 的最优方案

以下是三种方案在批量 Embedding API 调用场景下的实测性能对比(50 个请求,模拟 100ms 延迟):

import asyncio
import time
from concurrent.futures import ThreadPoolExecutor
import statistics

# 模拟 LLM API 调用(100ms 延迟)
def mock_sync_api_call(text: str) -> str:
    time.sleep(0.1)
    return f"embedding_for_{text[:10]}"

async def mock_async_api_call(text: str) -> str:
    await asyncio.sleep(0.1)
    return f"embedding_for_{text[:10]}"

texts = [f"document_{i}" for i in range(50)]

# 方案一:串行
start = time.time()
results = [mock_sync_api_call(t) for t in texts]
serial_time = time.time() - start
print(f"Serial:     {serial_time:.2f}s")  # ~5.0s

# 方案二:ThreadPoolExecutor
start = time.time()
with ThreadPoolExecutor(max_workers=10) as ex:
    results = list(ex.map(mock_sync_api_call, texts))
thread_time = time.time() - start
print(f"Threading:  {thread_time:.2f}s")  # ~0.5s(10 workers,50/10=5 批次)

# 方案三:asyncio(原生 async API)
async def run_asyncio():
    sem = asyncio.Semaphore(20)
    async def call(t):
        async with sem:
            return await mock_async_api_call(t)
    return await asyncio.gather(*[call(t) for t in texts])

start = time.time()
asyncio.run(run_asyncio())
async_time = time.time() - start
print(f"asyncio:    {async_time:.2f}s")  # ~0.25s(20 并发,50/20≈2.5 批次)

实测数据汇总:

方案 50 请求耗时 100 请求耗时 适用上限 代码复杂度
串行 ~5.0s ~10.0s 极少量 极低
ThreadPoolExecutor(10 workers) ~0.5s ~1.0s ~100 并发
asyncio(20 并发) ~0.25s ~0.5s ~500 并发
asyncio + run_in_executor(同步 SDK) ~0.5s ~1.0s ~100 并发

选型建议

  • 并发请求数 < 20:ThreadPoolExecutor,代码简单,性能够用
  • 并发请求数 20~200:原生 asyncio(使用支持 async 的 SDK)
  • 并发请求数 > 200:asyncio + 合理的 rate limiting,避免 429 错误
  • 有同步阻塞库无法替换:loop.run_in_executor() 桥接

1.8 GIL 的例外:C 扩展库

NumPy、Pandas、PyTorch 等使用 C/C++ 实现的库,在执行 C 代码期间会释放 GIL,因此多线程对这类库也有一定效果,但通常仍不如多进程(进程级别的真并行)。

import numpy as np
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time

def numpy_computation(size: int) -> float:
    """NumPy 操作:C 扩展,GIL 部分释放"""
    arr = np.random.randn(size, size)
    return float(np.linalg.eigvals(arr).real.max())

tasks = [1000] * 4  # 4 个大矩阵特征值计算

# 多线程(NumPy 操作部分绕过 GIL)
start = time.time()
with ThreadPoolExecutor(max_workers=4) as ex:
    results = list(ex.map(numpy_computation, tasks))
thread_time = time.time() - start

# 多进程(完全绕过 GIL)
start = time.time()
with ProcessPoolExecutor(max_workers=4) as ex:
    results = list(ex.map(numpy_computation, tasks))
process_time = time.time() - start

print(f"Threading:   {thread_time:.2f}s")
print(f"Multiprocess:{process_time:.2f}s")
# 对 NumPy 密集型计算,多进程通常比多线程快 20-50%

1.9 小结

Python 并发的选型规则:IO 等待用 asyncio(或 threading),CPU 计算用 multiprocessing

对 AI 应用而言:并发调用 LLM API 优先用支持 async 的 SDK(openai.AsyncOpenAI)+ asyncio.gather + Semaphore 限流;旧版同步 SDK 用 loop.run_in_executor() 桥接,不要直接在 async 函数中调用同步阻塞 IO;数据预处理管道(分词、清洗)用 ProcessPoolExecutor 充分利用多核;无论哪种方案,都要用 Semaphoremax_workers 控制并发上限,避免触发 429。

1.10 AI 应用中并发方案的决策指南

在 AI 应用开发中,面对不同场景,并发方案的选择是高频决策。下面是一个实用的决策框架:

调用 LLM/外部 API

文本预处理/清洗

向量相似度计算

< 20 个并发

20-200 个并发

> 200 个并发

需要并发处理

任务类型

并发数量

multiprocessing
ProcessPoolExecutor

numpy 向量化
不需要并发

threading
ThreadPoolExecutor

asyncio + AsyncOpenAI
asyncio.gather + Semaphore

asyncio + Semaphore
需要配合限流

简单直接
适合批量 embedding

高效
适合 FastAPI 服务

极高并发
注意 API Rate Limit

充分利用多核
适合 RAG 预处理

具体场景对应:

AI 场景 推荐方案 原因
FastAPI 接收多用户并发请求 asyncio(FastAPI 内置) 原生支持,不需要额外配置
批量文档向量化(RAG 建库) ThreadPoolExecutor(10) 代码简单,效果好
批量评估 LLM 输出质量 asyncio + Semaphore(20) 高并发,节省时间
大规模文本清洗(数百万文档) ProcessPoolExecutor CPU 密集,需要多核
单个请求的 prompt 构建 无需并发 计算量小,顺序执行即可
LLM 返回后的 JSON 解析 无需并发 CPU 轻量,顺序执行

1.11 小结

并发模型 关键字/类 适用 AI 场景 与 Java 对比
asyncio async/await, asyncio.gather LLM API 调用、FastAPI 服务 CompletableFuture(但单线程)
threading ThreadPoolExecutor 批量 Embedding、轻量并发 ThreadPoolExecutor
multiprocessing ProcessPoolExecutor 文档预处理、数据清洗 ForkJoinPool
向量化(numpy) @, np.dot 向量相似度计算 Java Stream 并行流

选型口诀:等网络用 asyncio,算数据用进程,简单并发用线程,矩阵运算用 numpy。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐