我收到的关于Python Asyncio最常见的问题是“它是什么,我能拿它来做什么”。这个问题最多的回答可能是 “在一个程序中执行多个并发HTTP请求”。事实上它不仅如此。Asyncio需要改变您构建代码的方式。
下面的故事为理解这一点提供了一个背景。异步的重点是如何最好地同时执行多个任务,不只是任何任务,而是特别需要等待时间IO的任务。这种编程风格关键的点是,要求你等待这个任务的时候 转而去完成其他任务。
The Restaurant of ThreadBots (eg:餐厅的线程机器人)
时间是2051年,你发现自己身处餐饮业。自动化、主要是机器人工人,为经济提供动力,但事实证明,人类仍然喜欢偶尔出去吃一顿。在你的餐厅里,所有的员工都是机器人——当然是类人机器人,但毫无疑问是机器人。最成功的类人机器人制造商是Threading Inc 公司,来自这家公司的机器人工人已经来了被称为“ThreadBots。
除了这些机器人的小细节,你的餐厅看起来和运作起来就像那些从2020年开始的老店。你的客人将会寻找传统的的用餐体验。他们需要从零开始准备的新鲜食物。他们想坐在桌子旁。他们想等着吃饭,但只是片刻功夫。他们在最后付账的时候,甚至想留下小费。
作为新兴的机器人餐饮行内,你需要的是雇佣一些机器人:一个在前台迎接客人的机器人(GreetBot),一个是侍应和点菜(WaitBot),一个是做饭(ChefBot),还有一个吧台管理(WineBot)。
饥饿的用餐者来到前台,受到GreetBot的欢迎。然后他们被引导到一个桌子,一旦他们坐好,服务员机器人他们的订单。然后服务员用一张纸条把订单送到厨房(因为你想保留过去的体验,记得吗?)ChefBot看起来按纸条上的指示开始准备食物。WaitBot会定期检查食物是否准备好,什么时候准备好,会马上把菜送到厨房
托盘上。当客人准备离开时,他们返回到GreetBot,付了帐,接受了他们的付款,然后亲切地祝他们度过一个愉快的夜晚。
你的餐厅很火,很快大量的顾客纷纷到来。你的机器人员工完全按照他们被告知的去做,他们非常擅长于你的工作分配。一切都很顺利,你高兴极了。
然而,随着时间的推移,你确实开始注意到一些问题。哦,没什么大不了的严重的;只是有几件事似乎出了差错。其他所有的机器人餐厅老板似乎也有类似的小故障。令人担忧的是,你越成功,这些问题似乎就越严重。
虽然很少见,但偶尔的碰撞还是令人不安的:有时,当厨房里的一盘食物准备好了,WaitBot会在ChefBot之前把它抢过来甚至放开盘子。这通常以盘子破碎和留下一个大烂摊子而告终。当然,ChefBot会对其进行清理,但是您仍然会认为这些一流的机器人会知道如何使彼此更加同步。在酒吧里也会发生这种情况:有时WineBot会在吧台上点酒,而WaitBot会在WineBot松开手之前把酒抢过来,导致酒杯破碎,洒出内德堡赤霞珠(Nederburg Cabernet Sauvignon)。
此外,有时GreetBot会在WaitBot决定清理它认为是空桌子的地方的同时让新就餐者就座. 这对就餐者来说很尴尬. 我们尝试过在WaitBot的清理功能中加入延迟逻辑,或者在GreetBot的坐位功能中加入延迟,但这些都无济于事,碰撞仍然会发生。但至少这些事件是罕见的。
确实是这样,但是你的餐厅变得如此受欢迎,以至于你不得不雇佣更多ThreadBots. 在非常繁忙的周五和周六晚上,您必须添加第二个GreetBot和两个额外的等待机器人。不幸的是,ThreadBots的雇佣合同意味着你必须雇佣他们一整个星期,所以这实际上意味着在一周的大部分空闲时间里,你都要雇佣三个你并不真正需要的额外的ThreadBots。
另一个资源问题,增加了额外的成本,如果你需要处理更多的工作 就需要更多额外的机器人。只监视4个机器人是可以的,但现在你要监视7个机器人了。持续监视7个机器人是不小的工作,正因为你的餐厅越来越受欢迎,你将需要关注更多的机器人。跟踪每个ThreadBot在做什么将成为一项全职工作。此外额外增加的机器人也会占用你餐厅的空间。这对你的顾客来说变得很紧张,因为所有的机器人都在快速移动。你会很担心不得不增加更多的机器人,这样空间资源问题 将变得更加糟糕。你期望的是将空间留给顾客,而不是机器人。
在添加了更多ThreadBots之后,碰撞也变得更糟了。有时两个服务员机器人在同一时间从同一个表中获取完全相同的订单。这就好像他们都注意到这张桌子已经准备好点餐了,然后就走了进去取餐,而没有注意到另一个服务员机器人也在做同样的事情。正如你想象的一样,餐厅出现了重复订单,这会额外增加后厨负载,同时增加碰撞机会。你很担忧随着waitBOt增加,情况会变得更糟。
时间流逝。
然后,在一个非常非常忙碌的周五晚上,你有一个非常清晰的时刻:时间慢了下来,清晰的压倒了你,你看到了你的餐馆冻结在时间的快照中。我的ThreadBots什么都没做!说句公道话,并不是什么都没有,但他们只是在等待。
你的三个服务员机器人在不同的桌子上等待其中一个桌子上给出订单。WineBot已经准备了17种饮料,现在正在等待取酒(只需要几秒钟),并且正在等待新的饮料订单。其中一个“问候机器人”已经迎接了一群新客人,并告诉他们需要等待一分钟才能入座,并且正在等待客人的回应。另一个GreetBot正在处理另一位即将离开的客人的信用卡支付,它正在等待支付网关设备上的确认。即使是厨师机器人,他现在正在做35顿饭,实际上也没有做任何事情,只是简单地等待其中一顿饭完成烹饪,这样就可以把它装盘并交给服务员机器人。
您意识到,即使您的餐厅现在充满了ThreadBots,并且您甚至在考虑购买更多(包括所有需要解决的问题),但是您目前使用的那些并没有得到充分利用。
这一刻过去了,但是你并没有意识到这一点。周日,您将向ThreadBots添加一个数据收集模块。对于每个ThreadBot,都要度量等待的时间和积极工作的时间。通过一个星期你收集到了数据。在周末晚上你开始分析这些结果。事实证明,即使餐厅满座,最努力工作的机器人也有98%的空闲。ThreadBots非常高效,可以在不到一秒的时间内执行任何任务。
作为一名企业家,这种低效率真的会让你感到困扰。你知道其他所有的机器人餐厅老板都和你一样在经营他们的生意,有很多相同的问题。但是,你想,用拳头猛击你的桌子。“这一定有更好的办法”.
因此,就在第二天,也就是安静的周一,您尝试了一些大胆的事情:编写一个ThreadBot来完成所有任务. 每当它开始等待,哪怕只有一秒钟,ThreadBot切换到下一个要在餐厅完成的任务,不管是什么,反正不是等待。这听起来令人难以置信——只有一个ThreadBot完成所有其他线程的工作——但是您相信您的计算是正确的。而且,星期一是个安静的日子;即使出了差错,影响也会很小。对于这个新项目,您将该机器人称为“LoopBot”,因为它将遍历餐厅中的所有工作。
编程比平常更难了。这不仅仅是因为您必须为一个ThreadBot编写包含所有不同任务的程序;您还必须编写一些何时在任务之间切换的逻辑。但是到目前为止,您已经有了编写这些ThreadBots的丰富经验,所以您可以设法完成它。
你像老鹰一样盯着你的LoopBot.
- 它在不到一秒的时间内在各个站点之间移动,检查是否有工作要做。
- 开业后不久,第一个客人就来到了前台.
- LoopBot几乎立刻就出现了,并询问客人是喜欢靠近窗户的桌子还是靠近酒吧的桌子。但是,当LoopBot开始等待时,它的程序会告诉它切换到下一个任务,然后它就迅速离开了。
- 这似乎是一个可怕的错误,但当客户开始说“请打开窗口”时,LoopBot又回来了
它接收回答并指引客人到42号桌. 它又开始了,一遍又一遍地检查饮料订单、食物订单、餐桌清理和到达的客人。
周一晚上,你为自己取得的巨大成功而祝贺. 检查LoopBot上的数据收集模块,确认即使使用一个ThreadBot完成7个线程的工作,空闲时间仍然在97%左右。这个结果让你有信心在本周余下的时间里继续实验。
随着繁忙的周五礼拜的临近,你开始反思你的实验取得的巨大成功. 对于正常工作周期间的服务,您可以使用一个LoopBot轻松地管理工作负载。你会注意到另一件事:你不会看到更多的碰撞. 这是有道理的。因为只有一个LoopBot,所以它不能与自己混淆。厨房不再有重复的订单,也不再有什么时候拿盘子或饮料混乱。
周五晚的服务开始了,正如您所希望的那样,单ThreadBot与所有的客户和任务保持同步,并且服务比以前进行得更好。您可以想象您现在可以获得更多的客户,并且您不必担心必须带来更多的ThreadBots。你开始考虑这能省下多少钱。
然后,不幸的是,有些地方出了问题:其中一餐,一个复杂的蛋奶酥,失败了。这在你的餐厅里从来没有发生过。你开始更仔细地研究LoopBot。 原来,在你的一个桌子旁,有位非常健谈的客人。这位客人独自来到你的餐厅,并一直试图与LoopBot交谈,甚至有时会握着你的LoopBot的手。当这种情况发生时,你的LoopBot就无法处理餐厅其他地方不断增长的任务列表。这就是为什么厨房生产了第一个失败的蛋奶酥:你的LoopBot无法回到厨房把盘子从烤箱中拿出来,因为它被一个客人拉住了。
周五的服务结束了。你回家反思你所学到的东西。LoopBot却是能够昨晚忙碌周五的所有工作,但是另一方面,你的厨房做出了第一顿变质的饭菜,这是前所未有的。健谈的客人过去总是让服务员机器人忙个不停,但这根本没有影响到厨房的服务。
综上所述,您决定继续使用单个LoopBot更好。令人担忧的碰撞不再发生,有更多的空间留给可以服务的顾客。但你会意识到LoopBot的一些深刻意义:只有当每个任务都很短,或者至少可以在短时间内完成时,它才会有效。 如果有任何活动使LoopBot也保持忙碌 长此以往,其他任务就会开始受到忽视。
提前知道哪些任务可能会花费太多时间是很困难的。如果客人点的鸡尾酒需要复杂的准备工作,比平时花更多的时间,该怎么办?如果客人想要抱怨前台的一顿饭,拒绝付款,抓住LoopBot的手臂,阻止它切换任务,该怎么办?您决定,与其预先解决所有这些问题,不如继续使用LoopBot,记录尽可能多的信息,并在以后出现问题时处理它们。
更多的时间过去了
渐渐地,其他餐馆老板注意到了你的操作,最终他们发现,他们也可以靠一个ThreadBot过活,甚至可以兴旺发达. 很快,每家餐厅都以这种方式运营,而且很难记住机器人餐厅曾经使用过多个ThreadBots。
后记
在我们的故事中,每个餐厅机器人都是一个单线程。在这个故事中,关键的观察是餐厅工作的性质涉及很多request.get()
正在等待服务器的响应。
在餐馆里,当慢的人做手工工作时,工人等待的时间并不长,但是当超级高效、快速的机器人做工作时,他们几乎所有的时间都花在等待上.在计算机编程中,当涉及到网络编程时也是如此。cpu 工作并在网络I/O上等待。现现代计算机中的cpu速度非常快——比网络IO快几十万倍。因此CPU在网络编程中花了大量时间在等待上。
本文的观点是,可以编写程序来显式地指示CPU在工作任务之间切换。尽管在经济性方面有所改进(同一工作使用更少的cpu),但与线程(多cpu)方法相比,真正的优势是消除了竞争条件。
然而,这并不是十全十美的:正如我们在故事中发现的,大多数技术解决方案都有优点和缺点。LoopBot的引入解决了特定类型的问题,但也引入了新的问题——其中最重要的是餐馆老板必须学习一种稍微不同的编程方法
What Problem Is Asyncio Trying to Solve? (异步通信试图解决什么问题?)
对于I/ o绑定的工作负载,使用基于异步的并发而不是基于线程的并发的原因有两个:
- 异步提供了一个更安全的选择抢占多任务(即。,从而避免了在重要的线程应用程序中经常发生的错误、竞争条件和其他不确定的危险。
- 异步提供了一种简单的方法来支持数千个同时的套接字连接,包括能够处理许多新技术(如WebSockets或用于物联网(IoT)应用程序的MQTT)的长期连接。
作为一种编程模型,线程最适合于某些类型的计算任务,这些任务最好由多个cpu和共享内存执行,以便在线程之间进行有效的通信。
网络编程不在这些领域之列。关键的是,网络编程涉及大量的“等待事情发生”,因此,我们不需要操作系统在多个cpu上有效地分配任务。此外,我们不需要先发制人的多任务处理带来的风险,例如使用共享内存时的竞争条件。
然而,基于事件的编程模型有大量的所谓其它好处的误解。以下是一些并非如此的事情:
异步将使我的代码非常快。
- 不幸的是,没有。事实上,大多数基准测试似乎都显示线程解决方案比它们的同类异步解决方案稍微快一些。如果并发程度本身被认为是性能指标,异步确实使创建大量并发套接字连接变得更容易一些。操作系统通常对可以创建多少线程有限制,
这个数字明显低于可以进行的套接字连接的数量。操作系统的限制可以更改,但是使用异步肯定更容易。虽然我们期望有成千上万的线程会产生协同程序避免的额外的上下文切换成本,但是在实践中很难对其进行基准测试。
No, speed is not the benefit of Asyncio in Python; if that’s what you’re after, try Cython instead!
异步使线程冗余
- 当然不是!线程的真正价值在于能够编写多cpu程序,不同的计算任务可以共享内存。例如,numpy已经通过使用多个cpu加速了某些矩阵计算,即使所有的内存都是共享的。就纯粹的性能而言,在cpu范围的计算方面,这种编程模型是无可匹敌的。
异步消除GIL的问题
- 同样不是,异步通信确实不受GIL的影响,但这只是因为GIL影响多线程程序。由于GIL在使用线程时阻止了真正的多核并行,因此人们所提到的GIL出现了“问题”。由于异步是单线程的(几乎是定义上的),它不受GIL的影响,但是它不能从多个CPU内核eithe中获益。Python GIL可能会导致其他方面已经提到过的性能问题点。Dave Beazley在PyCon 2010上发表了一个名为“理解Python GIL”的演讲,演讲中讨论的大部分内容在今天仍然适用。
异步阻止所有竞争条件
- 当然不是。在任何并发编程中,竞争条件的可能性总是存在的。不管使用的是线程还是基于事件的编程。异步确实可以消除多线程程序中常见的某类竞争条件,例如进程内部共享内存访问。然而,这并不能排除其他竞态条件的可能性,例如,进程间竞争使用分布式微服务体系结构中常见的共享资源。您仍然必须注意如何使用共享资源。异步相对于线程代码的主要优点是,在协同程序之间传输执行控制的点是可见的(因为存在await关键字),因此更容易推断共享资源是如何被访问的。
异步使并发编程变得简单。
- 咳咳,我该从哪儿说起呢?
最后一个神话是最危险的。处理并发总是很复杂,不管您是使用线程还是异步。当专家说“异步使并发性更容易”时,他们真正的意思是,异步使避免某些真正可怕的竞争条件错误变得更容易。那种让你彻夜难眠的感觉,那种你在篝火旁用安静的声音告诉其他程序员的感觉,那种狼在远处嚎叫的感觉。
即使使用异步,仍然有大量的复杂性需要处理。您的应用程序将如何支持健康检查?如何与只允许少量连接的数据库进行通信 比你连接客户端的5000套接字要少得多。当收到关闭信号时,程序如何优雅地终止连接?您将如何处理(阻塞!)磁盘访问和日志记录?这些只是您必须回答的许多复杂设计决策中的一部分。
应用程序设计仍然很困难,但希望当您只有一个线程要处理时,能够更轻松地推理应用程序逻辑。
From《Using Asyncio in Python》-chapter1