threading.Thread(target=...),也大概率听过「Python 有 GIL,多线程没用」这句江湖口诀。但下面这几个问题,你能立刻答出来吗?asyncio 的协程为什么不受 GIL 限制,它和线程到底差在哪?昨天我们说过,进程是「一个执行中的程序实例」,它拥有自己的独立地址空间、文件描述符表、信号处理表等等。今天的线程(thread),可以最简洁地定义为:
用一个数学家熟悉的类比:如果进程是一本「独立的草稿本」,那么线程就是同一本草稿本上同时演算的几支笔——纸(堆/全局变量)共享,但每支笔都有自己当前停在哪一行(PC)、握笔的姿势(寄存器)、各自的演算用稿(栈)。
为什么要有线程?因为创建进程的成本太高——要复制整个页表、复制文件描述符表、新建一套 PCB。而线程只需要新建一个栈和一组寄存器上下文,所以创建成本可以低 1~2 个数量级,上下文切换也更快(不需要换页表,TLB 不会被全部冲走)。
| 维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立(fork 时 COW) | 共享 |
| 文件描述符表 | 独立 | 共享 |
| 栈 | 1 个 | 每个线程 1 个(默认 8MB) |
| 创建代价 | 高(µs 级 ~ 数百 µs) | 低(µs 级 ~ 数十 µs) |
| 通信 | 需要 IPC(管道、共享内存…) | 直接读写共享变量 |
| 崩溃影响 | 只死自己 | 一个段错误整个进程都死 |
注意最后一行——这是「线程更轻但更危险」的根源:一个野指针写穿了堆,所有兄弟线程都会一起殉葬。所以高安全/高可靠场景(浏览器的 site isolation、Chrome 的多进程架构)反而会反其道而行,刻意用进程隔离换稳定。
线程是「概念」,落到操作系统层面有三种实现方式。理解这三种,是看懂 Go goroutine、Java green thread、Python asyncio 等所有「现代并发」的钥匙。
线程的调度完全在用户态库里完成,内核根本不知道你有多少线程——它只看到一个进程。优点是切换极快(不需要陷入内核)、可以创建几十万个;缺点是一个线程的阻塞 syscall 会阻塞所有线程(因为内核眼里只有一个执行流),而且不能利用多核。早期的 GNU Pth、Java green thread、Ruby 1.8 都是这种。
每个线程在内核里都有对应的调度实体。Linux 的 NPTL、Windows 线程都是这种。优点是真正并行、阻塞一个不影响别的;缺点是创建/切换需要进内核,开销大,难以支持「几十万级别」的线程。
M 个用户线程映射到 N 个内核线程上。Go 的 goroutine 是这种模型最成功的现代代表——M 个 goroutine(可达百万)由 Go runtime 调度到 N 个 OS 线程(通常 = CPU 核数)上。它兼顾了「轻量」和「真并行」。
Python 的 threading 是纯 1:1 内核线程,但因为 GIL 又让人误以为是 N:1 的用户线程——这就是一切混乱的开始。我们一会儿就解决它。
NPTL(Native POSIX Thread Library)是 Linux 自 2.6 内核起的官方线程实现,替代了早期那个臭名昭著的 LinuxThreads。NPTL 的设计哲学有一个惊人的极简结论:
实现这一切的核心系统调用,是 clone()。fork() 在内核里其实就是一个特定参数组合的 clone():
# clone() 关键标志位(与 fork 对比)
#
# fork() 等价于:
# clone(SIGCHLD) ← 完全不共享
#
# 创建线程(pthread_create 内部调用):
# clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
# CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS |
# CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID, ...)
#
# 每个 CLONE_xxx 标志 = 「这部分资源要共享」
你能看到:CLONE_VM=共享虚拟内存、CLONE_FILES=共享文件描述符表、CLONE_THREAD=放到同一个线程组(共享 TGID)。把这堆 flag 拼起来,就是「Linux 线程」;把所有 flag 都关掉,就是「Linux 进程」。线程和进程不是两个东西,是同一个东西的两端连续值。
# 写一个最简单的 C 线程程序
cat > t.c << 'EOF'
#include <pthread.h>
#include <stdio.h>
void* worker(void* arg) { printf("hi\n"); return NULL; }
int main() {
pthread_t tid;
pthread_create(&tid, NULL, worker, NULL);
pthread_join(tid, NULL);
return 0;
}
EOF
gcc t.c -o t -lpthread
strace -f -e trace=clone,clone3 ./t # 看到一行 clone3(...) 调用
你会看到一条 clone3() 或 clone() 的系统调用,flags 里有一长串 CLONE_VM|CLONE_FS|...,这就是 NPTL 在底下做的工作。
既然线程共享地址空间,那「全局变量」自然是所有线程都能看到的。但有时你想要「每个线程都有一份自己的全局变量」——比如 errno,每个线程的 syscall 失败码必须互不干扰。这就需要 TLS(Thread-Local Storage)。
TLS 的实现机制(x86-64 上):每个线程通过 fs 寄存器指向自己的 TLS 区。访问一个 TLS 变量时,编译器生成的是「%fs:offset」的相对寻址,而不是绝对地址——这样同一段代码在不同线程里访问的就是不同的物理位置。
# C 语言 TLS
__thread int counter = 0; // GCC 扩展,每个线程一份
# Python TLS
import threading
local = threading.local() # 每个线程访问 local.x 都是自己的副本
local.user_id = 42
在 Python 的 Web 框架(Flask 的 g、Django 的 request context)里,TLS 是处理「请求级别上下文」的经典武器。但要小心:async 协程下 threading.local 会泄漏——因为多个协程跑在同一个线程上,会读到彼此的「私有」数据。这时要用 contextvars.ContextVar 替代。
虽然你天天用 Python,但 pthread(POSIX Threads)API 是所有线程库的「公分母」——Python 的 threading、Java 的 java.lang.Thread、Rust 的 std::thread、底下都是 pthread。看懂 pthread 的几个核心 API,理解任何高级线程库都会轻松。
// 创建 + 等待
int pthread_create(pthread_t *tid, attr, void *(*start)(void*), void *arg);
int pthread_join(pthread_t tid, void **retval); // 等待线程结束
int pthread_detach(pthread_t tid); // 不等待,资源自动回收
// 互斥锁
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&m);
/* critical section */
pthread_mutex_unlock(&m);
// 条件变量
pthread_cond_t c = PTHREAD_COND_INITIALIZER;
pthread_cond_wait(&c, &m); // 必须配合 mutex
pthread_cond_signal(&c); // 唤醒一个
pthread_cond_broadcast(&c); // 唤醒所有
// 读写锁
pthread_rwlock_t rw;
pthread_rwlock_rdlock(&rw);
pthread_rwlock_wrlock(&rw);
记住条件变量必须配合 mutex 使用——因为「检查条件 + 进入等待」必须是原子的,否则会丢失唤醒信号。这是 OS 课考试题里反复出现的坑。Python 的 threading.Condition 把 mutex 内嵌了,所以接口看起来更简单,但本质一样。
终于到了今天的主菜。GIL(Global Interpreter Lock)是 CPython 解释器内部的一把互斥锁,它保证同一时刻,只有一个线程在执行 Python 字节码。
注意三个限定词:
为什么 CPython 要有 GIL?因为 Python 的对象都是引用计数 (refcount) 管理内存——每次给变量赋值、把对象传进函数、放进列表,都会有 Py_INCREF/Py_DECREF。如果允许多线程真并行执行字节码,那 refcount 的读改写必须用原子操作,每个最常见的操作都要付出原子指令成本,整个解释器就慢得不能用。GIL 是一个「为了单线程性能,牺牲多线程并行」的工程决策。
Python 3.2 之前(旧 GIL):基于「字节码计数」,每执行 100 条字节码就检查一次是否要释放 GIL。这种方式在多核上会出现「护航效应」:CPU 线程刚释放 GIL,IO 线程被唤醒,但还没抢到 GIL,CPU 线程又把它抢回去了——IO 响应延迟巨大。
Python 3.2+(新 GIL,由 Antoine Pitrou 改进):基于时间片。默认 sys.setswitchinterval(0.005),即 5ms 一切换。机制变成:线程持有 GIL 跑一段时间后,主动设置「需要切换」标志,其他线程被唤醒来抢锁。代码大致逻辑:
# CPython 内核里的简化伪代码 (ceval.c)
while (1) {
if (gil_drop_request) { // 有线程要求切换
release_gil();
# 这里给其他线程一个抢锁的窗口
acquire_gil();
}
opcode = NEXT_BYTECODE();
dispatch(opcode); // 执行字节码
}
所以 GIL 不是「Python 用不了多线程」,而是「Python 多线程不能在多核上真正并行跑字节码」。这两个表述在工程上差别巨大。
import threading, time
x = 0
def inc():
global x
for _ in range(10_000_000):
x += 1
t1 = threading.Thread(target=inc)
t2 = threading.Thread(target=inc)
t0 = time.time()
t1.start(); t2.start(); t1.join(); t2.join()
print(time.time() - t0, x)
# Python 3.11: 约 1.5-2.5 秒,x 不是 20_000_000 (竞态)
你会发现两个现象:(1) 速度没有变快——双线程版本几乎和单线程一样慢、甚至更慢(因为切换开销);(2) 结果 x 通常不是 20_000_000——GIL 并不保证 x += 1 是原子操作,因为它会被翻译成多条字节码(LOAD/ADD/STORE),中间是可以切换的。GIL 保护的是解释器内部数据结构,不是你的业务变量。
下面这张表是你用 Python 写并发时最重要的决策依据,请记牢:
| 工作负载 | 例子 | 持锁情况 | 多线程是否加速? | 该用什么 |
|---|---|---|---|---|
| 纯 Python CPU | 循环、字符串处理、纯 Python 数学 | 持锁 | 否,反而变慢 | multiprocessing / Cython / Rust 扩展 |
| NumPy / PyTorch 计算 | 矩阵乘、卷积 | 不持锁(C 扩展自己释放) | 是,能跑满多核 | threading 或交给底层 BLAS |
| 阻塞 IO | requests, socket.recv, 文件读写 | 不持锁(阻塞前主动释放) | 是,N 个并发可达 N 倍 | threading 或 asyncio |
| 异步 IO | aiohttp、asyncpg | 单线程 event loop | 不需要多线程 | asyncio |
所以那句口诀「Python 多线程没用」是错的,正确版本是:「Python 多线程在 CPU-bound 的纯 Python 代码上没用,但在 IO-bound 和 C 扩展计算上是非常有用的」。这也解释了为什么 Web 框架(Flask, Django)愿意用多线程跑 worker——绝大部分时间都在等数据库、等网络。
# cpu_bound.py —— 多线程比单线程慢
import threading, time
def burn():
n = 0
for _ in range(50_000_000): n += 1
t0 = time.time(); burn(); burn(); print("seq:", time.time()-t0)
t0 = time.time()
ts = [threading.Thread(target=burn) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print("thr:", time.time()-t0)
# io_bound.py —— 多线程是单线程的 N 倍
import threading, time, urllib.request
def fetch(): urllib.request.urlopen("http://example.com").read()
t0 = time.time()
for _ in range(8): fetch()
print("seq:", time.time()-t0)
t0 = time.time()
ts = [threading.Thread(target=fetch) for _ in range(8)]
for t in ts: t.start()
for t in ts: t.join()
print("thr:", time.time()-t0)
python3.13t(free-threaded build),允许在编译时彻底关掉 GIL。代价是单线程性能下降 5-15%,且很多 C 扩展需要重新适配。这是 CPython 自诞生以来最大的内部架构变更,预计还要好几年才会成为默认。
协程(coroutine)是另一种维度上的并发:它在一个线程内,通过主动让出(yield/await)的方式,让多个执行流交替推进。它和线程的根本区别:
| 线程 | 协程 | |
|---|---|---|
| 调度者 | OS 内核(抢占式) | 事件循环(协作式) |
| 切换时机 | 时间片到、阻塞 syscall | 只在 await 点 |
| 切换成本 | µs 级(陷入内核 + 上下文换) | ns 级(函数调用级) |
| 数量上限 | 千级(栈占 8MB) | 百万级 |
| 是否真并行 | 多核可并行(受 GIL) | 单核单线程 |
那为什么协程「躲开了 GIL」?因为 GIL 本来就是用来防止多个 OS 线程同时跑字节码,而协程只有一个 OS 线程——根本不存在「多个执行流同时跑」的问题,自然不需要 GIL 来排队。事件循环就是一个超级简化的「用户态调度器」:
所以 async def + await 不是魔法,它本质上是一个「函数能暂停 + 事件循环」的组合。await some_coro() 在语义上等于「把当前协程的执行状态保存进堆,让出控制权给事件循环,等被通知就绪后再恢复」。
| 场景 | 推荐 | 原因 |
|---|---|---|
| CPU 密集(科学计算) | multiprocessing 或 NumPy/Torch | 绕开 GIL,吃满多核 |
| 少量长连接(< 100) | threading | 简单,代码同步风格 |
| 海量并发 IO(> 1000) | asyncio | 线程开不了那么多 |
| 需要在多核上跑模型推理 | 多进程 + 共享显存 | vLLM/SGLang 都是这种架构 |
1. vLLM 是多进程 + 多线程的混合。 当你看到 vLLM 启动后 ps 出来有多个进程,其中每个进程又有很多线程——这就是为了绕开 GIL 又同时利用多核:CUDA kernel 启动、IO 调度跑在 Python 线程(释放 GIL 时真并行),调度循环本身则单线程协程化。理解今天的内容能让你看懂 vLLM 的 worker 拓扑。
2. Agent 工具执行的并发风险。 一个 Agent 同时调用多个工具时,如果工具是同进程线程,那么一个工具的全局副作用(修改环境变量、改工作目录)会污染其他工具的执行。OpenAI Code Interpreter 和我们公司的沙箱产品都因此选择「每个工具一个独立进程/容器」——这就是用进程隔离换稳定的现实案例。
3. 推理服务的拒绝服务攻击面。 如果你的 LLM 网关用 Python threading 服务长连接,攻击者一旦撑爆线程池(默认 8MB/线程,1024 线程就是 8GB),整个进程 OOM。换成 asyncio 之后单进程能撑 10 万连接——但又要警惕「事件循环阻塞攻击」:一段恶意输入触发某个 CPU 密集的同步函数,整个 event loop 卡死。Day 105、Day 107 我们会详细讲限流和熔断。
4. 多 Agent 系统的竞态。 多个 Agent 共享一个知识库或工具状态时,GIL 给你的安全感是错的——Day 06 我们看到 x += 1 都不是原子的,更别说复杂的 dict update。Multi-Agent 框架里因为这种竞态导致工具状态错乱、记忆污染的 bug 非常常见,必须用显式锁保护共享可变状态。
5. TLS / ContextVar 的上下文泄漏漏洞。 在带认证的多租户 LLM 服务里,如果用 threading.local 存当前用户身份,而你的框架内部偷偷把请求切换到 async(许多新框架混用),就可能出现「用户 A 的请求读到了用户 B 的 user_id」——这是真实出现过的权限越权 CVE类问题。规范做法是 contextvars.ContextVar。
1动手 · 看 clone() 真容
找一台 Linux(或在 Docker 里 docker run -it --rm ubuntu bash),写一个 5 行的 Python 多线程程序,然后用:
strace -f -e trace=clone,clone3 python3 your_script.py 2>&1 | head -20
观察 clone 的 flags 里有哪些 CLONE_VM|CLONE_FS|CLONE_FILES|...。问题:为什么会看到「clone」而不是「pthread_create」?
2观察 · GIL 的边界
跑文中的 cpu_bound.py 和 io_bound.py,记录单线程 vs 多线程时间。追问:把 cpu_bound.py 里的 n += 1 换成 np.dot(A, B)(A, B 是 1000×1000 矩阵),再观察——为什么这次多线程变快了?提示:NumPy 的 C 扩展会做什么?
3思考 · 协程取代线程?
如果协程这么便宜(百万级)、又躲开了 GIL,那为什么 Python 还要保留 threading 模块?至少想出三个 threading 仍然不可替代的场景。提示:思考「调用了一个会阻塞但没有 async 版本的旧库」、「需要在多核上真正并行」、「想用 signal.signal 处理 Ctrl+C」分别会怎样。