你知道进程、线程、协程的区别吗?一文搞懂为什么python的 8 线程会比单线程还慢
你知道进程、线程、协程的区别吗?一文搞懂为什么python的 8 线程会比单线程还慢
“A thread is just a process that shares everything.”
—— Linux Kernel 文档
你写过这样的代码吗?
import threading
results = []
def worker(x):
results.append(x * x)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print(results)
开了 8 个线程,满心期待能用满 8 个 CPU 核,结果跑完发现时间几乎没变——甚至比单线程还慢。
这不是 Bug,这是操作系统和 Python 运行时的联合"背刺"。理解它,需要从进程地址空间讲起。
4.1 进程:资源隔离的容器
操作系统用 进程(Process) 作为资源管理的基本单位。每个进程都拥有一套完整的、独立的资源:独立的内存地址空间、独立的文件描述符表、独立的信号处理。
进程地址空间
每个进程以为自己独占整块内存(这是虚拟内存的幻觉,第 5 章细讲)。地址空间从低到高分为几段:

图:左侧是进程地址空间的典型布局;右侧对比进程间隔离(需 IPC 通信)与线程间共享内存(需加锁)。
各段的职责:
| 段 | 内容 | 大小 |
|---|---|---|
| 代码段(Text) | 程序指令,只读 | 固定 |
| 数据段 | 已初始化的全局/静态变量 | 固定 |
| 堆(Heap) | malloc/new 动态分配,向上增长 |
动态 |
| 栈(Stack) | 函数调用帧、局部变量,向下增长 | 动态 |
栈是自动管理的——函数调用时压栈,返回时弹栈,速度极快。堆是手动(或 GC)管理的,灵活但有碎片风险,第 5 章会深入讨论。
上下文切换的代价
OS 用 上下文切换(Context Switch) 在多个进程间分时共享 CPU。每次切换,内核需要:
- 把当前进程的寄存器状态、程序计数器保存到 PCB(进程控制块)
- 加载下一个进程的 PCB
- 刷新 TLB(地址转换缓存,第 5 章讲)
这个过程需要 1~10 μs,频繁切换代价显著。这是为什么进程数不是越多越好。
fork() 与 Copy-on-Write
Unix 系统创建新进程用 fork()——把当前进程完整复制一份:
import os
pid = os.fork()
if pid == 0:
print(f"子进程 PID={os.getpid()}")
else:
print(f"父进程,子进程 PID={pid}")
复制整块内存听起来很慢,但实际上 fork() 非常快,原因是 Copy-on-Write(写时复制):fork 后父子进程共享相同的物理内存页,只有当某一方写入某页时,才真正复制那一页。读操作完全不复制。
AI 直接应用:PyTorch
DataLoader的num_workers > 0时,用的正是fork()。主进程 fork 出 N 个 worker 进程,每个 worker 拥有数据集对象的独立副本(靠 Copy-on-Write 高效实现),互不干扰地并行加载数据。这就是为什么 DataLoader 默认用多进程而不是多线程——因为多线程在 Python 里受 GIL 限制,无法真正并行执行 CPU 密集的数据解码。
4.2 线程:共享内存的双刃剑
线程(Thread) 是进程内的执行单元。同一进程的多个线程共享地址空间(堆、全局变量、文件描述符),但各自拥有独立的栈和寄存器状态。
线程比进程轻量:创建线程约 10~100 μs,创建进程约 1~10 ms;线程切换约 1 μs,进程切换约 10 μs。
共享带来的风险:竞态条件
共享内存是双刃剑。两个线程同时写一个变量:
counter = 0
def increment():
global counter
for _ in range(100_000):
counter += 1 # 这不是原子操作!
import threading
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter) # 期望 200000,实际可能是 137842 之类的随机值
counter += 1 在 CPU 层面是三步:读 → 加 → 写。两个线程可能交错执行,导致其中一次加法被覆盖。这就是竞态条件(Race Condition)。
解决方案是同步原语:
| 原语 | 作用 | 适用场景 |
|---|---|---|
Lock(互斥锁) |
同一时刻只有一个线程能进入临界区 | 保护共享变量 |
Semaphore(信号量) |
控制同时访问某资源的线程数量 | 限流、连接池 |
Condition(条件变量) |
线程等待某个条件成立再继续 | 生产者-消费者模型 |
Queue |
线程安全的队列,内置锁 | 线程间传递任务 |
import threading
counter = 0
lock = threading.Lock()
def safe_increment():
global counter
for _ in range(100_000):
with lock: # 自动 acquire/release
counter += 1
t1 = threading.Thread(target=safe_increment)
t2 = threading.Thread(target=safe_increment)
t1.start(); t2.start()
t1.join(); t2.join()
print(counter) # 稳定输出 200000
常见误区:加锁后,两个线程变成了串行——临界区里同时只有一个线程在跑。锁保证了正确性,但也消灭了并行性。锁的粒度越粗,并发收益越低;粒度太细又容易死锁。
4.3 Python 的三种并发模型

图:三种并发方式的适用场景、GIL 影响和典型用途对比。
GIL:Python 多线程的致命限制
CPython 有一把全局大锁:GIL(Global Interpreter Lock)。
规则:任何时刻只有一个线程能执行 Python 字节码。
存在理由是历史遗留问题:CPython 的引用计数垃圾回收不是线程安全的,加一把大锁是最简单的保护方式。

图:蓝色/红色深色块表示线程持有 GIL 正在运行,浅色块表示在等待 GIL 被阻塞。两个线程交替持锁,任意时刻只有一个在真正执行 Python 字节码。
GIL 何时会被释放?
- 每执行 N 个字节码(默认 100 条,Python 3.2+ 改为基于时间 5ms)
- 发起系统调用时(读文件、网络 I/O)——这是多线程在 I/O 密集场景有用的原因
- 调用 C Extension 时——NumPy、PyTorch 的底层计算都会释放 GIL
所以结论是:Python 多线程对 I/O 密集任务有效,对 CPU 密集任务几乎无效。
科普:Python 3.13(2024 年发布)引入了 free-threaded 模式(PEP 703),可以在编译时关闭 GIL(
python3.13t)。这是一个实验性特性,目前许多 C Extension(包括 NumPy、PyTorch)还在适配中。在 AI 领域,由于大量计算已经下沉到释放 GIL 的 C/CUDA 层,GIL 的移除对 AI 训练本身影响有限,但对纯 Python 数据预处理的并行化有潜在好处。
多进程(multiprocessing)
绕过 GIL 的根本方式:每个进程有独立解释器,不共享 GIL。
from multiprocessing import Pool
def cpu_heavy(x):
return sum(i*i for i in range(x))
with Pool(processes=8) as pool:
results = pool.map(cpu_heavy, [10**6] * 8)
代价:进程间通信需要序列化(Pickle),数据不能直接共享,启动开销更大。
DataLoader 的 num_workers 就是开多进程——每个 worker 独立解码图片/文本,完全并行,不受 GIL 限制。
asyncio 协程
asyncio 是单线程的,靠事件循环实现并发——不是并行,而是在等待 I/O 时主动让出控制权:
import asyncio, aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.text() # 等网络时,让出控制权给其他协程
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
results = await asyncio.gather(*tasks) # 并发发出所有请求
1000 个 HTTP 请求,多线程需要 1000 个线程(开销大),asyncio 用一个线程就能处理——等这个请求响应时,去处理其他请求的回调。
适用场景:大量并发 I/O(推理服务、API 网关),不适合 CPU 密集计算。
核心结论:进程是资源隔离的边界,线程是进程内的执行单元。Python 的 GIL 让多线程在 CPU 密集场景形同虚设——DataLoader 用多进程绕过 GIL 并行加载数据,推理服务用 asyncio 高并发处理请求,CPU 密集任务(数据预处理)用多进程真正并行。选对并发模型,性能差距可以是数量级的。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)