前言
先说一个措辞上的准确性问题。标题里的「GIL锁」其实是个叠词:GIL 全称 Global Interpreter Lock,本身就带着「Lock」。更重要的纠正是两点:
第一,GIL 是 CPython 的实现细节,不是 Python 语言的规定。你写的 Python 代码交给别的实现(比如 JPython)跑,就不一定有这把锁。所以精确的说法是「CPython 的 GIL」,而不是「Python 的 GIL」。
第二,GIL 和你在代码里import threading; lock = threading.Lock()用的那把锁是两回事。threading.Lock是你为了保护共享数据自己加的;GIL 是解释器内部为了自保加的。混为一谈是理解多线程时最常见的绊脚石。
本文讲清三件事:GIL 到底是什么、它为什么存在、以及它对爬虫这类程序到底有什么影响。示例以 CPython 3.8 及以上为基准,涉及 3.13 实验性自由线程构建的行为会单独标注。
一、GIL 到底是什么
一句话:GIL 让同一进程内、同一时刻只有一个线程在执行 Python 字节码。
拆开看这几个限定词都很关键:
- 同一进程内:GIL 是每个解释器进程一把,不是全局唯一。开两个进程,就有两把 GIL,能真正并行。
- 同一时刻:GIL 会在多个线程之间轮转,让每个线程都分到执行机会,只是任意瞬间只有一个在跑。
- 执行 Python 字节码:注意,是「执行字节码」时才必须持有 GIL。当线程在等待 I/O、在
sleep、在很多标准库的阻塞调用里时,它会释放 GIL,别的线程就能趁虚而入。
最后一点是理解「为什么多线程对爬虫有效」的全部关键。
二、GIL 为什么存在
GIL 不是设计失误,而是为了让 CPython 的实现变简单:
- 引用计数(reference counting)是 CPython 的内存管理方式。每次对象被引用或取消引用,计数器都要加一减一。如果多个线程同时改同一个对象的计数器,就需要给每个对象都加锁,开销巨大,还极易写出竞态。用一把全局锁保护整个解释器状态,简单得多。
- C 扩展的兼容性:大量第三方 C 扩展默认「有 GIL 保护就不会被并发打断」,因此写起来可以不管锁。这也是移除 GIL 极难的原因——牵扯整个生态。
一句话:GIL 用「牺牲多核并行」换来了「实现简单 + 扩展生态稳定」。
三、对两类任务,后果完全不同
| 任务类型 | 典型例子 | 多线程有用吗 | 原因 |
|---|
| CPU 密集 | 大量数值计算、图像处理、压缩 | 基本没用 | 大家抢同一把 GIL,多核用不上 |
| I/O 密集 | 网络请求、读写文件、等数据库 | 有用 | 等待时会释放 GIL,别人能接着跑 |
官方文档在threading模块页说得很直接:如果想让程序更好地利用多核机器的计算资源,应当改用multiprocessing或concurrent.futures.ProcessPoolExecutor;而线程依然适合同时跑多个 I/O 密集任务。
这个区分对爬虫极其重要,因为爬虫几乎纯是 I/O 密集:程序 90% 以上的时间在等网络返回,CPU 只是在解析少量 HTML。等待期间线程释放 GIL,所以多线程爬虫是有效的。
四、GIL 对爬虫的具体影响
好消息:GIL 基本不阻碍多线程爬虫。因为爬虫的瓶颈在网络上,不在 CPU 上。
坏消息藏在细节里,主要有三点:
1. 解析/计算密集的那部分仍然不并行。如果你在拿到页面后做大量正则回溯、大规模 HTML 解析、或者跑机器学习模型,这些是 CPU 密集的,多线程跑不出来加速。这时把解析部分放到进程池,或换用带 C 扩展的解析库,更划算。
2. 「多线程能提升 N 倍」是错的。实际吞吐上限由目标站点响应速度、你的带宽、以及对方的限流策略共同决定。线程开到 200 个,多半只是让一部分线程在排队,还容易被对方识别为异常流量。不要相信任何没实测过的倍数说法。想比较就用time.perf_counter()自己量。
3. GIL 的切换会带来开销。线程过多时,解释器花在切换上的时间占比会上升。线程数应当和「并发等待的 I/O 数量」匹配,而不是越大越好。切换间隔由sys.setswitchinterval()控制,默认值约几毫秒——这是全局设置,不要在业务代码里随手改。
五、想绕过 GIL,有几条路
| 方案 | 机制 | 适用与代价 |
|---|
multiprocessing | 每个进程一个解释器,各有一把 GIL | 真并行;代价是数据要序列化、进程开销大 |
concurrent.futures.ProcessPoolExecutor | 同上,接口更统一 | 适合把 CPU 密集的部分切出去 |
C 扩展 /numpy一类的库 | 在原生代码里主动释放 GIL | 真并行;但本机没有编译工具链就没法自己写 C 扩展 |
asyncio | 单线程事件循环,靠await切换 | 高并发 I/O 很省资源;但生态要适配异步 |
| 自由线程构建(3.13+) | 关掉 GIL,真正并行跑线程 | 实验性、非默认、需单独安装 |
关于最后一条,务必说准:CPython 3.13 起提供了实验性的自由线程(free-threaded)构建,可以在运行时禁用 GIL,实现线程的真正并行执行。但官方文档明说这是实验特性、默认不启用,需要单独的可执行文件(安装时选 free-threaded 版本,或自行用--disable-gil编译)。所以不要说「Python 已经没有 GIL 了」——默认构建里它依然在。想检查当前解释器是否跑在自由线程模式下,可以用sys._is_gil_enabled()。
对爬虫而言,实际情况是:主流做法本来就是多线程(或 asyncio)跑 I/O + 必要时多进程做 CPU 密集的解析,这条路不依赖自由线程构建,今天就能用。
六、一个能观察 GIL 行为的实验
下面这段代码不需要任何第三方库。用两种方式各跑一次同样的「做饭」任务:一种是纯等待(模拟 I/O),一种是纯计算(模拟 CPU 密集)。它会打印出实际耗时,你可以亲眼看到多线程只在第一种情况下才有收益。
# 适用于 Python 3.8+
import threading
import time
def io_task():
time.sleep(0.5) # 模拟等待网络:会释放 GIL
def cpu_task():
total = 0
for i in range(3_000_000): # 模拟纯计算:全程要持有 GIL
total += i * i
return total
def run_all(fn, n=4):
threads = [threading.Thread(target=fn) for _ in range(n)]
start = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
return time.perf_counter() - start
if __name__ == "__main__":
print(f"I/O 型,4 线程耗时:{run_all(io_task):.3f} 秒")
start = time.perf_counter()
for _ in range(4):
cpu_task()
serial = time.perf_counter() - start
print(f"CPU 型,串行 耗时:{serial:.3f} 秒")
print(f"CPU 型,4 线程耗时:{run_all(cpu_task):.3f} 秒")预期现象:I/O 型的 4 线程耗时远小于串行相加的 2 秒;而 CPU 型的 4 线程耗时不会明显低于串行——因为计算全程都在抢 GIL。请自己在本机跑一遍看真实数字,本文不给具体倍数。
七、爬虫合规提醒
讲爬虫绕不开合规。无论线程开几个,下面几条是底线:
- 先看目标站的
robots.txt和使用条款,尊重抓取范围的声明; - 控制请求频率,用
time.sleep或令牌桶限速,不要对单个站点打高并发; - 用合理的
User-Agent表明身份,不要伪装、不要绕登录墙或付费墙; - 只抓公开数据,注意个人信息与版权边界。
反爬机制(检查 UA、频率、Cookie 校验)的成因值得了解,但那是识别异常流量的机制,不是用来研究怎么绕过的。合规抓取既能拿到数据,也不会给对方造成负担。
常见坑点
1. 把threading.Lock当成 GIL
❌ 以为代码里加了with lock:就等于「用好了 GIL」。 ✅ GIL 是解释器内部的锁,和你自己的业务锁无关;保护共享数据仍需自己加threading.Lock。
2. 用多线程做 CPU 密集任务
❌ 用 8 个线程跑大量数值计算,期待 8 倍速。 ✅ 受 GIL 限制没用;改用ProcessPoolExecutor或原生扩展库。
3. 说「Python 已经没有 GIL 了」
❌ 把 3.13 的实验性无 GIL 构建当成默认行为。 ✅ 它是实验性、非默认、要单独安装;默认构建里 GIL 仍在。
4. 线程数开得越大越好
❌ 爬虫动辄开几百个线程。 ✅ 线程数和并发 I/O 数量匹配,配合限速;过多线程增加切换开销且会被对方拦截。
5. 编造「提速 N 倍」
❌ 写「多线程爬虫比单线程快 10 倍」却没有依据。 ✅ 用time.perf_counter()实测,或只讲原理不给数字。
6. 在业务代码里改sys.setswitchinterval()
❌ 随手调小切换间隔以为能加速。 ✅ 它是全局设置,影响整个解释器;非必要不改。
7. 以为 GIL 能代替业务上的同步
❌ 在线程里对共享字典「随便改」,觉得 GIL 会保护自己。 ✅ GIL 只保证单条字节码不被中途抢占;+=、dict的读改删仍需显式同步。
8. 用random生成爬虫里的令牌/签名
❌random.random()生成会话标识。 ✅secrets模块才是密码学安全的随机源。
总结
| 问题 | 结论 |
|---|
| GIL 是什么 | CPython 内部锁,同一进程同一时刻只有一个线程跑字节码 |
| 是语言规范吗 | 不是,是实现细节 |
| 对 CPU 密集 | 基本没有加速效果 |
| 对 I/O 密集 | 有效,因为等待时会释放 GIL |
| 对爬虫 | 爬虫是 I/O 密集,多线程有效;CPU 密集的解析交给进程 |
| 3.13 自由线程 | 实验性、非默认、需单独安装 |
| 想真并行 | 多进程、C 扩展、asyncio各有适用场景 |
把 GIL 记成一句话:它限制的是「同时算」,不限制「同时等」。爬虫干的正是「同时等」的活,所以多线程是合适的工具;而任何需要「同时算」的重活,才该请出多进程或原生扩展。