从 async/await 到虚拟线程:Python 并发的再思考
演进之路:从async/await到线程的反思
首先必须明确的是,async/await对Python并非全无裨益:它最大的价值,是让更多人接触到了并发编程。通过在编程语言中嵌入语法元素,并发编程的门槛被大幅降低,得以进入更多开发者的视野。但遗憾的是,其副作用也不容忽视:它需要一套极其复杂的内部机制,而这种复杂性会渗透到编程语言中,直接暴露给用户;更关键的是,它带来了“函数着色”(colored functions)的问题——即函数被划分为异步与同步两类,彼此调用受到严格限制。
相比之下,线程在概念上简单得多,但过去几代线程API的设计却差强人意。毫无疑问,async/await在这方面确实有所改进。
Python中async/await的核心特性是:在调用await之前,程序不会发生任何实际操作,开发者可以确保代码不会被挂起。但最近“自由线程”(free-threading)的变更,让这一保证变得形同虚设。因为开发者仍需编写代码考虑其他线程的存在,如今我们不得不同时应对异步生态与线程系统的双重复杂性。
此时,我们或许该反思:若完全拥抱线程,是否能找到一条更优的路径?
结构化虚拟线程:异步编程的积极探索
Python异步编程带来的另一项积极成果,是催生了大量改善API易用性的实验。其中最重要的创新,便是“结构化并发”理念——其核心是禁止子任务的生命周期超过父任务。这一特性的价值在于,它能让子任务与父任务建立明确关联,使上下文变量等信息的流转远比传统线程与线程局部变量清晰。在传统线程中,线程与其父线程几乎没有真正的关联关系。
但遗憾的是,作为Python中结构化并发的实现,“任务组”(task groups)是近期才新增的特性,且许多库往往未充分实现其对取消操作的严格要求。要理解这一点的重要性,需先明确结构化并发的工作原理:当一个任务作为子任务被启动时,任何相邻任务的失败都会触发其他所有任务的取消——这需要可靠的取消机制。
而当任务涉及实际线程时,可靠的取消操作会变得异常困难。以广受欢迎的aiofiles库为例,它通过线程池将I/O操作转移至I/O线程(因为在不同平台上,标准文件很难实现真正的异步I/O),但该库并不支持取消操作。这会导致一个严重问题:若启动多个任务,其中一些任务阻塞在aiofiles的读取操作上(且该读取需等待另一个任务完成才能成功),则取消操作可能引发死锁。这并非假设——在任务组中使用aiofiles时,多次出现解释器无法正常关闭的情况;更糟的是,任务组捕获的异常需等到线程池中阻塞的读取被键盘中断等信号打断后才会显现,这给开发者带来了极差的体验。
在诸多层面上,我们真正需要的是回到原点思考:“若一个编程世界只使用带有更优API的线程,会是怎样的图景?”
回归线程:性能挑战与虚拟线程的解法
若仅依赖线程,我们会重新面临当初促使asyncio诞生的性能挑战。而虚拟线程,正是解决这一问题的关键(关于虚拟线程的细节,可参考我此前的文章)。
要启用虚拟线程,核心在于直接在运行时解决异步I/O的诸多难题:当遇到阻塞操作时,需确保虚拟线程被放回调度器,让其他线程获得运行机会。
但仅此还不够——我们不能因回归线程而丢失结构化并发的优势,这正是设计的平衡点。
更优的线程API:简洁与并行的融合
我们从一个简单场景切入:按顺序下载多个URL的Python代码,可能如下所示:
def download_all(urls):
results = {}
for url in urls:
results[url] = fetch_url(url)
return results
注意,这里刻意未使用任何async或await——我们追求的是最简单的阻塞API。其行为清晰:下载多个URL,若任一URL失败,则整个过程中止并抛出异常,已收集的结果也会丢失。
但如何在保持简洁性的同时引入并行性?如何实现多URL同时下载?若一种语言支持结构化并发与虚拟线程,可通过如下假想语法实现:
def download_all(urls):
results = {}
await:
for url in urls:
async:
results[url] = fetch_url(url)
return results
这里刻意使用了await与async,但用法与现有机制完全相反:
- await:创建结构化线程组。在其中启动的所有线程(async标记的部分)均会附加到该线程组并被等待。若任一线程失败,后续线程启动会被阻断,已有线程会被通知取消。
- async:可理解为与“启动”绑定的函数声明,其代码块会在新任务中运行。由于线程存在父子关系,子线程会继承父线程的上下文,这也让取消操作能在线程间传递。
其底层实现逻辑类似:
from functools import partial
def download_all(urls):
results = {}
with ThreadGroup():
def _thread(url):
results[url] = fetch_url(url)
for url in urls:
ThreadGroup.current.spawn(partial(_thread, url))
return results
需注意,这里的线程均为虚拟线程:它们行为类似传统线程,但可被调度到不同的内核线程上。若任一启动的线程失败,线程组会直接终止,且阻止后续线程启动——失败的线程组不允许再启动新线程。
从设计上看,这一机制相当巧妙,但与Python的特性并不完全适配。这种语法会让Python开发者感到陌生,因为Python中并无“隐藏函数声明”的概念;更关键的是,Python的作用域机制会制约其实现——由于缺乏变量声明语法,函数内仅有一个作用域,这导致循环体中定义的辅助函数无法正确捕获循环变量。
但核心启示在于:这种编程模式无需依赖“未来对象”(futures)。尽管它可支持未来对象,但多数场景下,代码无需借助这种抽象即可实现。
因此,使用这类系统时,开发者需要理解的概念会大幅减少——无需再学习未来对象(futures)、承诺(promises)、异步任务等复杂概念。
API的妥协:适配Python的现实选择
上述特定语法并不完全适配Python,且“自动线程组”是否为最优解也存在争议。因此,我们可参考async/await的现有机制,让线程组更显式:
from functools import partial
def download_and_store(results, url):
results[url] = fetch_url(url)
def download_all(urls):
results = {}
with ThreadGroup() as g:
for url in urls:
g.spawn(partial(download_and_store, results, url))
return results
这种方式的行为与前述机制一致,但操作更显式,需额外定义辅助函数。不过,它仍能完全避免使用承诺(promises)或未来对象(futures)。
复杂性归位:将细节隐藏于底层
这一设计的核心价值,在于将并发编程的复杂性转移至其应属之地——解释器与内部API。例如,为确保上述代码正常运行,results字典需要加锁;fetch_url的API需支持取消操作;I/O层需能挂起虚拟线程并返回调度器。但对多数开发者而言,这些细节均是隐藏的。
此外,部分现有API在支持并发系统时已显陈旧。例如,相较于将互斥锁(mutex)独立于数据之外,我更倾向于Rust的设计理念——将数据封装在互斥锁中,通过锁的机制直接管控数据访问。
信号量(semaphores)也是限制并发、提升系统稳定性的重要工具,这类功能可集成到线程组中,直接限制同时运行的线程数量:
from functools import partial
def download_and_store(results_mutex, url):
with results_mutex.lock() as results:
result = fetch_url(url)
results.store(url, result)
def download_all(urls):
results = Mutex(MyResultStore())
with ThreadGroup(max_concurrency=8) as g:
for url in urls:
g.spawn(partial(download_and_store, results, url))
return results
未来对象(Futures):保留但非必需
未来对象仍有其适用场景,因此会继续存在。获取未来对象的一种方式,是保留spawn方法的返回值:
def download_and_store(results, url):
results[url] = fetch_url(url)
def download_all(urls):
futures = []
with ThreadGroup() as g:
for url in urls:
futures.append((url, g.spawn(lambda: fetch_url(url))))
return {url: future.result() for (url, future) in futures}
脱离线程组的启动:边界场景的考量
一个关键问题是:若不存在线程组,spawn是否仍能生效?例如,Python异步库Trio规定,必须在“育儿室”(nursery,类似线程组)中启动操作。这一策略虽合理,但部分场景下难以实现。对此,可考虑设置默认线程组处理后台任务,进程关闭时隐式等待其完成。但核心原则是:让默认API尽可能覆盖预期行为。
async/await的未来:兼容现有,面向未来
在未来的系统中,async/await会走向何方?这仍需探讨,但为现有代码保留异步功能是合理的;而对新代码而言,async/await或许完全没有必要。