Python concurrent.futures 实战:用 ThreadPoolExecutor 并发处理 + as_completed 收结果
你手头有 200 个 URL 要抓,串行 requests.get 一个个跑,每个网络往返 300ms,光等待就是一分多钟,CPU 全程在打瞌睡。这类「一堆各自独立、主要在等 IO」的活儿,正是线程池的主场。但很多人第一次用 ThreadPoolExecutor 就踩坑:要么用 map 拿不到异常,要么用 submit 后不知道怎么优雅地边完成边收结果。这篇把这些坑一次讲清楚。
朴素写法:串行,慢到肉眼可见
先看最直白的版本,抓一批 URL 的状态码:
python
import time
import requests
def fetch_status(url: str) -> int:
r = requests.get(url, timeout=5)
return r.status_code
urls = ["https://httpbin.org/delay/1"] * 10
start = time.perf_counter()
results = [fetch_status(u) for u in urls] # 一个跑完才跑下一个
print(results, f"{time.perf_counter() - start:.1f}s")
# [200, 200, ...] 10.x s ------ 每个 delay 1 秒,10 个就是 10 秒
10 个请求跑了 10 秒。但这些请求彼此毫无依赖,完全可以同时发出去。
用 ThreadPoolExecutor.map 并发:能跑,但异常会「延迟爆炸」
第一个改进是把列表推导换成线程池的 map:
python
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=10) as pool:
results = list(pool.map(fetch_status, urls))
# 10 个请求几乎同时发出,总耗时约 1 秒
max_workers=10 表示最多 10 个线程同时干活,with 退出时会自动等所有任务结束再关池。速度从 10 秒降到 1 秒。
但 map 有个隐蔽问题:它保持输入顺序,而且异常会在你迭代到那一项时才抛出 。如果第 3 个 URL 挂了,前两个结果你都拿到了,迭代到第 3 个时才 raise,这时候想知道「到底哪些成功了」就很别扭。而且 map 一旦某项抛异常,后面的结果你也拿不到了。
正解:submit + as_completed,谁先好谁先拿
生产里更常用的是 submit 提交任务拿到 Future,再用 as_completed 按「完成先后」而非「提交顺序」收结果。这样先跑完的先处理,慢的不拖累快的:
python
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_status(url: str) -> int:
r = requests.get(url, timeout=5)
return r.status_code
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/status/404",
"https://httpbin.org/delay/2",
"https://not-a-real-host.invalid", # 故意让它失败
]
results, errors = {}, {}
with ThreadPoolExecutor(max_workers=8) as pool:
# future 反查 url,方便出错时知道是谁挂了
future_to_url = {pool.submit(fetch_status, u): u for u in urls}
for future in as_completed(future_to_url):
url = future_to_url[future]
try:
results[url] = future.result() # 任务里抛的异常在这里重新抛出
except Exception as e:
errors[url] = repr(e)
print("成功:", results)
print("失败:", errors)
关键点在于 future.result():如果任务函数内部抛了异常,异常被 Future 兜住,直到你调 result() 才重新抛出。所以用 try/except 包住它,就能把「成功的」和「失败的」分开收集,一个失败不影响其余任务。as_completed 则保证谁先跑完谁先进循环,长尾任务不会阻塞短任务的处理。
给任务加超时,别让一个卡死拖垮整批
result(timeout=...) 可以给单个任务设等待上限。注意超时不会真的杀死线程(Python 线程无法强制中断),它只是让你别再干等:
python
from concurrent.futures import TimeoutError as FutureTimeout
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(fetch_status, u): u for u in urls}
for future in as_completed(futures, timeout=10): # 整批 10 秒内必须收完
url = futures[future]
try:
print(url, future.result(timeout=3)) # 单个任务最多等 3 秒
except FutureTimeout:
print(url, "该任务超时,先跳过")
except Exception as e:
print(url, "失败:", e)
真正的超时控制还得靠底层库自己支持,比如 requests.get(url, timeout=5)------线程池的 timeout 只是让主线程别一直傻等。
线程池 vs 进程池:选错等于白干
concurrent.futures 还提供 ProcessPoolExecutor,接口几乎一样,但适用场景相反:
python
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n: int) -> int:
return sum(i * i for i in range(n)) # 纯计算,吃 CPU
# CPU 密集用进程池,绕开 GIL,真正并行
with ProcessPoolExecutor(max_workers=4) as pool:
print(list(pool.map(cpu_heavy, [10**6] * 4)))
判断标准很简单:
- IO 密集 (网络请求、读写文件、查数据库):线程主要在「等」,GIL 在等待时会释放,用
ThreadPoolExecutor,max_workers可以开得比核心数大很多(几十到上百)。 - CPU 密集 (加密、图像处理、大量数学计算):线程被 GIL 卡住无法并行,必须用
ProcessPoolExecutor,max_workers一般设成 CPU 核心数。
用线程池跑 CPU 密集任务,你会发现开多少线程都不快------因为 GIL 让它们始终只有一个在真正执行。
小结
- 独立的 IO 任务用
ThreadPoolExecutor,一行with自动管理线程生命周期。 - 优先
submit+as_completed,谁先完成谁先处理;用{future: 上下文}字典反查是哪个任务出的错。 future.result()才是异常真正抛出的地方,用try/except包住它就能把成功与失败分流,单个失败不拖垮整批。result(timeout=)只是让主线程别干等,真超时靠底层库(如requests的timeout)。- 一句话记忆:等 IO 用线程池,算 CPU 用进程池,收结果认准
as_completed。