返回

进程、线程、协程详解(Python 视角)

只聊 Python 里的进程、线程、协程:GIL、multiprocessing、threading、asyncio,以及怎么选。

系列说明|这是 易混专栏进程 主题下的一篇。同主题另有 Go 视角,可以单独看,也可以对照读。

这篇文章只聊 Python 一门语言里的进程、线程、协程:它们分别是什么、实际怎么写、开销差多少、什么时候该用哪个。

读之前先记住一句话:在 Python 里写并发,绕不开一把叫 GIL 的锁。它导致默认情况下多线程跑 CPU 任务并不能变快,所以 Python 的选型思路和 Go 这类语言完全不一样。后面所有”该用哪个”的判断,都建立在这条上。

一、为什么 Python 的并发要单独讲

很多语言讲并发时,基本默认”开几个线程就能把多核用满”。Python 做不到,原因是 CPython 解释器里有一把全局解释器锁,简称 GIL:同一个进程里,同一时刻只允许一个线程真正在跑 Python 代码。这不是设计者故意为难你,而是一个具体的取舍,我们得先弄明白它到底解决了什么。

CPython 管理对象的内存,靠的是引用计数。简单说,每个对象都记着”现在有几个地方在用我”,一旦这个数字变成 0,对象立刻被回收。问题在于,这个”数字加一、减一”的动作并不是一气呵成、不可打断的。如果两个线程同时去引用同一个对象,计数器的自增可能交错在一起,结果少算一次——要么对象被提前释放(留下一个指向已销毁内存的悬空引用,程序直接崩),要么永远不释放(内存泄漏)。要想在多线程下保证引用计数不出错,要么给每个对象配一把细粒度的锁(开销大、还容易死锁),要么干脆用一把大锁把整个解释器锁住。Python 选了后者,GIL 就是这么来的:它的初衷不是为了限制你,而是为了让单线程跑得快、实现起来简单,是个早期的历史妥协。

代价也很现实。在多核机器上你开 8 个线程去跑 CPU 任务,实际还是只有一个核在跑 Python 代码,甚至可能因为多个线程反复抢锁、反复切换,比单线程还要慢。所以 Python 的并发必须分两条路走:想真正并行、吃满多核做 CPU 计算,得用 multiprocessing,每个进程各有一把独立的 GIL,互不干扰;做 I/O 密集的任务(网络请求、读写磁盘),等待的时候线程会主动让出 GIL,所以多线程和 asyncio 都能靠”等待时让位”来实现高吞吐,根本不需要并行。这就是 Python 和 Go 在并发思路上最根本的分叉,后面每一节都建立在这个分叉之上。

图解:一把 GIL,还是多把 GIL

threading · 一把 GIL同时只有一个线程在跑 Python核再多也排队multiprocessing · 各一把每进程独立 GIL · 真并行对齐核数最香

二、进程:multiprocessing

先说进程是什么。进程是操作系统分配资源的最小单位,也是隔离最彻底的执行体。每个进程都有自己独立的一块内存空间、独立的文件句柄表,当然也各持有一把 GIL。一个进程如果崩了(比如访问了非法内存),通常不会直接拖垮别的进程,因为大家的内存互不相干;两个进程之间想交换数据,必须借助内核提供的专门机制,不能直接读对方的内存。Python 用 multiprocessing 模块来开进程,而且它的 API 故意设计得和 threading 很像(同样是 start、join 这些名字,同样是 Lock、Queue 这种叫法),目的就是让你从多线程代码迁到多进程时改动最小。

启动子进程的三种方式

multiprocessing 起子进程有三种方式,差别在于”子进程一开始的内存从哪来”,这直接决定了启动快慢和安不安全。

spawn 是 macOS 和 Windows 的默认方式,也是三者里最安全、但最慢的。它不复制父进程的内存,而是重新启动一个干净的 Python 解释器,然后重新导入你的主模块,再从指定入口开始跑。正因为要重新导入主模块,如果你的执行代码直接写在文件最外层(没有包在 if __name__ == “__main__”: 里面),子进程一导入就会又执行一遍、又去 spawn,陷入无限套娃。所以在 spawn 模式下,那个守卫语句不是可选项,是必须写的。

fork 是 Unix 系统历史上有过的默认方式,它直接把父进程的整个内存空间复制一份给子进程,速度极快,子进程瞬间就拥有父进程的所有变量。但它有个致命隐患:如果在已经有线程在跑的进程里 fork,子进程只会复制那个调用 fork 的线程,其他线程手里的锁在子进程里永远停在”已锁”状态,一旦去访问就会死锁。forkserver 是 Unix 上的一剂折中:先起一个干净的单线程服务进程,之后所有干活的进程都由这个干净进程复制出来,既快,又绕开了”带着锁去 fork”的坑。

进程池与通信

实际开发里几乎不会手动一个一个 Process.start(),而是用 multiprocessing.Pool 或者 concurrent.futures.ProcessPoolExecutor,把一批 CPU 任务自动分发到多个核上;当进程数对齐核数时,并行度最高。通信方面,Queue 是最常用的进程安全队列,Pipe 是双向的字节管道,两者底层都是靠序列化(pickle,把对象转成字节流)在进程之间搬数据。如果你嫌序列化太慢,可以用共享内存 Value / Array,它真的在一块大家都能访问的内存上操作,读写最快,但正因为绕过了 Queue 的保护,你得自己加锁。Manager 则把一个对象(比如 dict、list)代理到一个专门的管理进程,调用方像操作本地对象一样去访问,灵活,但每次访问都要走一次进程间通信,最慢。

选型上记住一个口诀:高频小数据走共享内存省序列化,解耦的生产消费走 Queue,跨机器才走 Socket。下面这段代码把四份 CPU 任务分发到 4 个核上并行跑,注意入口必须包在 __main__ 守卫里。

import multiprocessing, time, math

def cpu_task(n):
    return sum(math.sqrt(i) for i in range(n))

if __name__ == "__main__":
    data = [3_000_000] * 4
    t0 = time.perf_counter()
    with multiprocessing.Pool(4) as p:
        print(p.map(cpu_task, data))
    print("elapsed", time.perf_counter() - t0)

进程适合什么场景,说起来很明确:CPU 密集、需要真正并行、隔离要求高的任务。代价也同样明确:创建贵(本机 spawn 约 20ms,比线程贵几百倍),通信要序列化,开销远高于线程。所以它不该被用来做”轻量并发”,而是专门解决”并行计算”。

三、线程:threading 与 GIL

线程和进程最大的不同,是线程共享同一个进程的内存空间。所有变量、文件句柄、堆内存都是大家共用的,只有每个人自己的调用栈是独立的。这让线程的创建和通信都特别便宜——两个线程读写同一个变量,不需要任何序列化,直接读直接写就行。但便宜的代价是几乎没有隔离:一个线程写越界可能踩坏另一个线程的数据,一个线程崩了往往整个进程一起完蛋,所以线程之间必须靠锁来协调谁先谁后访问共享数据。

GIL 到底是什么,怎么运转的

GIL 是 CPython 为了内存安全引入的一把进程级的全局锁。任何线程想执行 Python 代码,都得先拿到这把锁。拿到锁的线程跑一段之后会主动释放,让别的线程有机会抢。早期的 CPython 是”执行了 100 条底层指令就释放一次”,3.2 之后改成了按时间片让出(默认每 5 毫秒),本质都是一种轮流用锁的协作机制。

这里有几个关键点,决定了 Python 多线程的真实表现。第一,GIL 只在执行 Python 代码本身时起作用;当你调用一个会释放 GIL 的 C 扩展(比如 numpy 算大数组、部分加密库、部分图像处理库),那段计算其实是真正并行的,因为 C 代码执行期间 GIL 是被放开的状态。第二,做 I/O 操作(网络、磁盘)时,线程会阻塞在系统调用上,阻塞前会释放 GIL,所以多个线程做 I/O 时,等待的一方让出锁、另一方就能跑,多线程 I/O 依然有效。第三,纯 Python 的 CPU 计算全程都抱着 GIL,多核也只有一个核在跑代码,这就是”多线程跑 CPU 任务不但不加速、反而更慢”的根因。

GIL 为什么一直没去掉?原因有三:一是历史包袱,大量 C 扩展都假设”有 GIL 在就不需要自己加锁”;二是彻底移除要把引用计数改成更精细的并发方案,工作量巨大,而且会拖慢单线程;三是社区一度认为多进程和 asyncio 已经提供了并行的出路。到 2026 年的现状是:默认发行的 CPython 仍然带 GIL;3.13 起提供了实验性的 free-threaded(无 GIL)构建,用对象级的细粒度锁替代 GIL;3.14 把这个 free-threaded 构建转为官方支持,但仍是可选版本,单线程性能相比带 GIL 版本会有 5% 到 10% 的下降。换句话说,默认版本到今天 GIL 还在,面试里”Python 多线程能不能并行”的标准答案依然是”不能,除非用 free-threaded 或者换成多进程”。

线程安全的工具

既然共享内存不加保护就会出数据竞争,threading 就提供了一整套同步工具。Lock 是最基础的互斥锁,一段代码进锁后别的线程必须等它出锁;RLock 是可重入锁,同一个线程可以多次加锁而不会把自己锁死,适合有嵌套调用的场景。Condition 在 Lock 之上提供了”等到某个条件成立”的能力,典型用于生产者消费者;Semaphore 用计数器来限流,允许 N 个线程同时进入;Event 是一个简单的开/关信号,一个线程 set 之后所有等待的线程被唤醒。threading.local 则是反过来的思路——它给每个线程一个独立副本,让”看起来像全局变量、其实是线程私有”的数据不需要加锁。下面这段代码演示了不加锁会算错的共享计数,用 Lock 把自增包起来之后结果才正确。

import threading, time

counter = 0
lock = threading.Lock()

def worker():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(counter)  # 400000,加锁后无误

线程适合 I/O 密集的场景,也适合调用那些会释放 GIL 的 C 扩展。如果你只是想在 CPU 上并行,线程帮不上忙,得上一段讲的多进程。

四、协程:asyncio

Python 的协程是跑在单个线程、单个事件循环上的用户态调度实体。它和 Go 那种”每个协程有自己独立调用栈”不同,asyncio 是 stackless(无栈)的——协程没有自己独立的调用栈,它的执行状态以协程对象的形式存在堆内存上,靠类似生成器的方式记录”暂停在哪、下次从哪恢复”,在解释器层面做切换。

理解 asyncio,先抓几个核心概念。用 async def 定义的协程函数,调用它并不会立刻执行函数体,而是返回一个协程对象,相当于”一张还没开始跑的待办单”;真正让它跑起来的是 await,await 会驱动协程执行到第一个暂停点,然后把手里的控制权交回事件循环。Task 是把协程对象包装之后提交给事件循环去调度的容器,事件循环负责在合适的时机推进它;Future 是更底层的”将来会有结果”的占位对象,Task 本质上是 Future 的子类。asyncio.run 负责启动并管理整个事件循环的生命周期,create_task 把协程提交成任务,gather / wait 用来并发地等一组任务,3.11 之后新增的 TaskGroup 还提供了结构化并发——一个任务出错会取消同组其余任务,避免出现孤儿协程。

这里有个特别容易踩的坑:同步原语不能混用。asyncio 自己也有 Lock、Event、Queue、Semaphore,但它们是 async 版本,必须用 await 来获取和释放。如果你在 async 函数里直接用了 threading.Lock,由于它锁住的是”线程”而不是”协程”,而 asyncio 在单线程内切换协程并不会释放线程锁,结果就是:持锁的协程被 await 挂起、交出了控制权,另一个协程想拿同一把锁时,整个线程被卡死。所以在 async 的世界里必须用 asyncio 自己的同步原语,二者不能混。

import asyncio

async def fetch(name):
    await asyncio.sleep(0.1)  # 模拟 I/O
    return f"{name} done"

async def main():
    results = await asyncio.gather(fetch("a"), fetch("b"), fetch("c"))
    print(results)

asyncio.run(main())

图解:一个事件循环,一圈在等 I/O

eventloop正在跑await 等 I/O同刻只有一个在前进

底层真相值得讲透:asyncio 的协程本质是增强版生成器,每次 await 切换都要在解释器层面做对象分配、回调调度、状态保存,这些事都发生在 Python 代码的层面。本机实测下来,asyncio 每次 await 切换大约 19 微秒,反而比操作系统线程的内核级切换(典型 1 到 10 微秒)更慢。所以 asyncio 的强项从来不是”切换快”,而是”在单个线程内避开了线程之间的锁竞争和 GIL 下的并行失效”——它用单线程内的协作调度,换取了海量连接下极低的内存占用和没有锁开销的高吞吐。也正因如此,asyncio 不适合 CPU 密集:一旦某个协程里跑了长时间计算,它霸占着事件循环不主动 await,其他所有协程都会被饿死。

图解:创建成本差一个数量级还多

创建成本(本机量级)进程 ~20ms线程 ~50us协程 ~5us条越长越贵。便宜不等于能并行,贵也不等于该躲开。

五、三者横向对比(Python 视角)

维度进程 multiprocessing线程 threading协程 asyncio
创建成本~20ms/个(spawn 重导模块)~50us/个~5us/个(Task)
常驻内存独立地址空间,最重~17KB/线程(虚拟栈 1–8MB 惰性分配)帧在堆上,Task 仅数百字节
切换成本内核级,1–10us 且刷 TLB内核级,1–10us~19us(await,解释器层)
能否并行 CPU能(每进程独立 GIL)不能(GIL 限制)不能(单线程)
通信方式Queue / Pipe / 共享内存共享内存 + Lockawait / asyncio.Queue
隔离性强,崩溃互不影响弱,一崩全崩弱,一崩整个循环
典型场景CPU 密集并行I/O 密集、C 扩展释放 GIL高并发 I/O、海量连接

六、本机实测基准

下面这些数字来自本机实测(Apple M5 / Python 3.13.12,热身后的稳定值),不是凭感觉估的:

对象指标数值
threading.Thread创建 + join~50us/个
threading.Thread常驻内存~17KB/线程
multiprocessing.Process创建 + join(spawn)~20ms/个
asyncio.Task创建~5us/个
asyncio await调度切换~19us/次

GIL 对 CPU 密集的影响,本机实测(4 核上跑 4 份 sum(sqrt) 任务)给出了一个很直观的反例:单线程 0.195 秒跑完;开两个线程反而要 0.248 秒,更慢了,多出来的时间就是抢 GIL 的调度开销;开两个进程 0.224 秒,确实并行了,但进程调度和序列化略微拖慢,所以没达到理论上的一半时间。这个反例是判断任务类型的第一性原理——先判断是 CPU 密集还是 I/O 密集,再决定选型,这也是面试必问的点。

记住这个反例:Python 多线程跑 CPU 任务比单线程还慢。所以判断任务类型(CPU 密集 vs I/O 密集),是选型的第一步。

图解:本机反例,三条线比一比

单线程0.195s两线程0.248s · 更慢两进程0.224s · 并行了同量 sum(sqrt) 任务。抢 GIL 会把热闹变成排队。

七、调度模型深挖

GIL 与 free-threaded

在默认的构建里,GIL 把 Python 代码的执行串行化了,多线程 CPU 任务被锁死在单核。3.13 之后提供的 free-threaded 构建移除了 GIL,改用对象级的细粒度锁,多线程 CPU 任务能真正并行,代价是单线程性能略降(5% 到 10%)。但它是可选版本,默认发行的还是带 GIL 的。如果你在多核上跑纯 Python 计算、又没法改算法,free-threaded 是值得一试的路,只是要确认你依赖的 C 扩展也支持无 GIL 构建。

asyncio 事件循环

事件循环是单线程、协作式的调度核心。底层它借用了操作系统提供的 I/O 多路复用机制(Linux 的 epoll、macOS 的 kqueue)来同时盯着海量 socket,哪个 socket 就绪了,就唤醒对应的协程继续往下跑。协程之间靠 await 主动让出控制权,这意味着一个协程如果不写 await,就会一直占着循环,别人永远没机会——协作式调度的软肋就在这里:它依赖每个协程”自觉”让出。早期的 asyncio 用 @asyncio.coroutine 装饰器加 yield from 来写协程,现在统一成了 async / await 语法糖,读起来顺多了。

multiprocessing

每个子进程都是独立的 Python 解释器,各有各的 GIL 和内存空间,调度完全交给操作系统。它真的能并行,但因为跨进程通信要序列化(pickle),数据搬运的开销比线程共享内存大得多。这也是为什么进程适合”粗粒度、少通信”的并行任务,而不适合”频繁交换小数据”的场景。

八、选型指南

选型的核心是先给任务分类,再选模型,而不是反过来。CPU 密集、又需要并行的任务,用 multiprocessing / ProcessPoolExecutor,进程数对齐核数,这是 Python 里唯一能真正用满多核跑纯 Python 计算的路。I/O 密集、而且连接数极高(比如万级的 WebSocket、海量并发请求)的任务,用 asyncio,单线程调度海量协程,内存和切换成本远低于多线程。I/O 密集、但逻辑简单、你不想写 async 代码的任务,用多线程 threading 就够,直观也够用。混合型任务——少量 CPU 计算加大量 I/O,可以用 asyncio 做主循环,再用 loop.run_in_executor 把 CPU 活丢给线程池或进程池,让阻塞的计算不卡住事件循环。一句话总结:要并行选进程,高并发 I/O 选 asyncio,简单 I/O 选线程。

图解:先问任务,再选瓶子

先问:任务是什么CPU 密集multiprocessing高并发 I/Oasyncio简单 I/Othreading

九、常见坑

GIL 幻觉是最典型的误判:以为开多线程就能加速 CPU 任务,结果更慢。破解方法永远是先判断任务类型,CPU 密集就立刻换多进程。asyncio 里混进同步阻塞是另一个高频坑——在 async 函数里调用 time.sleep 或者同步的网络库,整个事件循环会停摆,因为同步调用不会让出控制权,正确做法是换成 await asyncio.sleep 或者异步客户端(比如 aiohttp)。僵尸进程来自父进程没有 join、没有回收子进程,子进程退出后变成僵尸占着进程号,用进程池统一回收最省心。数据竞争是多线程共享变量不加锁导致的偶发错值,最难复现,优先用 Queue 或者原子操作而不是裸共享。数量失控指无上限地建线程 / 协程,打满内存或者把内核调度压垮,用 Semaphore 或者协程池来限流。最后,Lock 混用——把 threading.Lock 塞进 async 函数会死锁,必须用 asyncio 自己的同步原语。

十、面试高频考点(Python 视角)

Q1. 进程、线程、协程的区别?

从三个维度看。资源隔离:进程有独立内存空间,线程共享进程空间但各自有独立的栈,协程(asyncio)的执行状态在堆上、没有独立栈。调度方式:进程和线程由内核抢占式调度,协程由事件循环在用户态协作式调度。并行能力:进程能真并行(各一把 GIL),线程受 GIL 限制不能并行 CPU,协程单线程更不行。切换成本依次递减,但 Python 的 asyncio 因为解释器开销,实测切换反而比线程慢。

Q2. GIL 是什么?影响什么?2026 现状?

全局解释器锁,限制同一进程内同时只有一个线程执行 Python 代码。影响:多线程 CPU 任务无法并行;I/O 密集仍然有用,因为等待时会释放 GIL。现状:默认 CPython 仍有 GIL;3.13 起实验性 free-threaded,3.14 转官方支持但仍可选,单线程差距收窄到 5% 到 10%。

Q3. 单线程的协程能并行吗?

不能。单个事件循环同一时刻只跑一个协程,所谓并发只是快速切换的假象。要并行得靠多进程或多线程承载多个事件循环,例如 loop.run_in_executor 把活丢给进程池 / 线程池。

Q4. 协程为什么快?和线程本质区别?

快在用户态调度、不进内核、不刷 TLB、协作式让出。本质区别在调度权归属:线程是内核调度实体(抢占式),协程调度权在事件循环(协作式)。反例:asyncio 切换约 19 微秒,反而比 OS 线程 1 到 10 微秒慢,它赢在避开 GIL 失效和锁竞争,而不是切换快。

Q5. 进程间通信有哪些?

Queue、Pipe、共享内存(Value / Array,最快但要加锁)、Manager(代理对象,灵活但慢)、Socket。高频大数据走共享内存,解耦的生产消费走 Queue,跨网络走 Socket。

Q6. threading.Lock 和 asyncio.Lock 区别?

前者给 OS 线程用,阻塞时让出 CPU;后者给协程用,await 时挂起协程而不阻塞整个线程。混用(在 async 里用 threading.Lock)会死锁,因为锁住的是线程而非协程。

Q7. CPU 密集任务怎么并行?

用 multiprocessing / ProcessPoolExecutor,每个子进程独立 GIL,能真正用满多核。asyncio 和多线程都做不到 CPU 并行。

Q8. 上下文切换差在哪?

进程切换要换页表、刷 TLB,最重;线程切换换寄存器栈、不刷 TLB,较轻;协程切换只换少量寄存器、不进内核,最轻。但 Python asyncio 因为解释器开销,实测切换反而比线程慢。

Q9. 为什么 asyncio 适合高并发 I/O?

单线程事件循环用 epoll / kqueue 同时监听海量 socket,哪个就绪唤醒哪个协程,开销是协程级(数百字节)而非线程级(数十 KB)。万级连接下内存和切换成本远低于多线程。

相关阅读

本文是 Python 视角的独立文章。若要对照 Go 视角,见同主题 进程、线程、协程详解(Go 视角)。继续深入并发与性能,可看 高性能后端实战