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 我的选择策略

在实际的回测项目中,我一般这样决策:

  1. 数据获取阶段:用ThreadPoolExecutor。网络请求是I/O密集型,多线程足够,而且省内存。
  2. 因子计算阶段:用ProcessPoolExecutor。计算量大,需要真并行。
  3. 参数优化阶段:用multiprocessing.Pool。需要精细控制进程数,而且任务量大。
  4. 简单任务:直接用concurrent.futures。代码简洁,后期维护方便。

避坑指南:我曾经在Windows上用过multiprocessing,结果程序卡死了。后来发现Windows没有fork,必须把代码放在if __name__ == '__main__':里。这个坑我踩了整整一天。

3.7 知识体系图

Python并行工具箱对比 并行任务 threading multiprocessing concurrent.futures 特性 • 轻量级线程 • 受GIL限制 • 适合I/O密集型 • 共享内存 特性 • 独立进程 • 真正并行 • 适合CPU密集型 • 独立内存 特性 • 高级封装 • 统一接口 • Future模式 • 易于切换 选择建议 I/O密集型 → threading / ThreadPoolExecutor CPU密集型 → multiprocessing / ProcessPoolExecutor

3.8 性能测试小实验

我建议你自己动手试试。写一个简单的测试:计算100万个随机数的平方和,分别用单线程、多线程、多进程跑一遍。你会发现:

  • 单线程:耗时约0.8秒
  • 多线程(4线程):耗时约0.8秒(GIL限制,没加速)
  • 多进程(4进程):耗时约0.25秒(真并行,接近4倍加速)

这个实验能让你直观感受到GIL的影响。嗯,纸上得来终觉浅,绝知此事要躬行。

小技巧:如果你用Jupyter Notebook做实验,记得把代码放在if __name__ == '__main__':里,否则多进程会出问题。这个坑我帮你们踩过了。

好了,关于Python并行工具箱的对比就到这里。记住一句话:没有银弹,只有合适的工具。选对了,你的回测速度能提升一个数量级;选错了,可能比串行还慢。