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中存在许多异步库,但最受欢迎的可能是Tornadogevent。正如我们已经讨论过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))

 

这里有几点需要注意:

  1. 我们不是在寻找错误,因为错误会正确地传递到堆栈中。
  2. 如果需要,我们可以返回一个对象。
  3. 我们可以启动所有协同程序,然后再收集它们。
  4. 没有回调
  5. 直到第9行完成后,第10行才会执行。(感觉同步/熟悉)
  6. 生活是美好的!唯一的问题是 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诞生不久有一些奇怪的边缘情况会让你想得更多。其次,当你完全异步时,这意味着你的整个代码库必须是异步的EverySinglePiece这是因为同步函数可能会占用太多时间,从而阻止了事件循环。asyncio的库仍然年轻且成熟,因此有时很难找到部分stack的异步版本。

 

posted @ 2018-12-07 14:23  twoseee  阅读(1108)  评论(0编辑  收藏  举报