asyncio:为什么默认情况下它不是非阻塞的?

4
默认情况下,asyncio 同步运行协程。如果它们包含阻塞IO代码,它们仍然会等待其返回。绕过这个问题的方法是使用 loop.run_in_executor() 将代码转换为线程。如果一个线程在IO上被阻塞,另一个线程可以开始执行。因此,您不必浪费时间等待IO调用。
如果您没有使用执行器,那么您将失去这些加速优势。所以我想知道,为什么你必须显式地使用执行器。为什么不默认启用它们呢? (以下内容中,我将重点关注http请求。但它们只是作为一个例子。我对一般原则感兴趣。)
经过一番搜索,我发现了aiohttp。这个库本质上是将asynciorequests结合起来实现的-非阻塞HTTP调用。使用执行器后,asynciorequestsaiohttp的表现几乎相同。那么,是否有理由实现一个新库呢?使用执行器会付出性能代价吗?
这个问题得到了回答:为什么asyncio不总是使用执行器?Mikhail Gerasimov向我解释说,执行器会启动操作系统线程,而且可能会变得很昂贵。因此,没有将它们作为默认行为是有道理的。aiohttp比在执行器中使用requests模块更好,因为它只使用协程来提供非阻塞代码。
这就引出了这个问题:aiohttp自称为:

Asynchronous HTTP Client/Server for asyncio and Python.

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

还是说 aiohttp 自己实现了这个新的事件循环(没有操作系统线程)? 如果是这样的话,我就不明白他们为什么要宣传自己是基于 asyncio 的了。Async/await 是一种语言特性,asyncio 是一个事件循环。如果 aiohttp 有自己的事件循环,那么与 asyncio 的交集应该很小。实际上,我认为这样的事件循环比 http 请求要重要得多。


2
Asyncio默认是非阻塞的。但是你在事件循环中运行的代码可能会是阻塞的。 - Klaus D.
1
asyncio和协程的整个目的是在没有线程的情况下运行非阻塞代码。 aiohttp的第一个示例展示了它使用asyncio来运行非阻塞代码。如果您决定在asyncio中运行阻塞代码,那不是asyncio的错。您也可以问为什么int(“我的银行账户”)不能返回您当前的账户余额。 - MisterMiyagi
2个回答

12

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

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

Python标准库中充满了非常有用的代码,asyncio项目将希望利用这些代码。大多数标准库由常规的“阻塞”函数和类定义组成。它们快速完成工作,因此即使它们“阻塞”,也会在合理的时间内返回。

但是,大多数代码也不是线程安全的,通常不需要。但是,一旦asyncio自动在执行器中运行所有这些代码,那么您就不能再使用非线程安全的函数了。此外,在其中创建线程以运行同步代码并不是免费的,创建线程对象需要时间,您的操作系统也不会让您无限制地运行线程。许多标准库函数和方法都非常快速,为什么要在单独的线程中运行str.splitlines()urllib.parse.quote(),当执行代码并完成它会更快呢?

你可能会认为这些函数不是按照你的标准阻塞的。你没有定义“阻塞”在这里的含义,但“阻塞”只意味着:不会自愿放弃。如果我们将其缩小到当它必须等待某些东西并且计算机可以做其他事情时不会自愿放弃,那么下一个问题就是如何检测它应该已经放弃了?对此的答案是你无法知道。time.sleep()是一个阻塞函数,你希望将其放弃给循环,但这是一个C函数调用。Python不能知道time.sleep()将会阻塞多长时间,因为调用time.sleep()的函数将在全局命名空间中查找名称time,然后在执行time.sleep()表达式时查找结果上的属性sleep。由于Python的命名空间可以在执行期间的任何时刻被更改,因此你无法知道time.sleep()实际执行的操作。
你可以认为time.sleep()实现在被调用时应该自动执行yield,但是那么你就必须开始识别所有这样的函数。而且你需要修补的地方没有限制,你永远也不可能知道所有的地方,特别是对于第三方库来说更是如此。例如,python-adb项目使用libusb1库为你提供了一个同步USB连接到Android设备。这不是标准的I/O代码路径,那么Python怎么知道创建和使用这些连接是好的yield位置呢?
所以你不能仅仅假设代码需要在执行器中运行,因为并不是所有的代码都可以在执行器中运行,因为它不是线程安全的,而且Python无法检测到代码何时会阻塞,并真正需要yield。
那么,在 asyncio 下,协程是如何协作的呢?通过使用 task 对象 来处理需要与其他任务同时运行的逻辑代码块,并使用 future 对象 向任务发出信号,表明当前逻辑代码块想要放弃控制权以便让其他任务执行。这就是异步 asyncio 代码异步的原因——自愿放弃控制权。当循环将控制权交给多个任务中的一个任务时,该任务会执行协程调用链的一个“步骤”,直到该调用链生成一个 future 对象,此时该任务会向 future 对象的“完成”回调列表中添加一个唤醒回调并将控制权返回给循环。在稍后的某个时间点,当 future 被标记为完成时,唤醒回调会被运行,任务将执行另一个协程调用链步骤。

有另外一些东西负责标记未来的对象完成。当使用 asyncio.sleep() 时,回调会在特定时间给予循环运行,该回调会标记 asyncio.sleep() 未来为已完成。当使用 stream object 执行 I/O 时,(在 UNIX 上),循环使用 select calls 检测何时应唤醒未来对象,以便在 I/O 操作完成时唤醒它。当您使用 lock or other synchronisation primitive 时,同步原语将维护一组未来对象,以在适当时候将其标记为“完成”(等待锁? 将未来添加到堆栈中。释放保持的锁?从堆栈中选择下一个未来并将其标记为已完成,以便等待锁的下一个任务可以唤醒并获取锁,等等)。

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

最后但并非最不重要的,使用{{asyncio}}的整个目的是尽可能避免使用线程。使用线程有缺点;代码需要保持线程安全(控制可以在任何地方切换线程,因此两个访问共享数据的线程应该小心处理,并且“小心处理”可能会使代码变慢)。线程无论是否有任务都会执行;在固定数量的线程之间切换控制,而所有线程都等待I/O发生是浪费CPU时间的,而{{asyncio}}循环则可以自由查找未等待的任务。

“'blocking' 只是指不会主动放弃。” 我不是一个真正的 Python 程序员,但这很糟糕。在我使用过的其他多线程环境中,“阻塞”意味着该函数将会让出 CPU 以便其他线程执行。毫无疑问,混淆与 Python 可怕的 GIL 有关。(即,“阻塞”可能意味着“阻塞调用线程”,也可能意味着“阻塞整个进程”)。 - Solomon Slow
@SolomonSlow 阻塞/非阻塞的区别与线程或GIL无关。它是关于协程中的yield,这意味着不阻塞同一线程。Python在一个线程将控制权交给其他线程时没有问题。 - MisterMiyagi
2
@SolomonSlow:线程不会主动让出控制权。操作系统会在不同的线程之间切换控制权,线程中运行的代码无法控制这一过程。让出控制权是执行代码时有意为之,以便其他代码可以运行。最多,线程可以使用同步原语(如锁和信号量)来停止任何工作,同时等待其他线程完成某些任务。但是,等待的线程仍在做某些事情,浪费CPU资源,线程仍然会被授予控制权以继续等待,直到控制权再次移交给其他线程。 - Martijn Pieters
@SolomonSlow:GIL是其中一种锁;Python评估循环使用锁来协调对评估循环内部的访问。你可以有19个线程都在等待GIL被释放,而第20个线程则持有锁来解释Python字节码。运行不需要解释Python字节码的代码的线程应该释放锁并继续他们的工作,他们不需要等待。 - Martijn Pieters
@SolomonSlow:asyncio 的工作方式完全不同:事件循环将控制权交给一个任务,直到该任务将控制权归还,此时运行另一个任务,以此类推。任务使用回调将自己放回运行状态,以便在稍后的时间继续执行。在任何时候都不会从任务中夺取控制权,任务控制何时让出控制权。 - Martijn Pieters
@SolomonSlow:使用 asyncio,等待锁意味着任务根本不会运行。没有浪费时间运行“现在这个锁是否可用”的检查循环。相反,当持有锁的任务完成并释放它时,下一个正在等待的任务将被安排尽快运行,这样当当前任务必须等待其他事情时,控制权转移到循环,然后可以将控制权交给下一个任务,该任务然后可以自由地获取锁,而不会浪费 CPU 时间。 - Martijn Pieters

4

那么是基于的吗?

是的,它建立在asyncio的抽象之上,例如futurestransports and protocolssynchronization primitives等。

为什么不只使用协程提供非阻塞代码呢?

如果您使用asyncio API,那么它确实提供了非阻塞代码,可以连接到服务器解析主机名创建服务器,甚至可以在单独的线程池中运行阻塞代码而不会阻塞事件循环。

aiohttp使用所有这些功能,在asyncio之上实现了一个功能强大的HTTP客户端和服务器。

或者说是自己实现了这个没有操作系统线程的新事件循环吗?

不是的,aiohttp钩入asyncio的事件循环。更准确地说,使用aiohttp(以及其他基于asyncio的库)的应用程序会启动asyncio事件循环并将其钩入aiohttp。

异步/等待是一种语言特性。Asyncio是一个事件循环。

异步/等待是一种语言特性,就像生成器一样。Asyncio是一个使用它们的库,就像itertools一样。还有其他使用协程的库,例如curiotrio


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接