Python 线程基础:threading 模块的使用与 GIL 的影响

我刚开始做多线程时,觉得 Python 的 threading 模块挺简单的——不就是创建几个线程跑任务吗?但实际一跑,发现事情没那么简单。你写个死循环 CPU 密集任务,多线程不仅没加速,反而变慢了。这背后的罪魁祸首,就是 GIL(全局解释器锁)。

先别急着骂 GIL,咱们先把 threading 模块玩明白,再看 GIL 到底怎么影响你的界面渲染性能。

threading 模块的核心用法

Python 标准库的 threading 模块提供了创建和管理线程的工具。我个人习惯用 threading.Thread 来创建线程,比用 _thread 模块更安全可控。

1. 基本创建与启动

import threading
import time

def worker(name):
    print(f"线程 {name} 开始工作")
    time.sleep(1)
    print(f"线程 {name} 结束")

t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))
t1.start()
t2.start()
t1.join()
t2.join()
print("主线程结束")

注意:start() 是让线程开始执行,join() 是等待线程结束。如果你忘了调 join,主线程可能提前退出,子线程还没跑完就跟着挂了。嗯,我踩过这个坑。

2. 线程守护(daemon)

守护线程会在主线程退出时自动终止。适合后台监控、心跳检测这类任务。

t = threading.Thread(target=some_long_task, daemon=True)
t.start()
# 主线程结束后,t 自动终止
小心: 守护线程中的资源(如文件句柄、数据库连接)可能不会被正常清理。我曾在生产环境遇到过守护线程写日志写到一半主线程挂了,日志文件损坏的情况。

3. 线程同步——锁(Lock)

多个线程共享同一份数据时,不加锁容易出乱子。看这个经典例子:

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100000):
        with lock:
            counter += 1

threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(counter)  # 期望 1000000,不加锁可能远小于这个值

我不建议用 lock.acquire() 和 release() 手动管理,用 with lock: 更安全。一旦你忘了 release,死锁就来了。

4. 其他同步原语

原语用途我什么时候用它
RLock可重入锁,同一线程可多次 acquire嵌套函数里都用同一个锁,用 RLock 避免死锁
Semaphore限制并发访问数量控制同时下载文件的数量,比如最多 5 个
Event线程间信号传递等某个条件满足再继续执行,比如等待网络连接就绪
Condition更复杂的等待/通知生产者-消费者模式,需要精确控制唤醒哪些线程

说句实话,大部分场景用 Lock + Event 就够用了。别一开始就上 Condition,除非你真的需要。

GIL 到底是什么?

GIL(Global Interpreter Lock)是 CPython 解释器中的一个互斥锁。它保证同一时刻只有一个线程在执行 Python 字节码。换句话说,你的多线程 Python 程序,在单个进程里是没法真正利用多核 CPU 的。

真相: Python 的多线程是并发,不是并行。对 CPU 密集任务,多线程可能比单线程还慢(因为切换开销)。但对 IO 密集型任务(网络请求、文件读写、数据库查询),多线程能极大提升效率。

为什么有 GIL?

这其实是为了简化内存管理——CPython 的引用计数不是线程安全的,如果没有 GIL,你每次操作变量都得加非常细粒度的锁,性能反而更差。说白了,GIL 是 Python 早期设计的一个折中。现在虽然有很多尝试去掉 GIL(比如 nogil 分支),但主流 CPython 里它还在。

曾经有个坑: 我曾经写过一个每秒处理几千条的日志解析程序,开了 8 个线程,结果 CPU 占用跑满了单核,8 核机器只有一核在干活。后来换成 multiprocessing 才解决问题。所以你心里要有数:GIL 不是 bug,是特性,但你要知道什么时候绕开它。

GIL 对 UI 线程的影响

回到咱们的课程主题——后台线程到 UI 渲染。你在后台线程里跑耗时计算,或者大量循环,GIL 会阻塞住主线程的 Python 字节码执行。主线程没法及时响应 UI 事件、重绘界面。结果就是:界面卡死。

具体来说:

  1. 后台线程持有 GIL 时,主线程要等待 GIL 释放才能执行。
  2. CPython 每执行 100 个字节码就会主动释放 GIL(Python 3.2 之后是每 5 毫秒)。
  3. 但如果是 C 扩展(比如 numpy、opencv),它们在处理期间不会释放 GIL。

所以你后台用 numpy 做大量矩阵计算,GIL 一直不释放,主线程惨了。

怎么规避?

  • CPU 密集任务: 用 multiprocessing 模块开子进程,每个进程有自己的 GIL,才能并行。
  • IO 密集任务: 多线程是好的,因为 IO 等待时会主动释放 GIL。
  • 混合作业: 可以用 ThreadPoolExecutor + 异步 IO(asyncio)组合。
  • 如果你的后台线程必须做 CPU 计算且不想卡 UI: 将大计算切分成小块,每块之间 time.sleep(0) 主动让出 GIL(实际上很不可靠),或者用 subprocess 调用外部进程。

一张图理清线程、GIL 与 UI 渲染的关系

Python 线程 · GIL · UI 渲染路径 主线程 (UI 线程) 事件循环 · 重绘 · 输入响应 后台线程 耗时计算 / IO 处理 GIL (全局解释器锁) 主线程持有 GIL (UI 可响应) 后台线程持有 GIL (UI 卡死) 结果:UI 卡顿 / 响应缺失 CPU 密集→ multiprocessing IO 密集→ threading 混合→ asyncio + executor

图中清晰展示了:主线程和后台线程都要争抢 GIL。当后台线程持有 GIL 且不主动释放时,主线程无法处理 UI 事件,界面就卡了。所以你要么让后台线程做 IO 操作(自动释放 GIL),要么干脆分进程跑。

实战建议:让 UI 不卡的两个核心原则

  • 原则一:绝不把 CPU 密集计算放在后台线程里。 除非你用 C 扩展并且它主动释放 GIL。
  • 原则二:如果必须在后台做计算,用进程 + 队列向主线程发结果。 比如用 multiprocessing.Queue 把计算结果发回主线程,主线程再更新 UI。
我的小习惯: 写 GUI 程序时,我会在后台线程里加一个 time.sleep(0.1) 的循环检查队列。这样既不会太频繁占用 GIL,也能及时获取结果。注意,这里不是让你在计算循环里 sleep,那会拖慢任务本身——我是说轮询队列的循环。

好了,threading 基础你先消化一下。下一段内容其实也跟 GIL 有关——我们聊聊怎么用 multiprocessing 彻底绕过它,以及如何优雅地管理子进程的生命周期。但这里先打住,先把 threading 的用法和 GIL 的机理融会贯通。