3. Python并行工具箱:threading、multiprocessing、concurrent.futures对比
做量化回测的,迟早要面对并行计算。我刚开始接触这块时,也踩过不少坑。Python的并行工具箱看似丰富,但每个工具都有自己的脾气。今天咱们就把threading、multiprocessing和concurrent.futures这三兄弟掰开揉碎,看看它们到底该怎么用。
3.1 先搞清楚:GIL到底是什么鬼?
聊Python并行,绕不开GIL(全局解释器锁)。说白了,GIL就是Python解释器里的一把大锁,同一时刻只允许一个线程执行字节码。你想想看,这就像只有一个收银台的超市,就算你雇了十个收银员,也只能一个一个结账。
为什么会这样?Python的内存管理不是线程安全的,GIL就是那个保护机制。我当年做回测时,开了8个线程去计算因子,结果CPU占用率只有100%——相当于只有一个核在干活。那一刻,我明白了GIL的威力。
核心结论:
- CPU密集型任务(如因子计算、参数优化)→ GIL是瓶颈,用多进程
- I/O密集型任务(如网络请求、文件读写)→ GIL影响小,用多线程
3.2 threading:轻量级,但被GIL束缚
threading是Python标准库里的多线程模块。它创建的是真正的系统线程,但受GIL限制。我习惯用它来处理那些需要等待的任务,比如同时下载多个股票数据。
import threading
import time
def fetch_data(symbol):
# 模拟网络请求
time.sleep(1)
print(f"获取 {symbol} 数据完成")
symbols = ['AAPL', 'GOOGL', 'MSFT', 'AMZN']
threads = []
for sym in symbols:
t = threading.Thread(target=fetch_data, args=(sym,))
threads.append(t)
t.start()
for t in threads:
t.join()
print("所有数据获取完成")
这段代码同时发起4个请求,总耗时约1秒。如果是串行执行,得4秒。这就是多线程的价值——让等待时间重叠。
注意:threading没有直接提供返回值获取机制。你需要自己用Queue或者全局变量来收集结果。我曾经因为这个踩过坑,线程跑完了,结果没拿到。
3.3 multiprocessing:真并行,但开销大
multiprocessing创建的是独立进程,每个进程有自己的GIL。说白了,这才是真正的并行。我做过一个回测优化,用multiprocessing把参数搜索时间从3小时压缩到20分钟。
from multiprocessing import Pool
import time
def backtest(params):
# 模拟回测计算
start, end, period = params
time.sleep(2) # 模拟计算耗时
return f"参数 {params} 回测完成,收益: {start * end * period:.2f}"
params_list = [(1, 2, 3), (4, 5, 6), (7, 8, 9), (10, 11, 12)]
with Pool(processes=4) as pool:
results = pool.map(backtest, params_list)
for r in results:
print(r)
这里的关键是pool.map,它自动把任务分发给进程池里的空闲进程。4个任务同时跑,总耗时约2秒,而不是8秒。
我的经验:进程数不要超过CPU核心数。我试过开32个进程跑32核机器,结果上下文切换开销反而拖慢了速度。一般设为核心数-1比较稳妥。
3.4 concurrent.futures:优雅的封装
concurrent.futures是Python 3.2引入的高级接口。它把threading和multiprocessing统一了,你只需要改一个参数就能切换。我个人非常喜欢这个模块,代码写起来干净多了。
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
def compute_sharpe(data):
# 模拟夏普比率计算
time.sleep(1)
return sum(data) / len(data)
datasets = [
[1, 2, 3, 4, 5],
[2, 3, 4, 5, 6],
[3, 4, 5, 6, 7],
[4, 5, 6, 7, 8]
]
# 用多线程
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(compute_sharpe, d) for d in datasets]
results = [f.result() for f in futures]
print("线程结果:", results)
# 改成多进程,只需改一行
with ProcessPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(compute_sharpe, d) for d in datasets]
results = [f.result() for f in futures]
print("进程结果:", results)
看到没?ThreadPoolExecutor和ProcessPoolExecutor的接口完全一样。你只需要根据任务类型选一个就行。我一般这样选:
- I/O密集型 → ThreadPoolExecutor
- CPU密集型 → ProcessPoolExecutor
3.5 三者的对比表格
| 特性 | threading | multiprocessing | concurrent.futures |
|---|---|---|---|
| 并行类型 | 并发(受GIL限制) | 真正并行 | 封装前两者 |
| 适用场景 | I/O密集型 | CPU密集型 | 通用 |
| 内存开销 | 低(共享内存) | 高(独立内存) | 取决于底层 |
| 返回值获取 | 需手动实现 | 支持(Queue/Pool) | 原生支持Future |
| 代码复杂度 | 中等 | 较高 | 低 |
| 调试难度 | 中等 | 高(进程间通信) | 中等 |
3.6 我的选择策略
在实际的回测项目中,我一般这样决策:
- 数据获取阶段:用ThreadPoolExecutor。网络请求是I/O密集型,多线程足够,而且省内存。
- 因子计算阶段:用ProcessPoolExecutor。计算量大,需要真并行。
- 参数优化阶段:用multiprocessing.Pool。需要精细控制进程数,而且任务量大。
- 简单任务:直接用concurrent.futures。代码简洁,后期维护方便。
避坑指南:我曾经在Windows上用过multiprocessing,结果程序卡死了。后来发现Windows没有fork,必须把代码放在if __name__ == '__main__':里。这个坑我踩了整整一天。
3.7 知识体系图
3.8 性能测试小实验
我建议你自己动手试试。写一个简单的测试:计算100万个随机数的平方和,分别用单线程、多线程、多进程跑一遍。你会发现:
- 单线程:耗时约0.8秒
- 多线程(4线程):耗时约0.8秒(GIL限制,没加速)
- 多进程(4进程):耗时约0.25秒(真并行,接近4倍加速)
这个实验能让你直观感受到GIL的影响。嗯,纸上得来终觉浅,绝知此事要躬行。
小技巧:如果你用Jupyter Notebook做实验,记得把代码放在if __name__ == '__main__':里,否则多进程会出问题。这个坑我帮你们踩过了。
好了,关于Python并行工具箱的对比就到这里。记住一句话:没有银弹,只有合适的工具。选对了,你的回测速度能提升一个数量级;选错了,可能比串行还慢。