GIL 与多线程:对比 C pthread (GIL & Multithreading)
章节概述
“Python 不支持真正的多线程”——这是最常被误传的一句话。真相是:GIL(Global Interpreter Lock,全局解释器锁)使得 CPython 的线程在同一时刻只有一个能执行 Python 字节码,但 I/O 操作会释放 GIL。本章从 CPython 源码出发,解释 GIL 的存在理由和工作原理,并与 C pthread 的直接共享内存并行进行深度对比。
核心理念:GIL 不是 CPython 的设计缺陷,而是为了简化 C API 和内存管理的一次务实取舍。理解 GIL 的限制和绕过方法(多进程、C 扩展释放 GIL),是写出高性能 Python 程序的前提。
第一节:GIL 是什么
1.1 GIL 的来源
CPython 的内存管理核心是引用计数(第一章)。考虑这个问题:
# 没有 GIL 会怎样?
x = []
# 线程 A # 线程 B
a = x # refcnt: 1→2 b = x # 同时读取 refcnt=1,准备设为 2
# CPU 执行: # CPU 执行:
# load refcnt (1) # load refcnt (1) ← 读到了旧值!
# add 1 (2) # add 1 (2)
# store refcnt (2) # store refcnt (2)
# 结果: refcnt = 2,但应该是 3!在没有锁的情况下,两个线程同时修改 ob_refcnt 会导致竞态条件——引用计数错误 → 对象被过早释放或永远不会释放 → 段错误或内存泄漏。
最直观的解决方案:给每个 Python 对象加锁。但这会导致:
- 每个对象多 8-16 字节的锁字段(内存膨胀)
- 每次引用计数操作都要获取/释放锁(性能灾难)
- 死锁风险剧增(a 引用 b,b 引用 a,同时修改)
CPython 的选择:在解释器层面加一把全局锁——GIL。这把锁保证任何时候只有一个线程在解释器中执行 Python 字节码。
// CPython 源码简化逻辑 (ceval_gil.c)
void take_gil(PyThreadState *tstate) {
// 1. 尝试获取 GIL
// 2. 如果被占用,阻塞等待或超时
// 3. 成功获取后,设置 tstate 为当前线程状态
}
void drop_gil(PyThreadState *tstate) {
// 释放 GIL,允许其他线程获取
// 通常在 I/O 操作或达到"检查间隔"时调用
}为什么 GIL 存在至今:移除 GIL 的尝试已有多次(如 gilectomy 项目),但每次都会导致单线程性能大幅下降(因为细粒度锁的开销)或 API 兼容性崩溃(C 扩展假设 GIL 存在)。Python 3.13 引入了”无 GIL 构建”的试验性选项,但仍需很长时间才能成为默认。
1.2 GIL 的行为
python -c "
import threading
import time
def cpu_bound(n):
total = 0
for i in range(n):
total += i * i
return total
# 单线程
start = time.time()
cpu_bound(10_000_000)
print(f'单线程耗时: {time.time() - start:.2f}s')
# 两个线程(CPU 密集)
def worker():
cpu_bound(5_000_000)
start = time.time()
t1 = threading.Thread(target=worker)
t2 = threading.Thread(target=worker)
t1.start(); t2.start()
t1.join(); t2.join()
print(f'双线程耗时: {time.time() - start:.2f}s')
# 预期:双线程甚至比单线程更慢!因为 GIL 竞争开销
"输出示例:
单线程耗时: 0.42s
双线程耗时: 0.85s # 几乎两倍!线程切换 + GIL 争夺 = 额外开销
第二节:GIL 何时释放
GIL 并非永不释放。在以下情况下,当前线程会释放 GIL:
| 场景 | 释放机制 |
|---|---|
I/O 操作 (read, write, sleep, socket) | 系统调用前主动释放 |
| 调用 C 扩展函数(可手动释放) | 使用 Py_BEGIN_ALLOW_THREADS 宏 |
| 解释器循环中的”检查点” | 每执行若干字节码指令后检查 |
线程主动调用 time.sleep() | sleep 期间释放 GIL |
python -c "
import threading
import time
import urllib.request
def io_bound(url):
start = time.time()
urllib.request.urlopen(url)
return time.time() - start
# 两个 I/O 密集线程 — GIL 在等待网络时释放
start = time.time()
t1 = threading.Thread(target=io_bound, args=('https://example.com',))
t2 = threading.Thread(target=io_bound, args=('https://example.com',))
t1.start(); t2.start()
t1.join(); t2.join()
print(f'双线程IO耗时: {time.time() - start:.2f}s')
# 预期:接近单次请求的时间(两者并行等待)
"经验法则:CPU 密集型 → 多进程(绕过 GIL);I/O 密集型 → 多线程(GIL 在等待时释放,线程有效并行)。
第三节:Python threading vs C pthread
3.1 C pthread:真正的共享内存并行
// pthread_demo.c — 两个线程真正并行计算
#include <stdio.h>
#include <pthread.h>
#include <time.h>
#define N 50000000
double sum = 0; // 共享变量 — 危险!
void* compute(void *arg) {
long start = (long)arg;
for (long i = start; i < start + N/2; i++) {
sum += i * 0.0000001; // 竞态条件!没有锁保护
}
return NULL;
}
int main() {
pthread_t t1, t2;
clock_t begin = clock();
pthread_create(&t1, NULL, compute, (void*)0);
pthread_create(&t2, NULL, compute, (void*)(N/2));
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("sum = %f, time = %.2fs\n", sum,
(double)(clock() - begin) / CLOCKS_PER_SEC);
return 0;
}gcc -O2 -o pthread_demo pthread_demo.c -lpthread
./pthread_demo
# sum = 124.999958, time = 0.08s ← 结果不对!(竞态)
# 用 mutex 保护后结果正确,但速度可能会变慢3.2 Python threading:GIL 的”意外安全”
import threading
counter = 0 # 共享变量
def increment(n):
global counter
for _ in range(n):
counter += 1 # 在 CPython 中看似"原子"
t1 = threading.Thread(target=increment, args=(1000000,))
t2 = threading.Thread(target=increment, args=(1000000,))
t1.start(); t2.start()
t1.join(); t2.join()
print(counter) # 可能是 2000000 ← 但不是因为 GIL 保护得好!重要警告:GIL 使得每次字节码执行是原子的,但
counter += 1不是单个字节码!它编译为多个字节码(LOAD_GLOBAL、LOAD_FAST、INPLACE_ADD、STORE_GLOBAL)。在字节码之间,GIL 可以被切换!对于复杂操作,仍然需要threading.Lock()。
python -c "
import dis
dis.dis('x += 1')
"输出:
0 0 RESUME 0
1 2 LOAD_NAME 0 (x)
4 LOAD_CONST 0 (1)
6 BINARY_OP 13 (+=)
10 STORE_NAME 0 (x)
14 RETURN_CONST 0 (None)
x += 1至少是 3-4 个独立的字节码指令,GIL 可以在任意两条之间被切换!Python 中多线程修改共享可变对象仍然需要显式上锁。
第四节:threading 模块实战
import threading
import time
import queue
# ===== 基础用法 =====
def worker(name, delay):
for i in range(3):
time.sleep(delay)
print(f'{name}: 第 {i+1} 次执行')
t1 = threading.Thread(target=worker, args=('线程A', 0.5))
t2 = threading.Thread(target=worker, args=('线程B', 0.3))
t1.start()
t2.start()
t1.join() # 等待 t1 完成
t2.join() # 等待 t2 完成
# ===== 守护线程 =====
def daemon_worker():
while True: # 无限循环
time.sleep(1)
print('守护线程还在运行')
dt = threading.Thread(target=daemon_worker, daemon=True)
dt.start()
time.sleep(3)
print('主线程退出 — 守护线程自动终止')
# daemon=True 的线程会在主线程退出时强制终止
# ===== 线程同步:Lock =====
balance = 100
lock = threading.Lock()
def transfer(amount):
global balance
with lock: # 等价于 lock.acquire() / lock.release()
tmp = balance
time.sleep(0.001) # 模拟其他操作
balance = tmp + amount
# ===== 线程安全队列 =====
q = queue.Queue(maxsize=10)
def producer():
for i in range(5):
q.put(f'item-{i}')
print(f'生产: item-{i}')
def consumer():
while True:
item = q.get()
if item is None:
break
print(f'消费: {item}')
q.task_done()第五节:multiprocessing — 绕过 GIL
对于 CPU 密集型任务,multiprocessing 模块启动独立进程(每个进程有自己的 Python 解释器 → 自己的 GIL → 真正的并行)。
5.1 Process 与 Pool
import multiprocessing as mp
import time
def cpu_heavy(n):
total = 0
for i in range(n):
total += i * i
return total
# 方式一:手动创建进程
start = time.time()
p1 = mp.Process(target=cpu_heavy, args=(10_000_000,))
p2 = mp.Process(target=cpu_heavy, args=(10_000_000,))
p1.start(); p2.start()
p1.join(); p2.join()
print(f'双进程耗时: {time.time() - start:.2f}s')
# 方式二:进程池(推荐)
start = time.time()
with mp.Pool(processes=4) as pool:
results = pool.map(cpu_heavy, [2_500_000] * 4)
print(f'进程池耗时: {time.time() - start:.2f}s')
# 方式三:异步任务
with mp.Pool(processes=2) as pool:
result1 = pool.apply_async(cpu_heavy, (5_000_000,))
result2 = pool.apply_async(cpu_heavy, (5_000_000,))
print(result1.get(), result2.get()) # get() 阻塞等待结果输出示例(4 核 CPU):
双进程耗时: 0.28s # 真正并行 ≈ 单线程一半
进程池耗时: 0.18s # 4 进程 ≈ 单线程 1/4
5.2 进程间通信
import multiprocessing as mp
# ===== Queue:进程间传递数据 =====
def producer(q):
for i in range(5):
q.put(f'data-{i}')
def consumer(q):
while True:
item = q.get()
if item == 'STOP':
break
print(f'收到: {item}')
q = mp.Queue()
p1 = mp.Process(target=producer, args=(q,))
p2 = mp.Process(target=consumer, args=(q,))
p1.start(); p2.start()
p1.join()
q.put('STOP')
p2.join()
# ===== Pipe:双向通道 =====
parent_conn, child_conn = mp.Pipe()
def child_func(conn):
conn.send('来自子进程的消息')
print('子进程收到:', conn.recv())
conn.close()
proc = mp.Process(target=child_func, args=(child_conn,))
proc.start()
print('父进程收到:', parent_conn.recv())
parent_conn.send('来自父进程的回复')
proc.join()
# ===== shared_memory:真正的共享内存(Python 3.8+) =====
import numpy as np
from multiprocessing import shared_memory
# 创建共享内存数组
a = np.zeros(10)
shm = shared_memory.SharedMemory(create=True, size=a.nbytes)
b = np.ndarray(a.shape, dtype=a.dtype, buffer=shm.buf)
b[:] = a[:]
# 另一个进程可以通过 shm.name 连接到同一块内存
# 这是绕过进程间数据拷贝的高性能方案
shm.close()
shm.unlink()C 对比:C pthread 用共享地址空间实现线程间通信零开销。multiprocessing 的不同之处在于进程地址空间隔离 → 需要序列化/管道/shared_memory 来传递数据,有序列化开销。
第六节:在 C 扩展中释放 GIL
如果你写 C 扩展做重计算,可以主动释放 GIL 让 Python 其他线程继续运行:
// myextension.c — 在 C 扩展中释放 GIL
#include <Python.h>
static PyObject* heavy_computation(PyObject *self, PyObject *args) {
long n;
if (!PyArg_ParseTuple(args, "l", &n))
return NULL;
// 释放 GIL — Python 其他线程可以在这期间执行
Py_BEGIN_ALLOW_THREADS
double result = 0.0;
for (long i = 0; i < n; i++) {
result += i * i * 0.000001;
}
// 重新获取 GIL — 之后才能操作 Python 对象
Py_END_ALLOW_THREADS
return PyFloat_FromDouble(result);
}
// ... PyMethodDef 和模块注册这是 Python 科学计算栈(NumPy、SciPy、PyTorch)能在底层利用多核的关键:C/C++ 实现的计算核心在
Py_BEGIN_ALLOW_THREADS和Py_END_ALLOW_THREADS之间运行,不受 GIL 限制。
第七节:选择策略总结
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| I/O 密集型(网络请求、文件读写) | threading 或 asyncio | GIL 在 I/O 等待时释放 |
| CPU 密集型(数学计算、图像处理) | multiprocessing | 绕过 GIL,真正并行 |
| 调用已有的 C 库 | 在 C 扩展中 Py_BEGIN_ALLOW_THREADS | C 代码不受 GIL 限制 |
| 大规模数值计算 | NumPy + 多线程 | NumPy 的 C 后端会释放 GIL |
| 需要共享大量数据 | multiprocessing.shared_memory | 避免序列化开销 |
import concurrent.futures
# ThreadPoolExecutor: 适合 I/O 密集型
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(fetch_url, url) for url in urls]
for f in concurrent.futures.as_completed(futures):
result = f.result()
# ProcessPoolExecutor: 适合 CPU 密集型
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(heavy_compute, data_chunks))练习
以下题目用于验证本章所学内容:
| 题号 | 题目 | 链接 | 涉及知识点 |
|---|---|---|---|
| 1114 | 按序打印 | https://www.luogu.com.cn/problem/P1001 | 多线程同步、锁机制 |
| 1115 | 交替打印 FooBar | https://www.luogu.com.cn/problem/P1001 | 线程交替执行、信号量 |