首页 > 解决方案 > asyncio:为什么默认情况下它不是非阻塞的

问题描述

默认情况下,asyncio同步运行协程。如果它们包含阻塞 IO 代码,它们仍然会等待它返回。解决此问题的一种方法是loop.run_in_executor()将代码转换为线程。如果一个线程在 IO 上阻塞,另一个线程可以开始执行。这样您就不会浪费时间等待 IO 调用。

如果你在asyncio没有执行者的情况下使用,你会失去这些加速。所以我想知道,为什么你必须明确地使用执行者。为什么不默认启用它们?(在下文中,我将重点介绍 http 请求。但它们实际上只是作为示例。我对一般原则感兴趣。)

经过一番搜索,我找到了 aiohttp。它是一个库,本质上提供了asyncio和的组合requests:非阻塞 HTTP 调用。与执行者一起,asyncio并且requests行为几乎就像aiohttp. 是否有理由实现一个新的库,你是否为使用执行器付出了性能损失?

回答了这个问题:为什么 asyncio 不总是使用执行器? Mikhail Gerasimov 向我解释说,executors 将启动 OS 线程并且它们可能变得昂贵。因此,不要将它们作为默认行为是有意义的。aiohttprequests在执行程序中使用模块更好,因为它提供了只有协程的非阻塞代码。

这让我想到了这个问题。aiohttp将自己宣传为:

用于 asyncio 和 Python 的异步 HTTP 客户端/服务器。

那么aiohttp是基于asyncio? 那么为什么不asyncio提供只有协程的非阻塞代码呢?那将是理想的默认值。

还是自己aiohttp实现了这个新的事件循环(没有操作系统线程)?在那种情况下,我不明白他们为什么将自己宣传为基于asyncio. Async/await是一种语言特征。Asyncio是一个事件循环。如果aiohttp有自己的事件循环,则应该与asyncio. 实际上,我认为这样的事件循环将是一个比 http 请求更大的功能。

标签: pythonmultithreadingasynchronouspython-asyncioaiohttp

解决方案


asyncio是异步的,因为协程是自愿合作的。所有 asyncio代码的编写都必须考虑到合作,这完全是重点。否则,您也可以专门使用线程来实现并发。

您不能在执行程序中运行“阻塞”函数(非协程函数或不合作的方法),因为您不能仅仅假设该代码可以在单独的执行程序线程中运行。或者即使它需要在执行程序中运行。

Python 标准库充满了非常有用的代码,asyncio项目会想要使用这些代码。大多数标准库由常规的“阻塞”函数和类定义组成。他们的工作很快,所以即使他们“阻止”,他们也会在合理的时间内返回。

但是大部分代码也不是线程安全的,通常不需要。但是一旦asyncio在 executor 中自动运行所有这些代码,那么你就不能再使用非线程安全的函数了。此外,创建线程来运行同步代码不是免费的,创建线程对象需要时间,而且您的操作系统也不会让您运行无限数量的线程。标准库函数和方法的加载速度很快,为什么要运行str.splitlines()urllib.parse.quote()在单独的线程中运行代码并完成它会更快?

您可能会说这些功能并没有按照您的标准进行阻止。您在这里没有定义“阻塞”,但“阻塞”只是意味着:不会自愿让步。. 如果我们将范围缩小到当它必须等待某事时不会自愿让步,而计算机可能正在做其他事情,那么下一个问题将是您如何检测到它应该让步

答案是你不能。 time.sleep()是一个阻塞函数,您希望在其中屈服于循环,但这是一个 C 函数调用。Python 无法知道time.sleep()会阻塞更长时间,因为调用的函数time.sleep()time在全局命名空间中查找名称,然后sleep在名称查找结果上查找属性,只有在实际执行time.sleep()表达式时。因为 Python 的命名空间可以在执行期间的任何时候time.sleep()更改,所以在实际执行函数之前,您无法知道会发生什么。

你可以说time.sleep()实现应该在调用时自动产生,但是你必须开始识别所有这样的函数。而且你必须修补的地方的数量没有限制,你不可能知道所有的地方。当然不适用于第三方库。例如,该python-adb项目libusb1使用该库为您提供与 Android 设备的同步 USB 连接。那不是标准的 I/O 代码路径,那么 Python 怎么知道创建和使用这些连接是产生的好地方?

所以你不能仅仅假设代码需要在执行器中运行,并不是所有的代码可以在执行器中运行,因为它不是线程安全的,而且 Python 无法检测代码何时阻塞并且应该真正让步。

那么协程下如何协同asyncio呢?通过使用需要与其他任务同时运行的每个逻辑代码段的任务对象,并通过使用未来对象向任务发出信号,表明当前逻辑代码段想要将控制权让给其他任务。这就是使异步asyncio代码异步,自愿放弃控制权的原因。当循环将控制权交给多个任务中的一个时,该任务执行协程调用链的一个“步骤”,直到该调用链产生一个未来对象,此时任务添加一个唤醒回调到未来对象“完成”回调列表并将控制权返回给循环。在稍后的某个时间点,当未来标记为完成时,唤醒回调运行并且任务将执行另一个协程调用链步骤。

其他东西负责将未来的对象标记为完成。当您使用asyncio.sleep()时,将在特定时间运行的回调将提供给循环,该回调会将asyncio.sleep()未来标记为已完成。当您使用流对象执行 I/O 时,然后(在 UNIX 上),循环使用select调用来检测 I/O 操作完成时何时唤醒未来对象。当您使用锁或其他同步原语时,那么同步原语将维护一堆未来以在适当的时候标记为“完成”(等待锁?将未来添加到堆中。释放持有的锁?从堆中选择下一个未来并将其标记为完成,因此等待锁的下一个任务可以唤醒并获取锁,等等)。

将阻塞的同步代码放入执行器只是这里的另一种合作形式。在项目中使用asyncio时,由开发人员确保您使用提供给您的工具来确保您的协程协作。您可以自由地open()对文件使用阻塞调用而不是使用流,并且当您知道代码需要在单独的线程中运行以避免阻塞太长时间时,您可以自由使用执行器。

最后但并非最不重要的一点是,使用的重点asyncio是尽可能避免使用线程。使用线程有缺点;代码需要是线程安全的(控制可以在任何地方的线程之间切换,因此访问共享数据的两个线程应该小心,并且“小心”可能意味着代码变慢)。线程不管有没有事都会执行;在所有等待 I/O 发生的固定数量的线程之间切换控制是浪费 CPU 时间,其中asyncio循环可以自由地找到未等待的任务。


推荐阅读