你知道进程、线程、协程的区别吗?一文搞懂为什么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。每次切换,内核需要:

  1. 把当前进程的寄存器状态、程序计数器保存到 PCB(进程控制块)
  2. 加载下一个进程的 PCB
  3. 刷新 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 DataLoadernum_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 密集任务(数据预处理)用多进程真正并行。选对并发模型,性能差距可以是数量级的。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐