第2章:concurrent.futures 深度解析
各位同学,今天我们来啃一块硬骨头——concurrent.futures。说实话,这个模块我用了好几年,直到有一次线上服务莫名其妙卡死,我才真正沉下心去研究它的底层。嗯,今天就把这些经验掰开揉碎了讲给你听。
2.1 ThreadPoolExecutor 的底层实现
先说说线程池。你想想看,每次创建线程、销毁线程,开销其实不小。线程池说白了就是「提前招一批工人,有活就干,没活待命」。
ThreadPoolExecutor 内部维护了一个 worker 线程集合 和一个 任务队列。核心逻辑就三步:
- 提交任务时,如果当前 worker 数没达到核心线程数,就新建线程
- 如果达到了,任务就扔进队列排队
- 如果队列满了,且还能创建更多线程(没到最大线程数),就临时加人
我在项目中遇到过一个问题:默认的线程池最大线程数是 CPU 核心数的 5 倍。有一次我处理 IO 密集型任务,开了 200 个线程,结果上下文切换把 CPU 吃满了。后来我改成 max_workers=64,性能反而翻倍。
核心参数一览:
| 参数 | 默认值 | 说明 |
|---|---|---|
| max_workers | min(32, os.cpu_count() + 4) | 最大工作线程数 |
| thread_name_prefix | "" | 线程名前缀,调试时很有用 |
| initializer | None | 每个 worker 启动时执行的初始化函数 |
2.2 ProcessPoolExecutor 的底层实现
进程池和线程池的思路很像,但实现上完全不同。进程池用的是 multiprocessing 模块,每个 worker 是一个独立进程。
这里有个坑:进程间通信(IPC)的开销比线程间通信大得多。我建议你只在 CPU 密集型任务上用进程池,IO 密集型用线程池就够了。
我曾经接手过一个数据清洗项目,同事用 ProcessPoolExecutor 处理网络请求,结果每个请求都要序列化、反序列化,性能还不如单线程。嗯,这就是典型的「杀鸡用牛刀」。
注意:ProcessPoolExecutor 的任务参数和返回值必须可 pickle 序列化。lambda 函数、生成器、某些自定义对象都不行。我踩过这个坑,排查了整整一下午。
2.3 Future 对象的状态机
Future 是什么?说白了就是一个「未来结果的占位符」。你提交任务时,它立刻返回一个 Future 对象,但结果还没算出来。
Future 的状态机其实很简单,就四种状态:
- PENDING:任务刚提交,还没开始执行
- RUNNING:任务正在执行(注意:这个状态在 ThreadPoolExecutor 中可能不会出现)
- FINISHED:任务正常完成,结果可用
- CANCELLED:任务被取消
状态转换只有一条路径:PENDING → RUNNING → FINISHED,或者 PENDING → CANCELLED。没有回头路。
小技巧:用 future.done() 检查是否完成,用 future.result(timeout=5) 获取结果。如果超时了会抛 TimeoutError,记得捕获。
2.4 回调机制
Future 支持添加回调函数,任务完成后自动调用。这个机制非常实用,比如异步写日志、更新进度条等。
def on_done(future):
print(f"任务完成,结果:{future.result()}")
future = executor.submit(my_task, arg)
future.add_done_callback(on_done)
注意:回调函数是在 提交任务的线程 中执行的,不是在 worker 线程中。如果你在回调里做耗时操作,会阻塞主线程。我建议回调里只做轻量级操作,比如更新 UI 或记录日志。
2.5 异常处理
异常处理是并发编程的重灾区。很多同学以为 submit 会直接抛异常,其实不会。异常被「吞」进了 Future 里,只有调用 result() 时才会重新抛出。
来看一个典型的错误写法:
# 错误:异常被吞了
future = executor.submit(risky_task)
# 这里不会抛异常
time.sleep(1)
# 这里才抛
result = future.result()
正确的做法是:
try:
result = future.result(timeout=10)
except Exception as e:
print(f"任务失败:{e}")
# 记录日志、重试、或者优雅降级
我曾经在生产环境遇到过一个问题:某个任务偶尔抛异常,但没被捕获,导致整个线程池的 worker 挂掉。排查后发现是回调函数里抛了异常,而回调的异常不会被 Future 捕获。从那以后,我所有回调函数都加了 try-except。
2.6 知识体系图
下面这张图帮你理清本章的核心逻辑:
2.7 实战建议
最后给你几个实战建议:
- 线程池大小:IO 密集型可以设大一点(比如 50-100),CPU 密集型设为核心数+1
- 进程池大小:一般不超过 CPU 核心数,太多反而因为上下文切换变慢
- 异常处理:永远在
result()调用处加 try-except,回调函数里也要加 - 资源清理:用
with语句管理 Executor,确保任务完成后资源被释放
嗯,concurrent.futures 的内容就讲到这里。记住一句话:Future 不是结果,是结果的承诺。理解了这个,你就掌握了并发编程的精髓。