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() 模拟接口等待:
| |
submit(函数, *args, **kwargs) 返回 Future;result() 等待并取得返回值,任务异常也会在这里抛出。先全部提交,再取结果,避免提交一个就等一个。本例按提交顺序取结果,任务仍并发执行。
2.2 先处理完成的任务,按输入顺序汇总
用 as_completed() 及时处理已完成任务,再通过索引把结果放回原位置:
| |
| 写法 | 结果处理顺序 | 适用情况 |
|---|---|---|
按 Future 列表逐项调用 result() | 输入顺序 | 结果要与输入逐项对应 |
executor.map(func, inputs) | 输入顺序 | 同一函数处理多个输入,调用形式统一 |
as_completed(futures) | 已完成任务优先 | 及时记录进度、处理失败或保存结果 |
区别在于何时处理结果,不影响任务本身并发执行。接口说明见 concurrent.futures。
3. 汇总不同调用,保留失败信息
用任务名称关联不同函数,通过 partial() 绑定参数。单项失败记入 errors,其余结果继续收集:
| |
- 成功与失败分开:正常返回的
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 运行;临时演示日志会自动清理:
| |
真实使用时,将临时数据替换为文件路径。这个小样例只演示任务组织;解析和计算足够重时才可能抵消进程开销,磁盘读取慢未必适合增加进程。
| 约束 | 实际写法 |
|---|---|
| 子进程需要找到工作函数 | 函数定义在模块顶层,示例保存为脚本执行 |
| 启动时可能重新导入模块 | 创建进程池的入口放在 if __name__ == "__main__" 下 |
| 任务数据需要跨进程传递 | 传可序列化参数,返回紧凑结果 |
| 子进程普通对象不会自动同步到父进程 | 通过参数和返回值交换数据 |
示例显式使用 spawn 启动子进程。工作函数放在模块顶层,不直接提交局部函数或 lambda;参数和返回值也要支持序列化。见 进程池约束。
少传大对象,多传路径或数据块。 跨进程传输需要序列化,反复复制大对象会增加耗时和内存占用。
评论
使用 GitHub 登录后参与讨论,评论将公开保存在 GitHub。
站长管理删除会同步到 GitHub,无法撤销。
阅读到这里时加载评论。