三年前我刚入行,接手了一个爬虫项目,跑得奇慢。
前辈说:"加多线程,快多了。"
我照着文档写了 threading.Thread,开了十个线程并发抓页面,速度确实蹭蹭往上走,心里暗爽——多线程果然是提速神器。
后来有个计算密集型任务,矩阵乘法,我的第一反应:加线程!
然后我发现——加了和没加一个速度,甚至慢了一点点。
那一刻我陷入了困惑。
先把这个问题搞清楚
import threading
import time
def cpu_task(n):
"""纯 CPU 计算"""
total = 0
for i in range(n):
total += i * i
return total
# 单线程跑两次
start = time.time()
cpu_task(10_000_000)
cpu_task(10_000_000)
single_time = time.time() - start
print(f"单线程耗时: {single_time:.3f}s")
# 双线程并行跑
start = time.time()
t1 = threading.Thread(target=cpu_task, args=(10_000_000,))
t2 = threading.Thread(target=cpu_task, args=(10_000_000,))
t1.start()
t2.start()
t1.join()
t2.join()
thread_time = time.time() - start
print(f"双线程耗时: {thread_time:.3f}s")
print(f"多线程是单线程的 {single_time/thread_time:.2f} 倍速")
输出(真实测试结果,Python 3.11,4 核机器):
单线程耗时: 2.841s
双线程耗时: 3.127s
多线程是单线程的 0.91 倍速
没有快,反而慢了。
这不是玄学,这就是 GIL 的本质。
GIL 是什么
全局解释器锁,Global Interpreter Lock。
Python 的解释器(CPython)内部有个锁,同一时刻只允许一个线程执行 Python 字节码。你开十个线程,表面上是"并发",底层是这十个线程在轮流争同一把锁。
对于 CPU 密集型任务,多线程没有任何提速效果,还因为线程切换的开销略微变慢。
那爬虫为什么快了?
因为爬虫是 IO 密集型——大量时间花在等待网络响应上。等待期间线程主动释放 GIL,其他线程可以趁机运行。这时候多线程确实有用,不是因为"并行计算",而是因为"等待时间重叠"。
本质上是时间的重叠,不是 CPU 资源的并行利用。
对比一下真正的并行
用 multiprocessing 绕过 GIL,每个进程有独立的 Python 解释器,真正利用多核:
import multiprocessing
import threading
import time
def cpu_task(n):
total = 0
for i in range(n):
total += i * i
return total
N = 10_000_000
# 单线程基准
start = time.time()
cpu_task(N)
cpu_task(N)
single = time.time() - start
# 多线程(受 GIL 限制)
start = time.time()
t1 = threading.Thread(target=cpu_task, args=(N,))
t2 = threading.Thread(target=cpu_task, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
thread = time.time() - start
# 多进程(真并行)
start = time.time()
with multiprocessing.Pool(2) as pool:
pool.map(cpu_task, [N, N])
multi = time.time() - start
print(f"单线程: {single:.3f}s (基准)")
print(f"多线程: {thread:.3f}s (比单线程 {single/thread:.2f}x)")
print(f"多进程: {multi:.3f}s (比单线程 {single/multi:.2f}x)")
输出:
单线程: 2.847s (基准)
多线程: 3.091s (比单线程 0.92x)
多进程: 1.523s (比单线程 1.87x)
多线程几乎没有提速,多进程接近两倍加速(双核理论上限就是 2x,实际损耗一点)。
那多线程到底该用在哪?
做一个简单的场景对照:
import threading
import concurrent.futures
import time
import urllib.request
# 模拟 IO 等待(用 time.sleep 代替网络请求)
def io_task(task_id):
time.sleep(0.5) # 模拟 0.5 秒网络延迟
return f"task-{task_id} done"
tasks = list(range(10))
# 顺序执行
start = time.time()
results = [io_task(t) for t in tasks]
seq_time = time.time() - start
# 多线程并发
start = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(io_task, tasks))
thread_time = time.time() - start
print(f"顺序执行 10 个 IO 任务: {seq_time:.2f}s")
print(f"多线程执行 10 个 IO 任务: {thread_time:.2f}s")
print(f"加速比: {seq_time/thread_time:.1f}x")
输出:
顺序执行 10 个 IO 任务: 5.01s
多线程执行 10 个 IO 任务: 0.51s
加速比: 9.8x
将近 10 倍,这才是多线程应该发光的地方。
一张表总结清楚
| 场景 | 用什么 | 原因 |
|------|--------|------|
| 网络请求、数据库查询、文件读写 | threading 或 asyncio | IO 等待期间释放 GIL,时间可重叠 |
| 数学计算、数据处理、模型训练 | multiprocessing 或 numpy | 绕过 GIL 或底层 C 实现,真并行 |
| 高并发轻量连接 | asyncio | 单线程事件循环,零线程切换开销 |
一个常见误区
有人会说:用 NumPy 做矩阵运算,多线程也能提速啊?
那是因为 NumPy 的底层是 C/Fortran 实现,计算过程中会主动释放 GIL。不是多线程"破解"了 GIL,而是 NumPy 根本没用 Python 字节码来做真正的计算。
所以这个结论更准确:GIL 限制的是 Python 字节码的并发执行,不是所有 C 扩展。
这也是为什么 Python 在数据科学领域这么好用——真正的计算都外包给 C/Fortran/CUDA,Python 只是个调度层。
最后说一句实在话
很多人(包括三年前的我)把多线程当成"提速魔法",觉得加线程就是加速。
这个认知是错的,而且代价不小——写了一堆锁、线程同步、竞争条件的代码,最后发现 CPU 利用率完全没变。
正确的认知应该是:先判断你的瓶颈在 IO 还是在 CPU,再选工具。
不理解 GIL,就会在错误的方向上用力。
最后,别忘了关注「有为大青年」,我们下期见~
这个坑你踩过吗?欢迎留言聊聊——或者转发给那个还在用多线程跑 CPU 任务的朋友,救他一命~