Python培训
美国上市Python培训机构

400-111-8989

热门课程

如何理解 Python 的异步编程?

  • 时间:2017-07-11 14:12
  • 发布:Python培训
  • 来源:问答

异步编程的 Python 实现,以及应用场景。

和异步编程相对应的是同步编程,我们刚开始学编程的时候一般写的代码都是所谓同步的,就是依次执行每一条指令。

即使加入了条件、循环和函数调用等语法支持,但是从本质上来看,我们认为这种编程方式在同一时间还是只能执行一条指令,完成一个才执行下一个。

下面举几个同步编程的例子:

- 批处理程序 一般是采用同步的方式来写:获取输入,处理数据,输出结果。这些步骤就这么一环扣一环地执行下去,别无他求。
- 命令行程序 一般都很小,主要是为了方便快捷地完成一些转换。这种任务通常被分解为一系列小任务,然后按指定顺序一步一步执行就好了。

异步程序 和上面提到的这些就不太一样了。尽管也是按照一定的步骤执行,区别在于,整个系统不需要等待某一个任务执行完毕就可以进行下一步。

就是说,我们的程序可以在一个(或者多个)任务还在执行过程中就继续执行下面的步骤。这也意味着当那些(后台)任务一旦完成,我们还得接管回来继续处理。

为什么要这么做呢?简单来说,因为这种方式可以解决同步编程不好解决或者解决不了的问题。

下面给大家举几个有代表性的异步编程的例子:

简化的网络服务器

网络服务器的基本工作流程跟我们刚才提到的批处理程序也差不多,获取输入,处理数据,输出结果。用同步的方式当然也能让服务器运行起来 —— 以一种很刺激又悲催的方式。

为什么? 网络服务器的工作任务可不光是完成单独的一个小流程(输入,处理,输出),这还远远不够,它必须能持久稳定的同时处理成百上千个同样的工作流程。

同步网络服务器还能改进吗? 我们或许可以通化优化程序执行效率来加快处理速度。但事实上,在网络服务器上有很多不以我们意志为转移的限制条件,就注定了它没法做出快速响应,也没法同时应对大量的用户请求。

到底是哪些限制条件呢? 网络带宽,文件读写速度,数据库查询速度,以及其他相关服务的速度等等。这些限制条件有一个共同点——都是读写操作。这类操作的速度比我们的 CPU 速度通常会慢好几个量级。

如果我们的网络服务器是同步模式的,假如执行了一个数据查询的步骤(打个比方),在查询结果返回之前的很长一段时间里 CPU 基本都处于闲置状态,完全可以用来干点别的。

对于批处理程序来说,这一点并不关键,处理读写操作的结果才是关键,并且耗时也远比读写操作要多得多。对于这种程序,优化的重点就不是读写操作部分,而是处理过程。

其实文件、网络和数据库的读写也不算慢,只是和 CPU 的速度相比显得很慢。异步编程技术能够让我们充分利用相对较慢的读写操作的工作间隙,把 CPU 过剩的能量释放出来去处理其他任务。

我自己刚接触异步的时候,不管是请教别人也好,还是查找资料也好,都遇到同一个问题,他们总是强调代码的非阻塞性。我觉得这是个误区。

非阻塞是什么鬼?阻塞又是什么鬼?我都不知道这项技术的应用环境和具体效果,直接讲这些不着边儿的术语和概念又有什么意义呢?

真实的世界就是异步的

异步编程(和常规编程思路)有点区别,很容易懵。但是很有意思,因为我们生活的真实世界,以及我们跟这个世界相处的方式,本身就是异步的。

你应该有过这样的经历: 作为一名家长,有时会同时忙活好几件事儿,结算账单,洗衣服,同时还要看孩子。

我们自己这么做的时候甚至是下意识的,但是现在,我要把它分解开来:

- 结算账单是我们需要完成的一项任务,可以视为一个同步的任务,一项一项结算,直到全部算完。
- 有时,我们算着算着账就要停一下,去把烘干机里的衣服取出来,再把洗衣机里洗好的衣服扔进去继续烘,然后洗衣机还要再洗一缸。这些任务完成的过程就是异步的。
- 洗衣机和烘干机分别都是同步任务,但是我们只需要启动机器,后面的大部分工作他们可以自主完成,这个时候我们就可以回去继续算我们的账了。这时就是异步任务了,洗衣机和烘干机可以独立工作,等完成了就用蜂鸣器通知我们过去处理。
- 看孩子也是异步的。一旦开始玩游戏就基本不用管他们了。等到肚子饿了或者碰伤了他们就会哭着喊着找爸爸妈妈,这时我们再去处理。孩子对于我们来说在很长一段时间里都处于高优先级,比其它那些算账、洗衣服什么的优先级都高。

这个例子阐述了阻塞和非阻塞两种情况。我们去洗衣机的时候,CPU (家长)就会处于忙碌状态,就没法再同时忙别的事儿了(看孩子)。

这些活也用不了多少时间,也没什么问题。我们启动洗衣机和烘干机之后,就可以回去忙其它事情了,这时洗衣服这个任务就是异步的,因为我们可以不用管它,去做其它事儿,等到它们完成了设定的任务就会用蜂鸣器通知我们。

作为一个正常人,我们天生就是这样,自然而然的就会同时兼顾好多事情。对于一个程序员来说,怎么才能把这种行为模式转化为相应的代码才是我们要说的重点。

下面我先介绍一种大家比较熟悉的代码思路:

脑洞1:“批处理”家长模式

想象一下如果是同步模式,要怎么来完成这些任务。作为一名称职的家长,在这种情况下肯定首先要看好孩子,除非其它事情主动来找我,否则就一直看着孩子。显然,结账和洗衣服什么的(肯定不会主动来找我们)就都不用干了。

我们也可以调整任务的优先级,但是在同步模式下,一次还是只能干一件事儿,然后一个接一个干。这就跟我们上面提到的同步模式的网络服务器差不多,不是说不能这么干,就是没什么好下场,谁干谁知道。

除非孩子们睡下,不然啥也别想干,等他们都睡了,黄花菜都凉了。要是真敢这么干,要不了几个星期,家长都得疯。

脑洞2:“轮询”家长模式

我们换个思路,改为轮询模式。家长定期中断当前任务,查看一下有没有其它活还要干。

开启这种模式后,我们可以设置每隔 15 分钟中断一下。然后就每隔 15 分钟去看看洗衣机、烘干机或者孩子们哪些需要自己来处理,如果有,就去把那件事儿做掉,如果都相安无事,就回去接着结算账单,等着下一次轮询间隔。

这个方案也不是不行,任务肯定能完成,但是会导致一些问题。一方面,有些任务尽管在一段时间内肯定完不成,仍要占用 CPU (家长)很多不必要的时间,比如洗衣机和烘干机。另一方面,这些任务也有可能在轮询间隔内完成,然而不能被及时发现和处理,除非等到下一个轮询周期。一些高优先级的任务,比如看孩子,恐怕会因为这么长的周期而导致一些严重的事故。

我们当然可以通过缩短轮询间隔来解决这个问题,不过 CPU 就会耗费更多的时间来切换任务,从而降低性能。还是那句老话,要是真敢这么干,要不了几个星期,家长又得疯一回。

脑洞3:“线程”家长模式

家长都有个口头禅,”恨不得一个人掰成两半用“。我们可以用线程在代码里掰出一大堆家长来。

如果我们把所有的任务视为一个整体,就可以把任务细化分配到多个线程里,然后给每个任务线程复制一个家长。这样每个任务都有一个家长去做,看孩子、看烘干机、看洗衣机、做结算,每个任务都独立完成。听起来很不错哦。

事实真的如此美妙吗?由于我们要明白的告诉每个家长(CPU)要做的工作,这里面很容易出问题,因为所有的家长在完成任务的过程中会共享所有资源。

比如说,看着烘干机的家长 A 发现衣服干了,就要把衣服取出来。假如在家长 A 正在取衣服的过程中,另一个看着洗衣机的家长 B 刚好发现洗衣机也洗完了,就打算使用烘干机,这样才能把衣服从洗衣机里取出来放到烘干机去烘。然后家长 A 取完衣服之后,也打算使用洗衣机,把衣服从洗衣机取出来放到烘干机去烘。

这时,两个家长就陷入了僵局(所谓死锁)。

两个人都控制着自己的资源,同时请求控制对方的资源,并且都妄想对方先释放资源给自己(之后才能释放自己的资源)。这就是使用线程时程序员们必须要解决的问题。

还有一个问题也是使用线程时可能遇到的。假设出现了意外,一个孩子受伤了,照看他的家长需要带他去急诊,他处理的很及时,毕竟这个家长是专门负责照看孩子的。但是到了医院该家长要填一张大面额的支票来付医疗费。

但是,家里那个负责结算账目的家长并不知道这张支票的事,于是家里的账户就透支了。因为所有的家长实例都运行在同一个程序当中,家里的钱(账本)是大家共享的,我们必须让照看孩子的家长及时通知管账的家长。或者提出一个锁定机制,确保同一种资源同一个时间只能被一个家长使用和改动。

这些问题在线程模式下也不是不能解决,只不过比较复杂,最头疼的是,出了错也不易察觉。

Python 实现

下面我们就要开始将上面这些脑洞方案用 Python 来实现。

你可以在 Github 代码库 下载全部示例。

所有示例在 Python 3.6.1 下测试通过,示例代码依赖列表 包含了全部依赖文件。

强烈建议开启 Python 虚拟环境 运行示例,以免受系统 Python 环境干扰。

例1:同步编程

第1个例子我们设计了一个任务,它要从指定队列里依次领受并完成一项工作。在这个例子里工作也很简单,就是从队列里顺次读取一个数字,然后开始数数,数到这个数就停。同时,每数一次就打印一句话,数完之后打印总共数了几个数。这个程序提供了一个简单的多任务处理队列数据的范例。


程序里的任务其实就是一个函数,它接收两个参数,一个字符串,一个队列。执行的时候先检查队列里有没有要处理的工作(一个数字),如果有,就把它从队列里取出来,然后开始数数,数到头了就显示出来。不断重复这个过程,一直到把列表里的工作全部做完,然后退出程序。

程序运行之后我们得到一个详细的状态列表,从中可以看到所有工作都是 Task One 完成的。等它忙完了,Task Two 才得以运行,但是到那时列表里的工作也没了,所以 Task Two 无事可做,只好打印一个状态声明就直接退出了。在这段代码里没有设置任何机制来帮助这两个任务和谐地切换和共存。
例2:简单的合作并发编程

这个例子是上例的升级版,加入了 Python 的生成器,这样就可以让两个任务进行。在任务函数执行部分加入了 yield 关键字,这意味着循环执行到这一句就会退出,同时保存上下文环境,等到需要的时候再继续运行。下面代码中 # run the tasks 的地方就利用了这个特性,使用 t.next() 方法来唤醒之前的任务,让它在刚才 yield 的地方继续。

这是合作并发的一种方式。程序交出当前环境的控制权,让其它任务得以执行。在本例中,这么做可以让主程序中同时运行两个任务,并且共享同一个队列数据资源。虽然是个不错的方案,但是想得到和上面例子一样的运行结果,还存在很多要改进的地方。

程序运行信息显示两个任务确实同时在执行,都从队列中获取了数据并处理。这算是我们想要的效果,两个任务都工作正常,各在队列里处理了两个数据。但是我再强调一遍,要实现这个结果还有很多工作要做。

这个例子实现的技巧是使用了 yield 语句将任务函数转换为生成器,以便切换的时候保存上下文。也正是利用这一点才实现了两个不同的任务实例之间的切换。

例3:协作并发与阻塞调用编程

再次升级程序,在任务循环的主体部分加入 time.sleep(1) 这个函数,其它部分跟上例一样。这个函数设置了每次循环中有 1 秒的延迟,以此来模拟日常任务中速度较慢的 IO 操作的效果。

除此之外我还使用了一个计时器工具,用来在打印运行日志的时候显示开始和消耗的时间。

从程序运行状态来看,跟之前一样,它也能让两个任务同时进行,从队列里获取并处理数据。但是,由于模拟了读写操作造成的延迟效果,我们可以看到,使用协作并发的方式并没有给我们带来什么好处,每一处延迟都拖慢了整个程序执行的进度,高速的 CPU 只能闲在一边默默的看它龟爬。

例4:协作并发与非阻塞调用编程(协程)
下面我们给程序做一个大升级。首先在程序中引入一个叫做 协程异步 的库。接下来引用它提供的一个叫做 monkey 的模块。

该模块里有一个 patch_all() 方法。这个东西到底是干什么用的呢?简单来说,有了它就能把其它各种库里采用阻塞模式(同步模式)运行的代码统统修补(patch_all 就是统统修补的意思)为异步模式。

说的太简单了,有的人可能不太理解。对我们的示例程序来说,就是我们模拟的读写操作本来会导致整个程序暂停来等待它执行完毕的,现在有了这个 patch_all() 就不用等了,它能让系统继续运行。请注意,上例中的 yield 语句现在已经不写了。

再来看,如果 time.sleep(1) 这个函数在协程的作用下不再占用系统控制权,那么控制权现在交给谁了呢?我们使用协程的时候,它会自动开启一个事件循环的线程。对我们来说就跟之前例3中使用多任务的循环差不多。等到延迟的部分执行完毕后,就接着执行延迟下面的语句。这么做的好处就是 CPU 在延迟的过程中被释放出来可以去做其它事情,不再需要陪着它干等了。

我们之前写的多任务循环也可以删掉了,任务列表加入两个 gevent.spawn() 函数。它们负责启动两个协程线程(我们称为 greenlets),这是一种轻量级的微线程,可以用协程的方式来切换上下文,不需要像普通线程那样由系统来切换。

接下来,在所有任务都启动之后我们调用了 gevent.joinall(tasks) 方法,这么做是为了让程序等待两个任务全部完成。如果不写这句,程序就会一直往下执行打印语句,然后就结束了。

程序运行状态表明,两个任务同时启动了,然后同时等待我们模拟的读写延迟。这证实了我们使用的 time.sleep(1) 函数没有导致整个程序中断,其它任务的运行没有受到影响。

程序结尾的总耗时基本是上例总耗时的一半。这就是异步带来的优势。

有了协程的 greenlets 方法和上下文切换控制,我们就可以采用非阻塞的方式并发运行两个甚至多个读写操作,即使任务之间的转换比较复杂也能得心应手。

英文原文:https://dbader.org/blog/understanding-asynchronous-programming-in-python#. 
译者:WDatou

例5:同步(阻塞)模式的 HTTP 下载

这次升级程序我们要旧瓶装新酒,一方面加入真正的读写任务,按照指定网址列表发起 HTTP 请求并获取页面内容,另一方面停止异步模式,退回之前的同步阻塞模式。

这里要引用 requests 库 来实现真正的 HTTP 请求,同时将之前的数字列表替换成网址列表。新的任务就不再是数数了,而是要把队列里的网址内容加载进来,然后显示耗时。

这里还是采用之前的 yield方法将任务函数变成生成器,保存上下文以便切换任务。

每个任务依次从队列中读取网址,然后访问该网址,最后显示耗时。

上面的例子中使用 yield 的时候是可以让多任务一起执行的,但是这次不一样,每个网络请求在获得页面返回内容之前 CPU 都是阻塞状态。请留意最下面的总耗时,在下个例子里我们会再提到。

例6:协程异步(非阻塞)模式的 HTTP 下载

这次我们把协程的方案也放进来。还记得我刚才说过,使用协程库里的 monkey.patch_all() 方法可以把任何同步代码转换为异步,当然也包括这个任务里用到的 requests 库。

现在 requests.get(url) 函数可以通过协程的事件循环来切换上下文,转换成非阻塞模式了,也就不用写 yield 了。在任务执行部分,我们使用协程来启动两个任务,最后用 joinall() 方法等待它们执行完毕。

仔细看看结尾的总耗时和每个网址分别耗时,显尔易见,总耗时小于每个网址的单独耗时之和。

因为每个请求都是异步的,这就帮助我们有效的利用了 CPU 资源来同时处理多个请求。

例7:基于 Twisted 的异步(非阻塞) HTTP 下载

下面这个例子我们改用 Twisted 框架 来实现刚才协程模块的任务,用非阻塞模式下载网页内容。

Twisted 非常强大,它采用完全不同的方法来创建异步程序。协程用修改模块的方法将同步转换为异步,Twisted 则提供了自己的一套函数和方法来实现同样的效果。

例6 中是用修补 requests.get(url) 的方式获取页面,现在我们改用 Twisted 提供的 getPage(url) 方法。

在下面的代码中,装饰器 @defer.inlineCallbacks 和 yield getPage(url) 一起使用,是为了将上下文切换到 Twisted 的事件循环当中。

在协程中事件循环是隐式的,而在 Twisted 中是通过程序结尾的 reactor.run() 语句来显式调用的。

这个运行结果和前面用协程方法得到的一样,总耗时小于每个网址访问单独耗时的总和。

例8:基于 Twisted 回调方法的异步(非阻塞)HTTP 下载

这个例子我仍然使用 Twisted 这个库,只不过换了一个比较传统的方式。

比如说,上面我们采用了 @defer.inlineCallbacks / yield的编码形式,现在我们要改成显式回调。所谓回调函数就是一个传递给系统的用来响应某个事件函数。下面例子中的 success_callback() 函数就是传递给 Twisted 用来响应 getPage(url) 事件的回调函数。

注意,这个例子里的 my_task() 任务函数已经不再需要 @defer.inlineCallbacks 装饰器了。另外,这个函数还生成了一个延迟变量,我们简称它为 d ,它是 getPage(url) 函数的返回值。

延迟变量是 Twisted 处理异步编程的方式,也是回调函数所必需的。一旦延迟被“触发”(也就是getPage(url) 完成时),就立刻调用回调函数,并传入指定参数。

程序运行结果和上面两个示例一样,总耗时小于每个访问分别耗时之和。

你想用协程还是Twisted的方式来实现都可以,因人而异。两种方案都提供了强大的功能帮助开发者来创建异步代码。

结论

希望以上内容能帮助你了解掌握异步编程的应用场景和工作方式。如果你要计算 Pi 的小数点后 100 万位,那恐怕异步起不了什么作用。

然而,如果你要实现一个服务器,或者其它一些需要大量读写操作的代码,那你肯定能感受到什么叫质的飞跃。这项技术非常强大,一定能让你的程序性能上一个新台阶。

上一篇:学python语言以后能干嘛?
下一篇:Python和C/C++交互有几种方法?

想学Python有没有必要报培训班?

Python这么简单还用参加python培训班学吗?

零基础学Python编程开发难度大吗?从哪学起?

python培训学费多少钱?学python课程价格?

选择城市和中心
贵州省

广西省

海南省