Python 中的 Event Loop 和多线程

因工作接触 Python Web,又稍微往底层和延伸搜了搜

这篇文章作为我对 Python 并发编程方案相关知识的思考、总结和陈述,希望这篇文章能帮到苦于理解和选择并发方案的其他人。对 Python 语言来说,有三种并发方案:多线程(threading)、多进程(multiprocessing)、event loop(asyncio etc.)。

#都怪 GIL,threading不能加速 CPU bound 任务

在 Python 语言中只有multiprocessing库可以加速 CPU bound 任务。为什么threading库不可以?因为有 GIL 的存在,同一时间只有一个线程在运行(Python 字节码)[1]。即使threading库(通过_thread[2],进而通过诸如pthread等底层库[3])创建的是 kernel-level thread(kernel-level thread 可以被 OS 的 scheduler 调度在不同 CPU core 上执行[4])。这一点可以用以下代码验证(感谢 perker,kky 两位,我都想不到这种验证方法,学习了):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import threading
import time
import os

def worker():
    while True:
        time.sleep(1)
        
t = threading.Thread(target=worker, daemon=True)
t.start()

print(f"{os.getpid()}")

运行上述代码获取 pid,在另一个终端执行ps -o thcount {pid},会看到结果是THCNT 2,一个主线程一个 worker 线程。

#对于 I/O bound 任务,选择在于 I/O 操作是否阻塞

本段落中所述也适用于其他编程语言。在 event loop 方案中,任务在遇到阻塞时马上交出执行权;而在多线程(kernel-level thread)方案中,OS 的 scheduler 需要等待被 I/O 阻塞的线程跑完分配给该线程的时间片再调度运行其他线程。这样一想,似乎 event loop 方案在处理 I/O bound 任务时始终优于多线程方案。实际上选择哪个方案取决于你将要调用的 I/O 操作是否为阻塞,只要在计算流程上有一个调用是阻塞 I/O,就只能选多线程方案了。

在 event loop 方案中,阻塞 I/O 会 block 住当前运行该逻辑的工作线程。我找到了一句对 Pythonasyncio库的辛辣评论:

Async is just a pretty face on single-threaded callback hell. – Reddit user, Niicodemus [5]

这位的观点在上下文语境中是正确的。“a pretty face on callback hell”(真怀念,这是我求职时被问到的问题,下次我会这样回答),即 Python 的asyncawait关键字的一个作用是作为 callback 的语法糖[6]asyncio库的 event loop 实现是单线程的[7],所以如果在使用asyncio库的同时调用阻塞 I/O,整个都会卡住。最好调用loop.run_in_executor(executor, func, *args)方法,在线程池中运行阻塞 I/O(或在进程池中运行 CPU bound 任务),将阻塞等待转换为非阻塞等待。

#并非所有 event loop 实现方案都是单线程实现

另外,Event loop 结构在不同实现方案中处于不同的位置。

(In Netty,) The event loop runs continuously in a single thread, although we can have as many event loops as the number of available cores.[8]

Java 生态下的Spring WebFlux作为一个 web application 框架,可使用多种 web server 作为底层依赖。其中Netty是一种实现了非阻塞 I/O 的 web client/server 框架,提供 TCP、UDP、HTTP 协议的非阻塞实现。使用Netty编写 web server 时,需要声明两类线程,接收请求的线程(boss)和处理请求的线程(worker)。每一个线程中都存在一个 event loop,被作为EventLoopGroup统一管理(因而我们要为一个 web server 声明bossGroupworkerGroup两个EventLoopGroup[9])。WebFlux库拥有类似asyncioloop.run_in_executor()方法的publishOn(Scheduler s)方法,将当前调用链的后续步骤移到另一个Scheduler(即另一个线程池)中运行。

JavaScript 语言被定义为一个“单线程”语言[10],它应当被执行在单线程环境中(但是书面约定并不阻止你多线程执行 JavaScript 代码)。JavaScript 引擎是负责解释并执行 JavaScript 代码的程序,大多数引擎(比如 Chrome V8)遵守了单线程执行的约定(除了 Rhino 引擎[11])。如果你在前端 JavaScript 代码中调用阻塞 I/O,由 JavaScript 事件驱动的 UI 将无法响应用户操作[12],因为 event loop 始终无法空闲下来并处理下一个事件。

Node.js uses a small number of threads to handle many clients. In Node.js there are two types of threads: one Event Loop (aka the main loop, main thread, event thread, etc.), and a pool of k Workers in a Worker Pool (aka the threadpool).[13]

Node.js基于 V8 引擎和libuv库,生成一个 Worker Pool(基于一个 kernel-level thread pool12,这个线程池是由libuv库调用系统调用申请创建的[14])来处理所有逻辑[15]。因此在 Node.js 所运行的代码中调用阻塞 I/O 的行为将仅仅阻塞当前运行这段逻辑的 worker,其他 worker 仍将正常运行。但Node.js只有一个 event loop 调度全部 worker。

#总结

对三种并发方案的选择变得很简单,简单到不会忘记:

  1. CPU bound -> 多进程
  2. I/O bound + Blocking I/O -> 多线程
  3. I/O bound + Non-blocking I/O -> Event Loop

实施 event loop 方案后遇到 Blocking I/O 或 CPU bound 任务,可以通过将任务运行在额外线程池/进程池中,将阻塞形式的结果变为非阻塞形式的结果,并使用正常的异步方式消费结果,框架或语言会提供这类转换 API(如上文提到的asyncio库的loop.run_in_executor()WebFlux库的publishOn())。

updatedupdated2025-05-022025-05-02