原文: Asynchronous Programming in python
译者:杰微刊兼职翻译马娟
![[译]Python中的异步编程](http://www.codesec.net/app_attach/201610/12/20161012_422_481358_0.png!web)
Quora的任务是分享并提高世界的认知,为了完成这个任务,我们不断地推出改进以使Quora对于我们的读者和作者来说速度更快。在我们上一篇文章里边,我们报道了我们近期对于客户端性能的优化,在这篇文章中,我们将讨论我们近期通过我们的异步编程架构对于服务器端性能的改进。
为同时优化页面加载和用户行为的性能表现,我们大力使用缓存,目的是为了确保对频繁使用的数据的访问持续高效。在Quora中,对于存储在一个较慢的存储系统如mysql中的数据,我们使用memcached作为我们的主缓存层。例如,当我们呈现出支持一个问题的一列人员时,我们常常要去我们的存储层取得从用户ID到用户姓名的映射。每一个请求直接访问MySQL的话会很快使我们的数据库过载,因此我们代以使用memcached去缓存这个映射。尽管为每一个用户ID分配一个独立的memcached请求会比查询MySQL更快,但是用一个单一的批量的多请求来取得所有数据甚至会更快。
因为网络通常是一个memcached请求中代价最大的部分(在我们的环境中达到整个时间的80%),因此适当批量缓存请求对于保持Quora的快速是很重要的。然而,如果开发者不得不手动指明所有数据如何从memcached中批量取回,这将是乏味且易错的。因此,我们开发出了称作Asynq的抽象概念,它使开发者批量写缓存请求时变得很简单,这个现在我们是开源的。
Priming
在我们开发Asynq之前,我们使用一个我们称作priming的方法来给memcached分配批量请求。每次开发者写一个访问数据的功能时,他们也需要写一个独立的priming函数来指明所有将被这个功能访问的数据。例如,假设我们已经有如下函数,这个函数取回一组姓名,这列姓名给定一组用户ID:
def get_names(uids):# let's say name_of_user is cached
return [name_of_user(uid) for uid in uids] 相应的priming函数会是这样的: def prime_get_names(uids):
for uid in uids:
prime_name_of_user(uid)
调用prime_get_names的代码首先要依赖于调用的get_names代码,get_names代码将使用一个多请求从memcached取回所有必需的数据。然后,这些数据将被存储在服务器本地缓存中,这样当真正的函数(这个例子中是get_names)运行时,它将不能做任何网络请求了!本质上,每个prime函数都代表了对一个函数或者该prime函数所依赖的memcached键值的依赖项。
当我们需要调用一个缓存函数去决定从memcached中获得什么样的额外数据时,Priming会变得更复杂。考虑如下模板函数: def get_upvoter_names(aid):# let's say upvoters_of_answer is cached as well
upvoter_uids = upvoters_of_answer(aid)
return [name_of_user(uid) for uid in upvoter_uids] 在这个例子中,prime_get_upvoter_names需要知道upvoter_uids以调用那些uids的prime_name_of_user。但因为upvoters_of_answer被缓存了,因此它也必须被启动。这样的话,相应的prime函数也将会明显得更为复杂: @priming.generator
def prime_get_upvoter_names(aid):
prime_upvoters_of_answer(aid)
# the above yield has already fetched upvoters_of_answer and put it
# in the local cache, so the call below is just going to hit local cache
upvoter_uids = upvoters_of_answer(aid)
for uid in upvoter_uids:
prime_name_of_user(uid)
通过恰当地启用我们所有的模型调用,我们看到了明显的速度改进,因此,我们使用我们的静态分析工具来强化了一个规则,即需要呈现Quora上任何页面的所有数据必须首先被primed。然而这些速度的提升伴随着一个明显的开发耗费,因为实质上开发者需要写(并调用)所有的模型函数两次。随着我们代码库的增长,priming变得冗长乏味、难以解释且易错。
Asynq
为了解决priming引出的复杂性问题,我们创造了一个称作Asynq的框架,它在底层使用类似的方法,但是修补了API,这样缓存请求就被集成到模型代码本身里边。在Asynq下,所有需要数据访问的函数都被调度程序来运行,这个调度程序保持对这些函数的依赖项的追踪。当一个函数需要通过调用其它函数来取回数据时,Asynq不再控制调度程序,而是指明这个函数需要取回的数据。然后调度程序停止执行这个函数直到它解决掉这个函数所有的依赖项。
在Asynq下,先前的get_names函数是这样的: from asynq import async@async()
def get_names(uids):
names = yield [name_of_user.async(uid) for uid in uids]
return names 不需要额外的priming函数――所有代码都包含在模型函数自身当中。因此,开发者不仅不再需要写一个完全独立的priming函数,他们也不需要记住每次在模型函数被调用时都去调用priming函数。之前更复杂的get_upvoter_names例子在Asynq中也会更简单: from asynq import async
@async()
def get_upvoter_names(aid):
upvoter_uids = yield upvoters_of_answer.async(aid)
names = yield [name_of_user.async(uid) for uid in upvoter_uids]
return names
Async函数可以理解为创造了一张依赖项图:在它的第一个yield中,get_upvoter_names依赖于upvoters_of_answer的完成。相似地,upvoters_of_answer可能有它自己的依赖项。直到所有当前正在执行的函数在从memcached中获取数据时发生阻塞时,Asynq调度程序才会解析这张依赖项图,执行Async函数。然后,调度程序使用一个单独的多请求从memcached取回数据,并继续执行直到Async函数执行完成。
假设我们有一个称作model_call()的async函数,这个函数有三个依赖项,每一个依赖项都会读取多重的memcached键值。一个单纯的执行将会使用三个多请求(或者甚至六个独立请求),分别用于每个依赖项函数,而由async调度程序解析的依赖项图表可能是如下这样:
![[译]Python中的异步编程](http://www.codesec.net/app_attach/201610/12/20161012_422_481358_1.jpg!web)
我们异步编程的方式不同于其他异步Python库像asyncio, Twisted, gevent, and Tornado。这些库关注于异步I/O,而Asynq关注于高效的批处理。例如,一个典型的使用memcached和asyncio的缓存执行将各自解析缓存依赖项,这样每个memcached请求都会对memcached产生一个单独的请求。在Asynq下,依赖项将会成批进入一个单独的memcached多请求,这能减少在I/O上阻塞的总时间。另外,Asynq允许函数被同步调用或异步调用(通过一个增加的.async属性),而asyncio需要所有的async def函数则需要明确安排在asyncio.get_event_loop()中。随着Asynq的批处理,我们只花费很少时间在I/O的阻塞上,因此采用其他异步I/O库对我们来说不是一个更好的选择。
与priming相比,Asynq提供一个更普遍的、简明的、有原则性的方式去进行批处理。因为逻辑仅需要被执行一次,Asynq明显比priming需要更少的开发耗费。减少priming的重复逻辑也会有性能改进,我们拷打服务器端速度的提升跟我们将代码库更多的从priming迁移到Asynq是一样的。
迁移和学习
开发出Asynq的第一个版本后,通过将代码库的几个小部分从priming迁移到Asynq,我们着手去验证我们的设计和实施决定。在做这个的过程中,我们发现并确认了一些问题:
1) 在Python 2.7中,生成器不能返回一个值,因此上述代码段实际上在Python 2.7中是无效的。在Asynq的第一个版本中,异步函数产生的最后一个值将会被解析为函数的返回值。然而,这意味着yield关键字的过载,这会使代码很难阅读。作为一个可替代的解决办法,我们从生成器中返回一个值――在PEP 380中有介绍――从Python 3到Python 2.7,必要的时候,在代码库中我们目前正在使用Python 2.7的一个补丁版本。(Asynq也支持Python 2.7的未打补丁版本,通过使用一个result函数,这个函数抛出一个异常,这个异常被解析为返回值。)
2 )最初,使用@async()装饰器来使一个函数变为异步函数完全改变了这个函数的接口――所有调用者不得不使用一个特殊的语法去调用async函数。这个决策使得priming and Asynq在我们的代码库中更难共存,后来所有的开发者都需要去意识到这个差异。为了解决这个问题,我们更新了@async()装饰器,给所有异步函数增加了一个新的.async属性,这样直接调用一个装饰过的函数将仍会返回一个结果。
3 )起初测试异步函数很有挑战性。在单元测试中我们普遍使用Python的 mock模块,但是模拟async函数变得很困难,因为这样做需要对.async属性的特殊处理并返回一个值。作为解决办法,我们创造了一个专用的模拟函数,asynq.mock.patch,这个函数自动关注对async函数的模拟,这使得模拟async函数变得更容易。
实施以上改进之后(连同许多其他改进),我们决定将我们的代码库从priming完全迁移到Asynq。让两个抽象概念同时存在于我们的代码库中对于我们的开发速度是不利的,因为工程师需要在两个API中进行上下文转换,这取决于他们正在编辑的是哪个模型。我们利用现有的静态分析工具自动进行迁移,这样我们的工程师仅需要去核实脚本的输出并做些微小的改变,而不是亲自手动迁移代码。
在着手进行我们整个代码库的大规模迁移之前,我们在不同的团队中通过工程师完成了大量的更小规模的“dry runs”,作为一种改善我们的自动迁移工具的方式,并得到精确的范围评估。完成几个dry run之后,我们由大约30个工程师(即我们工程师团队的50%)完成了一个有协调性的迁移,在这期间我们迁移了Quora代码库中15000多行priming代码,仅用了4天时间。
今天,我们整个代码库仅使用Asynq,服务器端开发变得更快且更不易出错。
开源Asynq(和朋友们)
现在可以在GitHub and PyPI上获得Asynq。你可以浏览源代码或者通过pip install asynq安装Asynq。和Asynq一起,我们也将QCore开源化了(Asynq仅有的从属项),QCore是一个助手集,贯穿使用于Quora代码库,包括一个装饰器框架、一个枚举实现、测试助手和一个事件实现。Asynq and QCore同时兼容Python 2.7 and Python 3,Asynq的更多详细文档可以在GitHub库中查看。
不断地前进,我们一直不断地致力于使Quora对于我们的用户来说更快,使得新特性对于我们的工程师来说更容易进行开发。我们目前正雇佣Platform工程师来开发像Asynq的核心框架和抽象概念,因此看看我们的职位页面,如果你愿意和我们分享并增长这个世界的知识!