Python 与 Node.js 的并发模型
1. 并发相关概念
并发和并行的区别是是否真的在同时做多件事:
- 并发(Concurrency)看起来是在同时做多件事,但实际上可能只是一个线程在多个任务间切换。
- 并行(Parallelism)则真的同时做多件事,需要多个 CPU 核同时工作。
在编程过程中我们会遇到不同类型的任务:
- IO 密集:大部分时间在等网络、数据库、文件、LLM API 返回。
- CPU 密集:大部分时间在 CPU 计算上,比如图像处理、压缩、矩阵运算、本地推理。
之后我们要讨论的线程、进程、协程等概念本质上都是在解决 CPU 或者 IO 等待的空闲时间怎么利用的问题。
还需要先建立一个认知:并发是目标,异步是实现并发的一种方式。异步模型通过”发起操作后不阻塞等待、就绪后再恢复执行”来让单个线程同时推进大量任务;而多线程、多进程是另一条路——靠增加执行单元来实现并发。这篇文章就是要把这两条路在 Python 和 Node.js 里的具体形态讲清楚。
2. Python 并发模型
Python 中的并发模型有**协程(asyncio)、多线程(threading)、多进程(multiprocessing)**三种。在讨论这三种并发模型前,我们需要了解一下 Python
字节码与 GIL
Python 代码是怎么被执行的
对单纯的 .py 文件,CPython 并不能直接执行,它会采用下面的执行链:
源代码 (.py)
│ 词法分析、语法分析
▼
抽象语法树 (AST)
│ 编译
▼
字节码 (bytecode) ← 存在 code 对象里,可缓存为 __pycache__/*.pyc
│
▼
CPython 虚拟机逐条解释执行
字节码是 CPython 虚拟机的指令集,类似 Java 中的 .class。可以用 dis 模块直接看到它:
import dis
def add(a, b):
c = a + b
return c
dis.dis(add)
2 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 STORE_FAST 2 (c)
3 12 LOAD_FAST 2 (c)
14 RETURN_VALUE
CPython 虚拟机是基于栈的:
LOAD_FAST把局部变量压栈,BINARY_OP弹出两个操作数相加再压栈,STORE_FAST把栈顶存回局部变量。所谓的”解释型语言”,准确含义不是”一行行翻译源码”,而是”源码先整体编译成字节码,再由虚拟机逐条解释字节码”。
GIL:锁住字节码执行的锁
CPython 的内存管理靠引用计数,多线程同时增减引用计数会出问题,而给每个对象加锁的实验表明单线程性能会暴跌。于是 CPython 选了简单粗暴的方案:用一把大锁锁住整个解释器。这就是 GIL 做的事情:同一时刻,只允许一个线程在 CPython 解释器里执行字节码。
注意锁的对象是字节码执行而不是线程本身。
GIL 会在三种情况下释放:
- 进入阻塞 IO:
socket.recv()、文件读写、time.sleep()在钻进 C 层等操作系统之前会主动释放 GIL——反正接下来也不跑字节码了; - 定期让出:即使线程一直在计算,解释器也会每隔约 5ms(
sys.getswitchinterval())强制切换线程,防止某个线程饿死别人; - C 扩展主动释放:NumPy、Pillow 这类库在做重量级 C 层计算时会释放 GIL,那段时间不在执行字节码,不需要锁。
这三条直接决定了后面三种并发模型的适用场景。
多线程(threading)
threading.Thread 创建的是真实的 OS 线程,由操作系统抢占式调度。但因为 GIL,它能并发、但不能真的并行,具体而言:
- IO 密集是有效的并行:线程进入阻塞 IO 时释放 GIL、让其他线程跑;
- CPU 密集无法并行执行:纯 Python 计算一直在执行字节码,线程只能轮流拿锁,总耗时 ≈ 单线程,还因为抢锁和切换更慢一些;
如果重计算发生在会释放 GIL 的 C 扩展里(NumPy 矩阵乘法),多线程可以真正并行,因为前面说了 GIL 在这种情况下被释放。
多线程比协程强的地方是兼容性:它使用普通同步写法,市面上绝大多数老库(如 requests、各种 SDK)拿来就能用。但是代价也很大:
- 每个线程占 MB 级栈内存,很难同时开大量线程;
- 抢占式调度意味着任何一行代码中间都可能被切走,共享数据必须自己加锁,写不好就很容易导致竞态和死锁。
协程(asyncio)
协程是单线程内的协作式调度。
对于一个普通函数,一旦调用,要么在运行、要么已经结束,栈帧随 return 销毁,没有”暂停”的概念。而协程做的事就是让栈帧在”暂停”时不销毁,而是整个打包存下来,下次从断点原样恢复。这个机制由 Python 的生成器演化而来:
def gen():
x = 1
yield x # 第一次停在这
x = 100
yield x # 第二次停在这
g = gen() # 函数体一行都没执行,只拿到一个对象
next(g) # 跑到第一个 yield,产出 1,暂停
next(g) # 从断点继续:x = 100,产出 100,暂停
async def 协程就是这套机制的升级版:yield 换成 await,驱动它的从 next() 换成事件循环。一个 coroutine 对象,本质就是”一个暂停中的函数 + 它的全部状态”。整个时间循环的链路简要概述如下:
while True:
task = 就绪队列.popleft() # 挑一个该跑的协程
try:
task.coroutine.send(None) # 恢复它,就是一次普通的方法调用
except StopIteration:
pass # 它 return 了
# 它 await 了 → send() 返回 → 循环继续挑下一个
这就是”协程切换是一次函数跳转”的字面意思:没有中断、没有内核态、没有寄存器保存,比线程切换便宜两三个数量级。一个进程挂上万并发连接毫无压力。
协程的缺点也能从上面的代码中看出:协程的让出靠 await 调用,如果某个协程里写了 time.sleep(10) 或调了同步阻塞的 requests.get(),send() 就不返回,整个事件循环所有的协程全部都会卡住。因此 asyncio 里必须用 httpx/aiohttp 这类异步库。
多进程(multiprocessing)
多进程是 Python 里唯一能真并行跑纯 Python CPU 计算的方式,每个进程都有独立的 Python 解释器和独立的 GIL。但是它比较重、有下面的问题:
- 内存不共享:进程 A 的变量进程 B 看不见,传数据得序列化后走管道/队列(IPC),慢且麻烦;
- 启动慢:开一个进程要启动整个解释器;
- 每个进程都占一份独立内存。
在协程中调用同步代码
在异步程序里遇到同步阻塞方法可以用如下方式调用:
result = await asyncio.to_thread(sync_blocking_func, arg)
to_thread 背后是一个默认线程池,max_workers = min(32, CPU核数 + 4),线程是复用的,多出来的任务排队等待线程池有空闲。
3. Node.js 并发模型
Node.js 的并发模型与 Python asyncio 一致:一个主线程 + 一个 Event Loop + 被暂停的函数体。与 Python 不同,Node.js 从设计上就是异步优先。
Event Loop
我们编写的 JavaScript/TypeScript 代码运行在单个主线程上,但 Node.js 本身并不是单线程的,Nodejs 进程内有多个线程:
- 主线程:运行 JS 代码和 Event Loop
- libuv 线程池(默认 4 个线程):处理文件读写、DNS 查询、加密压缩等耗时操作。比如 fs.readFile 并不是主线程去读磁盘,而是交给线程池,读完后把回调排进队列
- Worker Threads:如果主动调用 worker_threads 模块,可以开新线程跑 CPU 密集型 JS 代码
Event Loop 的职责是不断取出就绪的回调并执行。JS 层面表达异步的方式有四仲:
- callback:最原始的异步形式;
- Promise:对 callback 的封装,解决回调嵌套问题;
- async/await:Promise 的语法糖,现代代码的主流写法;
- EventEmitter / Stream:不以 Promise 形态存在的异步,例如按行读子进程输出的
data事件、Socket.IO 的消息事件。因为每个Promise只能返回一次性的Resolve、对事件流就需要用独立的机制来异步处理。
以下面的代码为例:
“`ts const user = await fetchUser(); ```
这行代码会如下执行:
fetchUser()发起异步 IO;- 当前 async 函数就地暂停,剩余代码被打包成续体(continuation)存起来;
- 主线程立刻被释放,Event Loop 继续处理其他任务——主线程从未停下,停的只是这一个函数;
- 网络响应到达后,内核 epoll 通知 libuv,libuv 将回调塞进事件队列;
- Event Loop 轮到它时取出续体,在同一个主线程上从
await处继续执行。
Node.js 的并发比较高正是因为它不做线程切换:一万个并发连接只是一万份”存起来的续体 + 注册的 fd”,而不是一万个线程。
libuv 线程池
Node.js 底层由 libuv 维护一个默认大小为 4 的线程池,处理那些没有原生异步系统调用的操作:
- 文件 IO;
- DNS 解析;
- crypto 计算。
网络 IO 走 epoll/kqueue/IOCP,不占用线程池。线程池大小可以通过 UV_THREADPOOL_SIZE 环境变量调整。
因此要避免在主线程里执行同步 API(如 fs.readFileSync)或较重的同步计算(如大对象的 JSON.parse),它们会直接堵住 Event Loop,导致所有请求一起变慢。