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_THREADSPy_END_ALLOW_THREADS 之间运行,不受 GIL 限制。


第七节:选择策略总结

场景推荐方案原因
I/O 密集型(网络请求、文件读写)threadingasyncioGIL 在 I/O 等待时释放
CPU 密集型(数学计算、图像处理)multiprocessing绕过 GIL,真正并行
调用已有的 C 库在 C 扩展中 Py_BEGIN_ALLOW_THREADSC 代码不受 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交替打印 FooBarhttps://www.luogu.com.cn/problem/P1001线程交替执行、信号量