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 的。
为什么有 GIL?
这其实是为了简化内存管理——CPython 的引用计数不是线程安全的,如果没有 GIL,你每次操作变量都得加非常细粒度的锁,性能反而更差。说白了,GIL 是 Python 早期设计的一个折中。现在虽然有很多尝试去掉 GIL(比如 nogil 分支),但主流 CPython 里它还在。
GIL 对 UI 线程的影响
回到咱们的课程主题——后台线程到 UI 渲染。你在后台线程里跑耗时计算,或者大量循环,GIL 会阻塞住主线程的 Python 字节码执行。主线程没法及时响应 UI 事件、重绘界面。结果就是:界面卡死。
具体来说:
- 后台线程持有 GIL 时,主线程要等待 GIL 释放才能执行。
- CPython 每执行 100 个字节码就会主动释放 GIL(Python 3.2 之后是每 5 毫秒)。
- 但如果是 C 扩展(比如 numpy、opencv),它们在处理期间不会释放 GIL。
所以你后台用 numpy 做大量矩阵计算,GIL 一直不释放,主线程惨了。
怎么规避?
- CPU 密集任务: 用 multiprocessing 模块开子进程,每个进程有自己的 GIL,才能并行。
- IO 密集任务: 多线程是好的,因为 IO 等待时会主动释放 GIL。
- 混合作业: 可以用 ThreadPoolExecutor + 异步 IO(asyncio)组合。
- 如果你的后台线程必须做 CPU 计算且不想卡 UI: 将大计算切分成小块,每块之间
time.sleep(0)主动让出 GIL(实际上很不可靠),或者用 subprocess 调用外部进程。
一张图理清线程、GIL 与 UI 渲染的关系
图中清晰展示了:主线程和后台线程都要争抢 GIL。当后台线程持有 GIL 且不主动释放时,主线程无法处理 UI 事件,界面就卡了。所以你要么让后台线程做 IO 操作(自动释放 GIL),要么干脆分进程跑。
实战建议:让 UI 不卡的两个核心原则
- 原则一:绝不把 CPU 密集计算放在后台线程里。 除非你用 C 扩展并且它主动释放 GIL。
- 原则二:如果必须在后台做计算,用进程 + 队列向主线程发结果。 比如用
multiprocessing.Queue把计算结果发回主线程,主线程再更新 UI。
time.sleep(0.1) 的循环检查队列。这样既不会太频繁占用 GIL,也能及时获取结果。注意,这里不是让你在计算循环里 sleep,那会拖慢任务本身——我是说轮询队列的循环。
好了,threading 基础你先消化一下。下一段内容其实也跟 GIL 有关——我们聊聊怎么用 multiprocessing 彻底绕过它,以及如何优雅地管理子进程的生命周期。但这里先打住,先把 threading 的用法和 GIL 的机理融会贯通。