目录
这篇文章作为我对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两位,我都想不到这种验证方法,学习了):
| |
运行上述代码获取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的async
、await
关键字的一个作用是作为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声明bossGroup
和workerGroup
两个EventLoopGroup
[9])。WebFlux
库拥有类似asyncio
库loop.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。
#总结
对三种并发方案的选择变得很简单,简单到不会忘记:
- CPU bound -> 多进程
- I/O bound + Blocking I/O -> 多线程
- I/O bound + Non-blocking I/O -> Event Loop
实施event loop方案后遇到Blocking I/O或CPU bound任务,可以通过将任务运行在额外线程池/进程池中,将阻塞形式的结果变为非阻塞形式的结果,并使用正常的异步方式消费结果,框架或语言会提供这类转换API(如上文提到的asyncio
库的loop.run_in_executor()
和WebFlux
库的publishOn()
)。
-
而GIL之所以存在,是因为Python的内存管理机制不是线程安全的。 GlobalInterpreterLock - Python Wiki
-
_thread — Low-level threading API — Python 3.12.5 documentation
-
17.9. _thread — Low-level threading API — Python 3.6.15 documentation
-
A thread-aware operating system schedules threads, not processes. Threads
-
https://old.reddit.com/r/django/comments/12lw2w8/mixing_sync_and_async_views_in_the_same/jgamyb1/
-
https://iximiuz.com/en/posts/from-callback-hell-to-async-await-heaven/
-
The second one, often called ‘worker’, handles the traffic of the accepted connection once the boss accepts the connection and registers the accepted connection to the worker. https://netty.io/wiki/user-guide-for-4.x.html#wiki-h3-5
-
concurrency - Is JavaScript guaranteed to be single-threaded? - Stack Overflow
-
concurrency - Is JavaScript guaranteed to be single-threaded? - Stack Overflow ↩︎
-
javascript - Why is LIBUV needed in Node JS? - Stack Overflow