Python 异步--Await the Future
python中的异步编程最近变得越来越流行。python中有许多不同的库用于进行异步编程。其中一个库是asyncio,它是Python 3.4中添加的python标准库。
Asyncio是异步编程在Python中越来越流行的部分原因。本文将解释异步编程是什么,并比较其中的一些库。让我们来看看历史,看看异步编程是如何在python中演变的
一次只执行一个任务
程序具有固有属性,每行按顺序执行。
例如,如果您有一行代码转到远程服务器以获取资源,这意味着您的程序在等待时不会做任何事。它坐着等待响应才能继续。在某些情况下,这是可以接受的,但在许多情况下并非如此。标准修复是通过线程。一个程序可以启动多个线程; 每个线程一次做一件事。
这些线程一起允许您的程序一次执行多项操作。当然,线程有许多警告随之而来。多线程程序更复杂,通常更容易出错,它们包括常见的麻烦问题:竞争条件,死锁,活锁和资源匮乏。
上下文切换
虽然异步编程可以防止所有这些问题,但它实际上是针对一个完全不同的问题而设计的:CPU上下文切换。当您运行多个线程时,每个CPU核心仍然只能一次运行一个线程。为了允许所有线程/进程共享资源,CPU经常进行上下文切换。为了简化过程,CPU以随机间隔保存线程的所有上下文信息并切换到另一个线程。CPU会以非确定的间隔在您的线程之间不断切换。线程也是资源,它们不是免费的。
异步编程本质上是软件/用户空间线程,其中应用程序管理线程和上下文切换而不是CPU。基本上,在异步中,仅在定义的切换点而不是在非确定性间隔中切换上下文。
令人难以置信的高效秘书
现在让我们将这些概念与非计算机示例进行比较。想象一下,我们有一个非常高效的秘书,并且根本不浪费任何时间 - 总是把事情做好,试图最大化每一秒。这个秘书 - 让我们称他为鲍勃 - 必须像疯了一样多任务来实现这一目标。鲍勃有一次他正在做的5项任务:接听电话,接待员(指导客人),试图预订航班,处理会议日程和提交文件。现在让我们想象一下这是一个低流量环境,因此电话,访问者和会议请求都很少。在提交文件时,Bob的大部分时间都将花在与航空公司的电话上。这一切都很标准,很容易想象。当有电话进来时,鲍勃会把航空公司搁置,接听电话,直接拨打电话,然后回到航空公司。任何时候任何任务都引起鲍勃的注意,归档文件将被置于后台,因为它不需要立即关注。这是一个人同时完成许多任务,在适当的地方进行上下文切换。Bob是异步的。
这个的线程版本看起来像5个Bob,每个只有一个任务,但只有一个允许在任何给定时间工作。将有一个控制Bob可以工作的设备,它不了解任务本身。因为设备不理解任务的事件性质,所以即使其中有3个坐在那里什么都不做,它也会不断地在5个Bob之间切换。例如,Paper-Filing-Bob被打断,以便Phone-Call-Bob可以做一些工作,但是Phone-Call-Bob无事可做,所以他只是回去睡觉了。在所有Bob之间切换时只是为了找出其中3个甚至没有做任何事情。大约57%(略低于3/5)的上下文切换是徒劳的。虽然是,CPU上下文切换速度非常快,没有任何免费的
绿色线程
绿色线程是异步编程的原始级别。绿色线程看起来和感觉完全像普通线程,除了线程是由应用程序代码而不是硬件调度的。Gevent是一个众所周知的python库,用于使用绿色线程。Gevent基本上是绿色线程+ eventlet,一个非阻塞的I / O网络库。Gevent monkey修补了常见的python库,使其具有非阻塞I / O. 以下是使用gevents一次向多个网址发出请求的示例:
import gevent.monkey from urllib.request import urlopen gevent.monkey.patch_all() urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org'] def print_head(url): print('Starting {}'.format(url)) data = urlopen(url).read() print('{}: {} bytes: {}'.format(url, len(data), data)) jobs = [gevent.spawn(print_head, _url) for _url in urls] gevent.wait(jobs)
如您所见,gevent API的外观和感觉就像线程一样。然而,在引擎盖下,它使用协程而不是实际线程,并在事件循环上运行它们以进行调度。这意味着您可以获得轻量级线程的好处,而无需了解协同程序,但您仍然可以解决线程带来的所有其他问题。对于那些已经了解线程并希望减轻重量线程的人来说,Gevent是一个很好的库。
事件循环?协同程序?哇,慢下来,我迷路了......
让我们澄清一些关于异步编程如何工作的事情。进行异步编程的一种方法是使用事件循环。事件循环正是它的声音,
有一个事件/作业队列和一个循环,它不断地从队列中拉出作业并运行它们。这些工作称为协同程序。它们是一小组指令,包括重新放入队列的事件(如果有的话)
异步回调方式
虽然Python中存在许多异步库,但最受欢迎的可能是Tornado和gevent。正如我们已经讨论过gevent,让我们关注Tornado的工作方式。Tornado是一个异步Web框架,它使用回调样式来执行异步网络I / O. 回调是一个函数,它意味着“一旦完成,执行此函数”。它基本上是代码的“完成后”钩子。换句话说,回调就像你打电话给客户服务热线,并立即留下你的号码并挂断电话,这样他们就可以在可用时给你回电,而不必永远等待。
让我们来看看如何使用Tornado做同样的事情。
1 import tornado.ioloop 2 from tornado.httpclient import AsyncHTTPClient 3 urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org'] 4 5 def handle_response(response): 6 if response.error: 7 print("Error:", response.error) 8 else: 9 url = response.request.url 10 data = response.body 11 print('{}: {} bytes: {}'.format(url, len(data), data)) 12 13 http_client = AsyncHTTPClient() 14 for url in urls: 15 http_client.fetch(url, handle_response) 16 17 tornado.ioloop.IOLoop.instance().start()
为了解释一下这个代码,最后一行是调用一个名为AsyncHTTPClient.fetch的龙卷风方法,该方法以非阻塞方式获取URL。该方法基本上执行并立即返回,允许程序在等待网络调用时执行其他操作。因为在命中url之前到达了下一行,所以无法从该方法获取返回对象。这个问题的解决方案是,不是fetch方法返回一个对象,而是调用带有结果的函数或回调函数。此示例中的回调是handle_response。
回调的问题
在前面的示例中,您将注意到第一行正在检查错误。这是必需的,因为无法引发异常。如果引发异常,由于事件循环,它将不会由适当的代码段处理。什么时候取执行时,它启动http调用,然后在事件循环上处理响应。当我们注意到我们的错误时,调用堆栈将只是事件循环和这个函数,没有我们的代码来处理异常。因此,回调中抛出的任何异常都会破坏事件循环和程序。因此,所有错误都必须作为对象passed而不是raised。这意味着如果您忘记检查错误,则会吞下您的错误。任何熟悉golang的人都会认识到这种风格,因为语言在任何地方都强制执行。这是golang最受欢迎的方面。
回调的另一个问题是,在异步中,不阻塞事物的唯一方法是使用回调。这可能会导致回调后回调后的一长串回调。由于您无法访问堆栈和变量,因此最终将大对象推送到所有回调中,但如果您使用第三方API,则无法将任何内容传递给不期望的回调。这也成为一个问题,因为每个回调都像一个线程,但没有办法“收集”任务。让我们说例如你想调用三个API,然后等到三个完成,然后返回聚合结果。在gevent世界,你可以做到这一点,但回调你不能。您必须通过将结果保存到某些全局状态变量来破解它,并且在回调中您必须检查它是否是最后的结果。
比较
我们到目前为止比较一下。如果我们想阻止I / O阻塞,我们必须使用线程或异步。线程带有资源不足,死锁和竞争条件等问题。它还会为CPU创建上下文切换开销。异步编程可以解决上下文切换错误,但也有自己的问题。在python中,我们的选项是绿色线程或异步编程的回调方式。
绿色线程样式
- 线程在应用程序级别而不是硬件上进行控制
- 感觉像线程; 适合那些了解线程的人
- 包括除CPU上下文切换之外的普通基于线程的编程的所有问题
回调风格
- 根本不像线程程序
- 线程/协同程序对程序员来说是不可见的
- 回调吞下异常
- 回调不可收集
- 回调后的回调变得混乱,难以调试。
我们怎样才能改善?
Python需要一些方法来部分执行方法,停止执行,并在整个过程中维护堆栈对象和异常。如果您熟悉Python概念,您可能会意识到我在暗示Generators。生成器允许函数返回列表,一次返回一个项目,停止执行直到需要下一个项目。生成器的问题是它们必须被调用它的函数完全占用。换句话说,生成器不能调用生成器,停止执行两者。然而,直到PEP 380增加了yield from语法允许生成器 yield 另一个生成器的结果。虽然异步并不是生成器的意图,但它提供了使异步非常好所需的所有功能。生成器维护堆栈并可以引发异常。如果你要编写一个运行生成器的事件循环,你可以拥有一个很棒的异步库。因此,asyncio库诞生了。此时这个函数就被认定为是协程函数了。这是我们像以前一样调用相同的三个URL的示例。
1 import asyncio 2 import aiohttp 3 4 urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org'] 5 6 @asyncio.coroutine 7 def call_url(url): 8 print('Starting {}'.format(url)) 9 response = yield from aiohttp.ClientSession().get(url) 10 data = yield from response.text() 11 print('{}: {} bytes: {}'.format(url, len(data), data)) 12 return data 13 14 futures = [call_url(url) for url in urls] 15 16 asyncio.run(asyncio.wait(futures))
这里有几点需要注意:
- 我们不是在寻找错误,因为错误会正确地传递到堆栈中。
- 如果需要,我们可以返回一个对象。
- 我们可以启动所有协同程序,然后再收集它们。
- 没有回调
- 直到第9行完成后,第10行才会执行。(感觉同步/熟悉)
- 生活是美好的!唯一的问题是 yield from 看起来太像generator,如果它实际上是一个generator,它可能会导致问题。
Async and Await
该ASYNCIO库获得了大量关注,所以Python决定,使之成为核心库。随着核心库的引入,他们还在Python 3.5中添加了关键字async和await。 关键字旨在使您的代码更加清晰,异步; 所以你的方法不会与生成器混淆。在异步关键字去之前def表明,一个方法是异步的。await关键字替换从生成器 yield from ,使之更清楚你正在等待协同程序来完成。这是我们的示例,但使用async / await关键字。
1 import asyncio 2 import aiohttp 3 4 urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org'] 5 6 async def call_url(url): 7 print('Starting {}'.format(url)) 8 response = await aiohttp.ClientSession().get(url) 9 data = await response.text() 10 print('{}: {} bytes: {}'.format(url, len(data), data)) 11 return data 12 13 futures = [call_url(url) for url in urls] 14 15 asyncio.run(asyncio.wait(futures))
基本上这里发生的是异步方法,当执行时,返回一个可以等待的协同程序
Python最终拥有一个出色的异步框架asyncio。让我们看看线程的所有问题,看看我们是否解决了它们。
- CPU上下文切换:asyncio是异步的,使用事件循环; 它允许您在等待I / O时具有应用程序控制的上下文切换。这里没有CPU切换!
- 竞争条件:因为asyncio一次只运行一个协同程序并且仅在您定义的点处切换,所以您的代码在竞争条件下是安全的。
- 死锁/活锁:由于您不必担心竞争条件,因此您根本不必使用锁。这使你从死锁中非常安全。如果你需要两个协同程序来唤醒你,你仍然可能陷入死锁状态,但这种情况非常罕见,你可以尝试实现下。
- 资源饥饿:因为协同程序都在单个线程上运行,并且不需要额外的套接字或内存,所以资源耗尽会更加困难。然而,Asyncio确实有一个“执行器池”,它本质上是一个线程池。如果您在执行程序池中运行了太多内容,则仍可能耗尽资源。但是,使用太多的执行程序是一种反模式,而不是你经常会做的事情。
公平地说,虽然asyncio非常棒,但确实存在问题。首先,asyncio诞生不久。有一些奇怪的边缘情况会让你想得更多。其次,当你完全异步时,这意味着你的整个代码库必须是异步的。Every. Single. Piece。这是因为同步函数可能会占用太多时间,从而阻止了事件循环。asyncio的库仍然年轻且成熟,因此有时很难找到部分stack的异步版本。