理解 Python 的 asyncio
如何开始使用 Python 的 asyncio。
今年早些时候,我参加了 PyCon,国际 Python 会议。一个在众多演讲中提出并在走廊里非正式讨论的话题是 Python 中线程的状态——简而言之,既不理想,也没有一些批评者认为的那么糟糕。
反复出现的相关话题是“asyncio”,这是一种相对较新的 Python 并发方法。不仅有关于 asyncio 的正式演讲和非正式讨论,而且许多人还向我询问有关该主题的课程。
我必须承认,我对所有这些兴趣感到有点惊讶。毕竟,asyncio 并不是 Python 的新成员;它已经存在几年了。而且,它并没有解决与线程相关的所有问题。此外,对于许多人来说,入门可能会感到困惑。
然而,不可否认的是,在人们忽略 asyncio 多年之后,它开始逐渐流行起来。我确信部分原因是 asyncio 随着时间的推移已经成熟和改进,这在很大程度上要归功于无数开发人员的辛勤工作。但这也是因为 asyncio 对于某些类型的任务(尤其是跨网络的任务)来说,越来越成为一个好的和有用的选择。
因此,通过这篇文章,我将开始一个关于 asyncio 的系列文章——它是什么,如何使用它,它适用于哪里,以及你如何以及应该(以及不能和不应该)将其融入到你自己的工作中。
什么是 asyncio?每个人都习惯了计算机能够同时做多件事情——好吧,某种程度上是这样。虽然看起来计算机好像在同时做多件事情,但实际上它们非常快速地在不同的任务之间切换。例如,当您 ssh
登录到 Linux 服务器时,似乎它只在执行您的命令。但实际上,您只从 CPU 获得一小部分“时间片”,其余的则用于计算机上的其他任务,例如处理网络、安全性和各种协议的系统。实际上,如果您使用 SSH 连接到这样的服务器,那么其中一些时间片正被 sshd
用来处理您的连接,甚至允许您发出命令。
所有这些都是在现代操作系统上通过“抢占式多任务处理”完成的。换句话说,正在运行的程序无法选择何时放弃对 CPU 的控制权。相反,它们被迫放弃控制权,然后在稍后一段时间恢复。计算机上运行的每个进程都以这种方式处理。每个进程又可以使用线程,线程是将分配给其父进程的时间片细分的子进程。
因此,在一台假设的具有五个进程(和一个核心)的计算机上,每个进程将获得大约 20% 的时间。如果其中一个进程有四个线程,则每个线程将获得 5% 的 CPU 时间。(事情显然比这更复杂,但这是一种在高层次上思考它的好方法。)
Python 通过“multiprocessing”库可以很好地处理进程。进程的问题在于它们相对庞大而笨重,并且您不能将它们用于某些任务,例如在保持 UI 响应的同时响应按钮点击而运行函数。
因此,您可能想使用线程。实际上,Python 的线程可以工作,并且对于许多任务来说效果很好。但由于 GIL(全局解释器锁)的存在,它们并没有那么好,GIL 确保一次只有一个线程运行。所以,当然,Python 会让您运行多线程程序,并且当它们进行大量 I/O 时,这些程序甚至会运行良好。这是因为与 CPU 和内存相比,I/O 速度很慢,Python 可以利用这一点来服务于其他线程。但是,如果您使用线程来执行严肃的计算,那么 Python 的线程是一个坏主意,它们不会让您有任何进展。即使有许多核心,一次也只会执行一个线程,这意味着您与串行运行计算相比没有任何优势。
Python 中添加的 asyncio 为并发性提供了不同的模型。与线程一样,asyncio 不是 CPU 密集型问题(即,需要大量 CPU 时间来处理计算)的好解决方案。当您绝对必须让事物真正并行运行时,它也不适用,就像进程的情况一样。
但是,如果您的程序正在处理网络,或者它们执行大量的 I/O,那么 asyncio 可能是一个不错的选择。
好消息是,如果它适用,asyncio 可能比线程更容易使用。
坏消息是,您需要以一种新的和不同的方式来思考才能使用 asyncio。
协作式多任务处理和协程早些时候,我提到现代操作系统使用“抢占式多任务处理”来完成任务,强制进程放弃对 CPU 的控制权,转而支持另一个进程。但还有另一种模型,称为“协作式多任务处理”,其中系统等待直到程序自愿放弃对 CPU 的控制权。因此有了“协作”这个词——如果函数决定执行大量的计算,并且永远不放弃控制权,那么系统对此无能为力。
这听起来像是灾难的根源;您为什么要编写,更不用说运行,放弃 CPU 的程序?答案很简单。当您的程序使用 I/O 时,您可以几乎保证您将空闲等待直到收到响应,考虑到 I/O 比在内存中运行的程序慢得多。因此,每当您使用 I/O 执行某些操作时,您可以自愿放弃 CPU,因为您知道很快,其他程序也会类似地调用 I/O 并放弃 CPU,从而将控制权返回给您。
为了使它工作,您需要让这个协作式多任务处理宇宙中的所有程序都同意一些基本规则。特别是,您需要它们同意所有 I/O 都通过多任务处理系统,并且没有任务会长时间占用 CPU。
但是等等,您还需要更多一点。您需要为任务提供一种方法来暂时自愿停止执行,然后从它们停止的地方重新启动。
最后一点实际上已经在 Python 中存在了一段时间,尽管语法略有不同。让我们从那里开始 asyncio 的旅程和探索。
一个普通的 Python 函数在被调用时,会从头到尾执行。例如
def foo():
print("a")
print("b")
print("c")
如果您调用它,您会看到
a
b
c
当然,通常函数不仅打印一些东西是好的,而且返回一个值也很好
def hello(name):
return f'Hello, {name}'
现在当您调用该函数时,您将获得一些返回值。您可以获取该返回值并将其分配给一个变量
s = hello('Reuven')
但是 return
有一个变体,它将被证明对您在这里所做的事情至关重要,即 yield
。yield
语句看起来和行为都很像 return
,但它可以在一个函数中多次使用,甚至在循环中使用
def hello(name):
for i in range(5):
yield f'[{i}] Hello, {name}'
因为它使用 yield
而不是 return
,所以这被称为“生成器函数”。当您调用它时,您不会得到一个字符串,而是得到一个 generator
对象
>>> g = hello('Reuven')
>>> type(g)
generator
generator
是一种知道如何在 Python for
循环中表现的对象。(换句话说,它实现了迭代协议。)
当放入这样的循环中时,函数将开始运行。但是,每次生成器函数遇到 yield
语句时,它都会将值返回给循环并进入休眠状态。它什么时候再次醒来?当 for
循环要求从迭代器返回下一个值时
for s in g:
print(s)
因此,生成器函数提供了您所需的核心:一个正常运行的函数,直到它到达代码中的某个点。在这一点上,它将一个值返回给它的调用者并进入休眠状态。当 for
循环从生成器请求下一个值时,该函数将从它停止的地方(即,紧接在 yield
语句之后)继续执行,就好像它从未停止过一样。
问题是,这里描述的生成器产生输出,但无法获得任何输入。例如,您可以创建一个生成器来每次迭代返回一个斐波那契数,但您无法告诉它跳过十个数字。一旦生成器函数正在运行,它就无法从调用者那里获得输入。
也就是说,它无法通过正常的迭代协议获得此类输入。生成器支持 send
方法,允许外部世界将任何 Python 对象发送到生成器。通过这种方式,生成器现在支持双向通信。例如
def hello(name):
while True:
name = yield f'Hello, {name}'
if not name:
break
给定上面的生成器函数,您现在可以说
>>> g = hello('world')
>>> next(g)
'Hello, world'
>>> g.send('Reuven')
'Hello, Reuven'
>>> g.send('Linux Journal')
'Hello, Linux Journal'
换句话说,首先您运行生成器函数以获得一个生成器对象(“g”)返回。然后您必须使用 next
函数对其进行“预热”,运行到并包括第一个 yield
语句。从那时起,您可以通过 send
方法将任何您想要的值提交给生成器。在您运行 g.send(None)
之前,您将继续获得输出返回。
以这种方式使用,生成器被称为“协程”——也就是说,它具有状态并执行。但是,它与主例程协同执行,您可以随时查询它以从中获取某些内容。
Python 的 asyncio 使用这些基本概念,尽管语法略有不同,来实现其目标。虽然能够将数据发送到生成器并定期获取返回值似乎是一件微不足道的事情,但事实远非如此。实际上,这为创建高效的网络应用程序提供了整个基础设施的核心,这些应用程序可以处理许多并发用户,而没有线程或进程的痛苦。
在我的下一篇文章中,我计划开始研究 asyncio 的具体语法以及它如何映射到我在这里展示的内容。敬请期待。