【译】异步Python

原文地址:file:///D:/share/Asynchronous%20Python%20%E2%80%93%20Hacker%20Noon.html

异步编程在Python中正在变得越来越流行。而且,在Python中有许多库用来支持异步编程。其中之一是asyncio,它从Python3.4开始成为Python标准库中的一部分。Asyncio是异步编程在Python中开始大行其道的原因之一。

本文将阐述Python中异步编程的概念,以及对比其中的多种异步机制。首先让我们从Python的异步编程进化史开始。

每次一个

程序代码运行具有上下文的继承性(前后代码之间)。

例如,如果你有一行代码用来连接远程服务器并获取资源,意味着在等待期间,你的程序无事可做,只能等待。因为后续的代码,依赖交互的返回结果,才能继续。在单个连接的情况下,这种情形也是可以接受的,但是在很多情形下,这是不可想象的。

标准的解决方案是多线程。一个程序中可以管理多个线程,每个线程同一时间处理一件事情。

多个线程也就允许同时处理多件事情。但是,随之也产生了一些问题。首先,多线程编程更加复杂,且通常更易报错,尤其是这些令人头疼的问题:条件竞争、死锁、生命周期管理,以及饿死问题。

上下文切换

以上诸多问题,异步编程可以完全避免,虽然它被设计用于解决完全不同的问题:CPU上下文切换。

当多个线程同时运行的时候,每个CPU同时只能运行一个线程(注:意味着单核没有真正的异步)。为了在所有线程/进程之间共享资源,CPU上下文需要频繁切换。在某些简单的场景下,CPU间隔随机时间,保存当前线程信息然后切换到另外一个线程。这样的结果就是CPU会不断的重复这样一个过程,频繁切换。必须清醒的认识到,线程也是资源,并非没有开销。

异步编程本质上是软件/用户态线程(注:这是和多线程的根本区别),是应用程序自身来管理线程、上下文切换,而不是由CPU来选择时机。简而言之,在异步的世界中,代码中语句来决定何时切换上下文,而不是一个软件层面不确定的时机。

不可思议的高效率助理

我们先用一个非计算机的例子来类比一下。

假设你有一个工作非常高效的助理,从不浪费时间,按时搞定任何事情,最大化的利用每一秒。这个助理--我们暂且称呼Bob--不得不疯狂的工作以达到这样 的工作目标。Bob手头上有5件工作:接听电话、前台指引(给用户带路)、预定机票、制定会议日程、填表。我们假定在一个低强度的环境中,接听电话、客人来访以及会议安排很少发生,且存在间隔。Bob大部分时间需要花费在预订机票以及填表上。这种情形容易构思出来,且极为常见。当有电话接入时,Bob暂停预定机票,接听电话,结束之后继续预定机票。任何其他事情发生时,Bob都会将填表暂停,这并不是一个特别紧急的事情。这就是一个典型的一个人同时处理多个任务的模型,在适当的时机切换。我们可以说,Bob是异步工作的。

如果是多线程,就好比有5个Bob,每个人处理一个任务,但是同一时间只有一个人能工作。仿佛有一只无形的手在控制哪个Bob可以工作,虽然它并不知晓每个人的任务。因为这只无形的手并不理任务的属性 ,只能机械在5个Bob中不断切换,即使其中有的Bob无事可做。例如,填表的Bob被暂停,接电话的Bob开始工作,但是却没有电话需要处理,所以直接开始睡眠。这就导致了一些时间被浪费在了无效的切换上面。虽然CPU的切换速度异常迅速,但是天下没有免费的午餐,这仍然付出了时间的代价。

绿色线程

绿色线程是异步编程中的主要概念(层级)。一个绿色的线程看起来,听起来和正常的线程没有区别,出了前者是代码调度,而后者是CPU硬件级别调度。gevent是一个Python钟应用广泛的绿色线程库。gevent约等于绿色线程+eventlet,一个 非阻塞的I/O 网络库 。gevent monkey使得普通的Python库可以有非阻塞的I/O。

以下是一个简单的例子:

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运行起来如同threading。但是解开神秘面纱,它使用的是协程,而不是普通的线程,且运行一个事件轮询以调度它们。这意味着你能轻易的从这种轻量线程中获益,而无需理解协程(但是这将使你陷入和threading一样的困境)。当然,对于熟悉线程,想要轻量级线程的人而言,gevent仍然是一个好的选择。

事件轮询?协程?天哪,我晕菜了。。。
让我们来厘清异步编程中的一些概念。异步编程的一种选择是采用事件轮询。人如其名,它就是一个事件/任务队列,不断的放入新的任务,并轮流工作。这些任务被称为协程,是一些指令的小的集合。

回调风格的异步

Python中有为数不少的异步库,但是最流行的恐怕是Tornado和gevent了。我们之间讨论过gevent,现在我们在Tornado如何工作上花费一点时间。
Tornado是一个异步的Web框架,采取的是回调风格做异步的网络I/O。一个回调就是一个函数,意思是“一旦前置条件完成,则调用该函数”。简而言之,它是一个“完成之时”的代码钩子。
换句话说,这个很像当你呼叫服务的时候,他们将你的号码牌拿走,并当他们可以为你服务的时候呼叫你,而不是让你一直等待。好,我们来看看Tornao是如何处理同样的工作:

import tornado.ioloop
from tornado.httpclient import AsyncHTTPClient
urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org']

def handle_response(response):
    if response.error:
        print("Error:", response.error)
    else:
        url = response.request.url
        data = response.body
        print('{}: {} bytes: {}'.format(url, len(data), data))

http_client = AsyncHTTPClient()
for url in urls:
    http_client.fetch(url, handle_response)
    
tornado.ioloop.IOLoop.instance().start()

简单的解释一下,倒数第二行是调取Tornado方法AsyncHTTPClient.fetch,以异步的方式抓取一个url资源。这个方法最主要的功能就是直接返回,并允许程序做其他工作,等待网络返回回调。如果fetch函数执行后,仍然执行下一行代码,是无法取得返回值的(通常url的返回没有这么快)。
解决方案就是,不直接接受返回值,而是再次插入一个回调函数,以等待网络I/O返回时调用。该例子中的回调函数是handle_response.

回调的地狱

在上述例子中,你可以发现代码起始处有一个异常捕获。这个是必须的,因为通常而言不会发生异常。如果异常发生了,我们没有合适的代码来处理它。当fetch函数执行后,它启动http调用,然后通过事件轮询来处理返回值。当异常发生的时候,调用堆栈只能是事件轮询以及该函数,而该函数中没有任何代码处理异常。
因此,回调中产生的异常,将会终止消息轮询以及程序的运行。出于这种考虑,我们将容许这种异常,而不是直接抛出。这样就意味着,如果你忘记了检查错误,你的错误将会被忽视,而不是终止程序运行。熟悉Golang的人都很清楚这种风格,因为这是它的默认处理方式,也是它最让人诟病的地方。

回调带来的另一个问题就是,在异步程序中,非阻塞的唯一途径就是回调函数。而这会使得回调链条变得很长。这会让你对堆栈和变量失去控制,你只能通过将大量的对象塞到堆栈中来解决,但是如果你使用了第三方API呢?你可不要指望把堆栈传过去。
这已经成为一个严重的问题,回调类似于线程,但是并没有办法去收集/管理这些任务。举例来说,你想调用三个API,并等到调用都返回之后,返回总的结果。在gevent中,这轻而易举,但是回调却无能为力。你讲不得不将结果保存在全局变量中,然后检测是否是最后一个返回结果。

对比

我们来做个简单的对比。如果要避免I/O阻塞,线程、异步二者择一即可。
线程会调来一些问题,诸如饿死、死锁,条件竞争等。同时也导致CPU不断的切换。
异步编程可以解决CPU上下文切换的问题,但是却带来其他问题。在Python中,我们的选项是绿色线程,或者回调。

绿色线程风格

  • 线程控制在程序界别,和硬件调度无关;
  •  感觉像线程,便于理解;
  • 有普通线程的问题,但是并非CPU上下文切换。

回调风格

  • 迥异于多线程编程;
  • 线程/协程对于编码者是可见的;
  • 回调会吞掉所有的异常;
  • 无法对多个回调统一管理,收集结果;
  • 回调嵌套,难以理解,且无法调试。

如何改进?

坦率的说,在Python3.3之前,这的确使我们最好的选择了。如果要做的更好,只能寻求更多语言层面的帮助了。为了做的更好,Python需要有些改进,诸如函数部分执行(注:generator)、执行挂起、维护堆栈对象,以及异常处理。如果你对Python熟悉的话,你也许已经意识到了,没错,我说的就是Generator(生成器)。Generator允许你返回一个列表,但是每次返回一个,需要下个元素时返回下一个,直到全部返回。
Generator的问题在于,调用者必须调用Generator直到元素全部都被返回。换句话说,generator不能嵌套调用。直到PEP 380加入了yield from关键字支持,允许一个生成器返回另一个生成器的结果。
诚然,async并非generator的直接目的,但是却无形中提供了对async的完美支持。Generator维护一个堆栈,可以抛出异常。如果你正在用generator来完成一个事件轮询,这应该是个很棒的异步库。Bingo!asyncio横空出世了。你现在要做的只是添加一个@coroutine装饰器,asyncio会将你的生成器打包成一个协程。
以下是一个简单的例子,和上述例子完成一样的事情:

import asyncio
import aiohttp

urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org']

@asyncio.coroutine
def call_url(url):
    print('Starting {}'.format(url))
    response = yield from aiohttp.ClientSession().get(url)
    data = yield from response.text()
    print('{}: {} bytes: {}'.format(url, len(data), data))
    return data

futures = [call_url(url) for url in urls]

asyncio.run(asyncio.wait(futures))

有几个事情需要注意:

  1. 无需查找错误,堆栈会进行处理(?);
  2. 如果需要,我们可以返回一个对象;
  3. 我们可以启动所有协程,并收集它们的结果;
  4. 没有回调;
  5. 只有第9行彻底结束,第10行才能被执行到。

太棒了!唯一的问题是yield from看起来太像生成器了,且会代入一些其他问题。

Async和Await

随着asyncio支持了越来越多的应用,Python官方决定将它纳入核心库。随之而来的是,Python3.5版本中引入了async和await关键字。加入async/await关键字的目的在于让代码更清晰,同时也能更明确的和生成器进行区分。
async关键字放在def之前,标明这是一个异步方法;await替代了yield from,看起来更加明晰,等待一个协程完成。
以下是一个使用了async/await关键字的例子:

import asyncio
import aiohttp

urls = ['http://www.google.com', 'http://www.yandex.ru', 'http://www.python.org']

async def call_url(url):
    print('Starting {}'.format(url))
    response = await aiohttp.ClientSession().get(url)
    data = await response.text()
    print('{}: {} bytes: {}'.format(url, len(data), data))
    return data

futures = [call_url(url) for url in urls]

asyncio.run(asyncio.wait(futures))

简而言之,当一个async函数被执行的时候,返回一个协程,而该协程会被异步等待。

到达终点

Python终于有了一个出色的异步框架-asyncio。让我们来看看多线程带来的问题,以及是否得到了解决:

  • CPU上下文切换 asyncio是异步的,采用了事件轮询机制;允许代码中指定何时进行协程切换。没有CPU上下文切换。
  • 条件竞争 由于asyncio同一时间只执行一个协程,且在代码中指定的位置切换,所以代码更多的从条件竞争中安全脱身。
  • 死锁 由于你不必担心条件竞争,你将完全没有使用锁的必要。这让你彻底摆脱死锁。当然,如果两个协程相互唤醒,当然也存在进入死锁的可能。
    饿死 由于协程都运行在独立的线程中,不需要额外的socket或者内存,所以运行外部资源要困难的多。但是Asyncio提供了一个“executor pool”作为线程池。如果你在一个executor pool中运行了太多任务,你仍然会运行外部资源。但是在,这种运行方式是不被提倡的,且这种场景并不多见。

平心而论,asyncio足够优秀,当然,也有它自己的问题。
首先,asyncio是Python中的新人,会有一些诡异的边界案例让你困惑。
其次,当你完全采用异步,也就意味着你整个的代码全都是异步结构的。每个-微小-片段。在异步程序中,某些同步函数会耗费很长时间,因此会阻塞事件轮询。(注:只有async/await关键字的地方才会切换,而同步函数无法使用await,所以会阻塞所有的协程切换)
最后,由于asyncio时日尚浅,为它提供的库并不是很成熟,而且还在逐渐成长中,因此,有时寻找asyncio可用的库也是一件费力的事情。

猜你喜欢

转载自blog.csdn.net/lzl001/article/details/84995093