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())。

updatedupdated2024-03-102024-03-10