《Python编程入门》12.1 GIL 与线程/进程模型

GIL 让同一时刻只有一个线程执行 Python 字节码。本节讲清它保护什么、为何存在,用真实计时证明 CPU 密集多线程不提速而 IO 密集近线性提速,再讲透 Thread/Lock/Event/Semaphore/Queue、竞态复现与线程进程选型。

本节目标:理解 GIL 是什么、保护什么、为什么存在,用实测数据判断线程/进程/异步的选型,并掌握 threading 的核心工具。
适用版本:Python 3.12+(实测 3.14.6)

12.1 GIL 与线程/进程模型

第 11 章我们把脚本做成了命令行工具。但只要工具稍微实用一点,就会遇到「同时处理多个任务」的需求:下载一百个文件、同时服务多个客户端、并行跑一堆计算。这时你会听到一个绕不开的词——GIL。本节先把 GIL 讲清楚,再给出「什么任务用什么模型」的决策依据。

12.1.1 GIL 是什么

GIL(Global Interpreter Lock,全局解释器锁)是 CPython 解释器里的一把全局互斥锁。任何线程想执行 Python 字节码,都必须先持有它。由此得到一条核心结论:

同一时刻,一个 CPython 进程里只有一个线程在执行 Python 字节码。

注意措辞——是「执行 Python 字节码」时才需要 GIL,不是「线程存在」就需要。当线程在等 I/O、time.sleep()、或在 C 扩展里主动释放 GIL 时,锁会让出去,别的线程就能跑。

GIL 保护的是解释器内部状态的一致性:引用计数、对象分配、垃圾回收标记等。CPython 用引用计数管理内存,如果两个线程同时修改同一个对象的引用计数而不加锁,计数就会错乱,导致对象被提前回收或内存泄漏。给每个对象都加细粒度锁代价极高,于是 CPython 选择了「一把大锁」这个简单方案。

12.1.2 实测:GIL 确实存在

光讲原理不够,我们用计时把 GIL 的后果直接测出来。下面的脚本分别对 CPU 密集与 IO 密集任务,比较「单线程顺序执行」和「4 线程并发」,各跑 5 次取最小值以抵消机器噪声:

import time
import threading


def cpu_work(n: int) -> int:            # 纯计算,占满 CPU
    total = 0
    for i in range(n):
        total += i * i
    return total


def io_work(seconds: float) -> float:   # 模拟等待 I/O
    time.sleep(seconds)
    return seconds


def run(target, args, threaded):
    t0 = time.perf_counter()
    if threaded:
        ts = [threading.Thread(target=target, args=args) for _ in range(4)]
        for t in ts:
            t.start()
        for t in ts:
            t.join()
    else:
        for _ in range(4):
            target(*args)
    return time.perf_counter() - t0


def best(target, args, threaded):
    return min(run(target, args, threaded) for _ in range(5))


for label, target, args in [("CPU 密集", cpu_work, (6_000_000,)),
                            ("IO  密集", io_work, (0.5,))]:
    seq = best(target, args, False)
    thr = best(target, args, True)
    print(f"{label}: 单线程 {seq:.3f}s | 多线程 {thr:.3f}s | 加速比 {seq / thr:.2f}x")
CPU 密集: 单线程 1.085s | 多线程 1.028s | 加速比 1.06x
IO  密集: 单线程 2.018s | 多线程 0.507s | 加速比 3.98x

结论一目了然:

  • CPU 密集任务:4 个线程跑得和 1 个线程一样快(1.06x)。GIL 把它们串行化了,多开线程纯属白忙。
  • IO 密集任务:4 个线程拿到 3.98x 加速,接近理论上限。因为 sleep 期间线程主动让出了 GIL。

计时绝对值受 CPU 型号、负载、后台进程影响,每次都有波动;但「CPU 密集几乎无加速、IO 密集接近线程数」这个定性结论在任何机器上都成立。

12.1.3 Thread 的基本用法与 join

最基础的线程用法:

import threading
import time


def job(name: str, seconds: float) -> None:
    print(f"  {name} 开始")
    time.sleep(seconds)
    print(f"  {name} 结束")


threads = [threading.Thread(target=job, args=(f"T{i}", 0.2)) for i in range(3)]
for t in threads:
    t.start()          # 启动,不阻塞
for t in threads:
    t.join()           # 等这个线程结束
print("全部完成")
  T0 开始
  T1 开始
  T2 开始
  T0 结束
  T2 结束
  T1 结束
全部完成

start() 立即返回,主线程继续;join() 阻塞直到该线程结束。不 join 就退出主线程,未完成的线程可能被强制中断。若某线程「后台跑就行、不必等它」,设成守护线程:

bg = threading.Thread(target=heartbeat, daemon=True)
bg.start()
print(bg.is_alive(), bg.daemon)     # True True

守护线程会在所有非守护线程结束后被自动回收,适合心跳、监控这类附属任务。

12.1.4 竞态条件与 Lock

线程共享内存,不加保护就会出竞态。看这个读-改-写循环:

import threading

N = 1_000_000
counter = 0


def next_value(v: int) -> int:
    return v + 1


def worker():
    global counter
    for _ in range(N):
        current = counter               # 读
        counter = next_value(current)   # 计算(一次函数调用)后写回


def run():
    global counter
    counter = 0
    threads = [threading.Thread(target=worker) for _ in range(2)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return counter


for i in range(3):
    got = run()
    print(f"无锁 第{i + 1}次: counter = {got}  (期望 {2 * N},丢失 {2 * N - got})")
无锁 第1次: counter = 1652878  (期望 2000000,丢失 347122)
无锁 第2次: counter = 1488634  (期望 2000000,丢失 511366)
无锁 第3次: counter = 1285499  (期望 2000000,丢失 714501)

两个线程各加 100 万次,结果却少了几十万——丢失的更新。原因是「读 → 计算 → 写回」不是原子的:线程 A 读到 100,还没写回,GIL 在函数调用处切换给线程 B,B 也读到 100,各自加 1 都写回 101,两次加法只生效一次。

一个反直觉的实测细节:若循环体只有一行 counter += 1,在 CPython 3.10+ 上往往观察不到丢失——因为 GIL 切换点落在循环回边(写回之后),这条语句碰巧成了「事实上的原子操作」。一旦读和写之间插入任何会触发切换点的操作(函数调用、I/O、sleep),竞态立刻暴露。别把「测不出来」当成「不存在」。

用 Lock 把临界区保护起来,结果就对了:

lock = threading.Lock()


def worker():
    global counter
    for _ in range(N):
        with lock:                      # 同一时刻只有一个线程能进入
            current = counter
            counter = next_value(current)
加锁: counter = 2000000  (期望 2000000)

Lock 不可重入:同一线程连续 acquire() 两次会死锁。若临界区里要再次获取同一把锁,用 RLock(可重入锁),它记录持有者与重入次数。

12.1.5 Event、Semaphore 与 Queue

Lock 解决互斥,另外三个工具解决协作:

工具用途关键方法
Event一个线程通知另一个「可以开始了」set() / wait() / clear()
Semaphore限制同时访问某资源的线程数acquire() / release()
Queue线程安全的生产者-消费者队列put() / get() / task_done()

Event 是最轻量的「通知」机制:一个线程调用 ready.wait() 阻塞住,另一个线程在条件成熟时调用 ready.set() 把它唤醒(常用于「初始化完成后开始工作」)。Semaphore(2) 用来限流,最多允许 2 个线程同时进入;下面用 active 计数直观看到并发上限始终不超过 2:

import threading
import time

sem = threading.Semaphore(2)            # 最多 2 个线程同时进入
active = 0
guard = threading.Lock()


def limited(i):
    global active
    with sem:                           # 拿不到名额就在这里排队
        with guard:
            active += 1
            cur = active
        print(f"  task-{i} 进入,当前并发 {cur}")
        time.sleep(0.1)
        with guard:
            active -= 1


ts = [threading.Thread(target=limited, args=(i,)) for i in range(5)]
for t in ts:
    t.start()
for t in ts:
    t.join()
  task-0 进入,当前并发 1
  task-1 进入,当前并发 2
  task-2 进入,当前并发 2
  task-3 进入,当前并发 2
  task-4 进入,当前并发 2

Queue(queue.Queue)是线程安全的生产者-消费者队列:put() 放入、get() 取出(无数据时阻塞),内部已加锁,常用哨兵值 None 通知消费者结束。它把生产与消费彻底解耦,是线程间传数据最安全的方式。

12.1.6 threading.local:线程私有数据

有时你想「每个线程各有一份同名变量」,互不干扰。threading.local() 就是为此而生:

import threading
import time

local = threading.local()
results = {}


def store(name):
    local.value = name          # 每个线程各有一份
    time.sleep(0.01)
    results[name] = local.value  # 不会被别的线程覆盖


ts = [threading.Thread(target=store, args=(f"T{i}",)) for i in range(3)]
for x in ts:
    x.start()
for x in ts:
    x.join()
print("local:", results)
local: {'T0': 'T0', 'T1': 'T1', 'T2': 'T2'}

Web 框架常用它保存「当前请求上下文」(如数据库连接),避免把上下文一路当参数传递。

12.1.7 自由线程构建与 sys._is_gil_enabled

Python 3.13 引入了 PEP 703 的自由线程构建(俗称 no-GIL,实验性),编译时用 --disable-gil 开启。要判断当前解释器是否带 GIL,可以查询:

import sys
print("GIL 已启用:", sys._is_gil_enabled())
GIL 已启用: True

本机是标准构建,返回 True。自由线程构建下会返回 False(除非运行时又把它打开)。需要强调:自由线程目前仍是实验特性,生态兼容性、单线程性能都还在打磨,生产环境不要贸然使用;它属于「值得知道、暂不依赖」的范畴。

12.1.8 选型决策表

把本节结论浓缩成一张表:

任务类型推荐模型理由
IO 密集(网络、磁盘、DB)线程 或 异步等待时释放 GIL,可近线性提速
CPU 密集(纯 Python 计算)多进程绕过 GIL,真正并行
受 C 库释放 GIL 的计算(NumPy 等)线程也行计算在 C 层释放 GIL,能并行
大量并发连接异步(第 13 章)单线程事件循环,开销最低

一句话记忆:「等」用线程/异步,「算」用进程。 下一节我们就把「用进程算」落地成可复用的工具。

12.1.9 延伸阅读

想深入 GIL 的历史、移除尝试与性能影响,可看专题 Python 的 GIL 对并发编程有哪些影响 ;线程、进程、异步的综合对比见 Python 并发与性能 。

小结

  • GIL 是 CPython 的全局锁,保证同一时刻只有一个线程执行字节码;它保护引用计数等解释器内部状态。
  • 实测:CPU 密集多线程几乎无加速(1.06x),IO 密集接近线性(3.98x)——因为 I/O 等待会释放 GIL。
  • 读-改-写不是原子操作,不加锁会丢失更新;用 Lock 保护临界区,可重入场景用 RLock。
  • Event 做通知、Semaphore 做限流、Queue 做线程安全传递,threading.local 存线程私有数据。
  • 3.13 的自由线程构建(PEP 703)仍是实验特性,用 sys._is_gil_enabled() 查询当前解释器是否启用 GIL。
  • 选型口诀:IO 密集用线程/异步,CPU 密集用多进程。

本节我们看清了 GIL 的边界,也知道了「CPU 密集该用进程」。下一节就把这个结论变成可复用的代码——concurrent.futures 用统一接口管理线程池与进程池。

阅读导航:上一节:argparse 与命令行工具 · 下一节:concurrent.futures 与 multiprocessing 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时