跳至内容

Python 并发调用实战

用线程池组织批量调用和业务聚合,处理结果、异常与超时,再用进程池拆分计算任务。

  • Python
  • 并发
  • 线程池
  • 进程池

独立的 I/O 调用用线程池,可拆分的纯 Python 计算用进程池。常用流程是:提交任务、收集结果、处理异常。

本文使用 ThreadPoolExecutor 与 ProcessPoolExecutor,不涉及 asyncio。

运行环境:Python 3.13,默认启用 GIL 的 CPython。日志示例使用 Loguru,安装命令为 python -m pip install loguru。

1. 先判断任务是否适合并发

任务特点通常考虑主要限制
调用 HTTP 接口、对象存储等,时间主要花在等待上线程池下游容量、连接数和超时
可拆分的纯 Python 计算,时间主要花在执行代码上进程池进程启动、数据传输和内存
任务很少、执行很快,或前后有强依赖顺序执行并发可能增加复杂度和耗时

任务需相互独立。 如果 B 要使用 A 的返回值,就要等 A 完成后再执行 B。

并发不一定更快。 它能重叠 I/O 等待,但也有调度开销,实际收益要测量。

GIL 使同一进程中的多个线程无法同时执行 Python 字节码,因此纯 Python 计算通常考虑进程池。NumPy 等库的部分计算会释放 GIL,这时多个线程也可能利用多核。见 线程与 GIL、NumPy 线程说明。

2. 用线程池组织业务调用

max_workers 限制工作线程数,超出的任务排队等待。

2.1 同一个函数,处理一批参数

批量查询商品价格,用 sleep() 模拟接口等待:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from concurrent.futures import ThreadPoolExecutor
from time import sleep


def fetch_price(product_id, *, currency="CNY"):
    sleep(0.05)
    return {"product_id": product_id, "price": product_id * 10, "currency": currency}


product_ids = [101, 102, 103]
with ThreadPoolExecutor(max_workers=3) as executor:
    futures = [executor.submit(fetch_price, product_id, currency="CNY") for product_id in product_ids]
    results = [future.result() for future in futures]

for result in results:
    print(result)

# 输出:
# {'product_id': 101, 'price': 1010, 'currency': 'CNY'}
# {'product_id': 102, 'price': 1020, 'currency': 'CNY'}
# {'product_id': 103, 'price': 1030, 'currency': 'CNY'}

submit(函数, *args, **kwargs) 返回 Future;result() 等待并取得返回值,任务异常也会在这里抛出。先全部提交,再取结果,避免提交一个就等一个。本例按提交顺序取结果,任务仍并发执行。

2.2 先处理完成的任务,按输入顺序汇总

用 as_completed() 及时处理已完成任务,再通过索引把结果放回原位置:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from concurrent.futures import ThreadPoolExecutor, as_completed
from time import sleep


def fetch_price(product_id, *, currency="CNY"):
    sleep(0.05)
    return {"product_id": product_id, "price": product_id * 10, "currency": currency}


product_ids = [101, 102, 103]
results = [None] * len(product_ids)
with ThreadPoolExecutor(max_workers=3) as executor:
    futures = {executor.submit(fetch_price, item): index for index, item in enumerate(product_ids)}
    for future in as_completed(futures):
        index = futures[future]
        results[index] = future.result()
        # 在这里更新进度或保存该项结果。

print([item["price"] for item in results])

# 输出:[1010, 1020, 1030]
写法结果处理顺序适用情况
按 Future 列表逐项调用 result()输入顺序结果要与输入逐项对应
executor.map(func, inputs)输入顺序同一函数处理多个输入,调用形式统一
as_completed(futures)已完成任务优先及时记录进度、处理失败或保存结果

区别在于何时处理结果,不影响任务本身并发执行。接口说明见 concurrent.futures。

3. 汇总不同调用,保留失败信息

用任务名称关联不同函数,通过 partial() 绑定参数。单项失败记入 errors,其余结果继续收集:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import partial
from time import perf_counter, sleep
from loguru import logger


def run_calls(calls, max_workers=8):
    started = perf_counter()
    results, errors = {}, {}

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {executor.submit(call): name for name, call in calls.items()}
        for future in as_completed(futures):
            name = futures[future]
            try:
                results[name] = future.result()
            except Exception as exc:
                errors[name] = exc
                logger.exception(f"任务失败:{name}")
            else:
                logger.info(f"任务成功:{name}")

    logger.info(f"任务数={len(calls)},成功={len(results)},失败={len(errors)},总耗时={perf_counter() - started:.3f} 秒")
    return results, errors


def fetch_profile(user_id):
    sleep(0.05)
    return {"user_id": user_id, "name": "Lin"}


def fetch_orders(user_id, *, limit=2):
    sleep(0.03)
    return [user_id * 100 + index for index in range(limit)]


def fetch_coupon(user_id):
    raise ConnectionError("优惠服务不可用(模拟)")


calls = {
    "profile": partial(fetch_profile, 7),
    "orders": partial(fetch_orders, 7, limit=2),
    "coupon": partial(fetch_coupon, 7),
}
results, errors = run_calls(calls, max_workers=3)
print(results["profile"])
print(results["orders"])
print(sorted(errors))

# print 输出如下;日志顺序和耗时不固定。
# {'user_id': 7, 'name': 'Lin'}
# [700, 701]
# ['coupon']
  • 成功与失败分开:正常返回的 None、False、0、空列表仍算成功;异常进入 errors,业务错误码由工作函数处理。
  • 调用线程汇总:results 和 errors 只由调用线程更新,不需要额外加锁。
  • 保留诊断信息:logger.exception() 记录堆栈,perf_counter() 测量整批耗时。

这个函数等待全部任务结束。调用方根据 errors 决定是否接受部分结果;写入任务重试前,要确认重复执行不会造成重复写入等问题。

4. 超时:限制的是哪一段等待

层次控制位置实际含义
底层调用超时HTTP 客户端或具体 SDK按库的规则限制连接、读写等等待
取结果超时future.result(timeout=...)限制这次取结果最多等待多久
整批时间预算调用方记录统一截止时刻到期后决定停止等待、返回部分结果或报告失败

4.1 result(timeout) 不会终止任务

future.result(timeout=1) 只限制本次等待最多 1 秒,不限制任务总执行时间。任务已完成时,直接取回结果。

例如 A、B 同时运行 1.5 秒,依次用 1 秒超时取结果:等 A 时超时,随后只需再等约 0.5 秒就能拿到 B。超时后的 A 仍会继续运行。

等待超时不等于任务已经停止。 cancel() 只能尝试取消尚未运行的任务,不能强行结束正在执行的线程。见 Future 的超时与取消。

4.2 超时后为什么还在等

with ThreadPoolExecutor(...) 退出时会等待任务结束,即使已经发生等待超时或其他异常。

shutdown(wait=False) 可以先返回,但运行中的任务会继续,解释器退出时仍会等待它们。见 shutdown()。

先给 HTTP 客户端或 SDK 设置超时。 仅给 Future 设置等待超时,不能解决底层调用一直阻塞的问题。客户端的连接、读取超时也未必是整个请求的时长上限,需看具体库的约定。见 HTTPX 超时说明。

4.3 整批任务共用一个时间预算

“整批最多等 2 秒”需要统一截止时刻,不能给每个 Future 重新分配 2 秒。可用 wait() 收集已完成任务,但它同样不会停止未完成任务。

要按时返回,需由应用继续管理未完成任务使用的线程池和客户端;若返回前还要退出局部线程池的 with,仍会继续等待。

5. 控制并发和资源

  • 线程数不等于 QPS:max_workers 限制同时工作的线程数,不限制每秒请求数。结合下游限流、连接池容量、耗时和错误率调整。
  • 执行上限不等于提交上限:大量 submit() 仍会堆积 Future 和任务数据。大批量任务应分批提交,或限制在途任务数。
  • 多批次会叠加资源:多个请求各建一个线程池,总线程数会随请求量增长。高频调用可在应用进程内复用线程池,并在退出时关闭。
  • 共享对象需确认线程安全:优先让工作函数返回结果,由调用线程汇总;共享客户端是否安全,要看库的约定。
  • 避免池内互等:任务占着线程,等待同一个池中的新任务,可能因线程耗尽而死锁。见 死锁示例。

Python 3.14 起,Executor.map(..., buffersize=...) 可限制已提交但尚未产出结果的任务数;3.13 不支持此参数。见 map()。

6. 用进程池拆分计算任务

进程池适合可拆分的 CPU 任务。下面按文件解析 JSONL 日志,统计各服务的 5xx 数量:子进程接收路径、返回计数,主进程合并。

保存为 process_batch.py 运行;临时演示日志会自动清理:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import json
from collections import Counter
from concurrent.futures import ProcessPoolExecutor
from multiprocessing import get_context
from pathlib import Path
from tempfile import TemporaryDirectory


def count_errors(path):
    counts = Counter()
    with path.open(encoding="utf-8") as source:
        for line in source:
            record = json.loads(line)
            if 500 <= record["status"] < 600:
                counts[record["service"]] += 1
    return counts


def main():
    batches = [
        [{"service": "orders", "status": 200}, {"service": "payments", "status": 502}],
        [{"service": "orders", "status": 500}, {"service": "payments", "status": 503}],
    ]
    with TemporaryDirectory() as directory:
        paths = []
        for index, records in enumerate(batches):
            path = Path(directory) / f"requests-{index}.jsonl"
            path.write_text("".join(json.dumps(record) + "\n" for record in records), encoding="utf-8")
            paths.append(path)

        totals = Counter()
        context = get_context("spawn")
        with ProcessPoolExecutor(max_workers=2, mp_context=context) as executor:
            for counts in executor.map(count_errors, paths):
                totals.update(counts)

    print(dict(sorted(totals.items())))


if __name__ == "__main__":
    main()

# 输出:{'orders': 1, 'payments': 2}

真实使用时,将临时数据替换为文件路径。这个小样例只演示任务组织;解析和计算足够重时才可能抵消进程开销,磁盘读取慢未必适合增加进程。

约束实际写法
子进程需要找到工作函数函数定义在模块顶层,示例保存为脚本执行
启动时可能重新导入模块创建进程池的入口放在 if __name__ == "__main__" 下
任务数据需要跨进程传递传可序列化参数,返回紧凑结果
子进程普通对象不会自动同步到父进程通过参数和返回值交换数据

示例显式使用 spawn 启动子进程。工作函数放在模块顶层,不直接提交局部函数或 lambda;参数和返回值也要支持序列化。见 进程池约束。

少传大对象,多传路径或数据块。 跨进程传输需要序列化,反复复制大对象会增加耗时和内存占用。

评论

使用 GitHub 登录后参与讨论,评论将公开保存在 GitHub。

阅读到这里时加载评论。