事件循环和协程:从生成器到协程

1,453 阅读10分钟
原文链接: zhuanlan.zhihu.com

打上一篇「事件驱动与协程:基本概念介绍」已经过去了一周时间,今天我们就来讲一讲真正的协程。

上一节我讲到,协程的出现其实为的就是简化异步IO流程,使得异步IO变得跟同步一样。那什么又是异步IO呢?

一)异步IO

异步IO最形象的一个比喻就是打电话。

今天早上起来,你想打电话去喷一喷知乎的人,说编辑器太辣鸡了请改进。于是乎,你掏出手机,打电话到知乎总机去,然后知乎总机的服务台小姐说:先生请稍等,我找我们领导来给你骂,你千万别走开

此时,你能干嘛呢,大概就是坐在凳子上,左手拿着你的iphoneX,右手扣着你的脚趾。这就是所谓的同步IO

异步IO呢?我们只需要变化一下。知乎的服务台小姐在你打电话过来的时候她说:先生等我去找我领导过来给你骂,你可以先挂电话,等我找到他了我叫他打给你。这时候你就爽了,你挂了电话,可以去刷牙洗脸,干很多事情,不用再蛋疼的抠脚等着。

现在我们把比喻归回编程。当我们进行任何IO操作的时候,我们的CPU都得傻等,这无疑是一件浪费时间的事情。于是乎人们就想出了多线程这个玩意儿,让一个线程IO,一个线程负责处理别的事情。但是问题就来了,一两条线程还好,当线程数成千上万的时候,你的机器也就嗝屁了。

又于是,人们干脆就算了,把所有的IO都变成非阻塞的,所有的结果都通过回调来获得。这更菊紧,这种菊紧并不是说依靠封装之类就能解决的。就连牛x的如Nodejs作者都因此放弃了nodejs,临走前喷了nodejs一发:回调玩死我啦!

二)生成器

无论是JS还是python,做协程的都是基于生成器(es6和python3.6基本是同一个语言...

生成器提供了一种「中断机制」,使得子程序可以暂时返回,等在之后的某个时刻,继续回来运行。

是不是感觉很像,回调?不多说,我们直接上代码看一看什么是生成器,什么又是子程序。

2.1 最简单的生成器以及调用

首先我们先写一个生成器函数:

在任意一个函数中,把yield这个关键字写在函数里面,这个函数就成了一个生成器。yield关键字你别害怕看到它,它的作用就是我刚刚说的「暂时返回」,其实作用约等于return。

我们轻松的写一个main函数,传进来的参数是一个函数,这个函数比较特殊,必须是一个生成器。看图你就知道,生成器的运行方式就是给它来一个send(None)调用。

我是子程序

得到的结果就是这样.

2.2 观察yield是否是暂时返回

要观察yield是否是暂时返回,非常简单

我们在yield后面添加一行话,然后在main之中,又加入一个c.send(None)。按照正常来说,我们调用一个函数,他会从头到尾重新执行一次,但是有了yield之后,我们每次调用生成器,他就会从上次返回的地方再次运行

我是子程序
我在yield后面运行
Traceback (most recent call last):
  File "coroutine.py", line 14, in <module>
    main(c)
  File "coroutine.py", line 11, in main
    c.send(None)
StopIteration

我操,竟然报错了...

2.3 生成器迭代结束,抛出错误.

不要害怕生成器抛出错误,这其实只是生成器运行完毕,没有别的内容可以运行的一个信号而已,这是一件极好的事情,有了这个以后我们的主程序就可以知道我们的子程序是何时结束的。

为此,我们写一个暴力得不行的while 1 循环去狂搞子程序,直到他完毕为止。

三)生成器配合事件循环(io多路复用)构建协程

3.1 线程切换

在讨论如何协程是如何工作之前,我们还是得来讨论系统级别的多线程。

这里必须有一个概念,就是一个cpu核心状态下,多线程技术其实是假的。假的是什么意思?就是根本没有多线程这种玩意儿,那系统是怎么实现两段代码同时运行的呢?

答案就是:骗你

举一个很简单的例子,小明推两个箱子,因为他只有一个人,所以他先推左边的箱子,然后再推右边的箱子。在平常人看来,就是左边的箱子先移动,右边的箱子后移动。这时候,小明获得了闪电侠⚡️的能力,他左右来回移动接近光速,那你看到的两个箱子就是相当于同时向前移动了

单核情况下CPU,系统内核就是这么干的,同过快速的切换所谓的线程,来获得「假同步」,因此很多时候我们都说多线程的代码运行结果「是不可预计的

讲这个的原因其实就是为了和协程做比较。协程是用户(我们)在用户态来进行线程切换,而线程是由系统自己在内核里「疯狂切换」。

上述代码中,thread1里有一个超级慢的操作,而thread2都是正常的快操作。系统当然就是:

读一下thread1然后又切换到thread2又读一下。线程其实很简单,因为我们的线程都在一个进程内,用的都是同一个地址空间,所以只要把寄存器刷新一遍就行,因此消耗要比进程切换小很多,但是仍然有消耗。

3.2协程

先前说到,协程是用户(我们)在用户态来进行逻辑流切换。这样连时钟阻塞、 线程切换这些功能我们都不需要了,自己在进程里面写一个逻辑流调度的东西。那么我们即可以利用到并发优势,又可以避免反复系统调用,还有进程切换造成的开销,那我们就飞起来了。

3.3 用事件驱动和协程改善网络IO

事件驱动有一个最大的问题就是回调,每次IO你都必须进行回调,调啊调啊调,把Nodejs的作者都调走了。

3.3.1 原生事件驱动的蛋疼

现在就来感受一下:

我们先用python自带的selector(linux下用的是epoll)来写一个非阻塞的网络请求。一般情况下connect函数会阻塞,我们使用setblocking以后,就不会再阻塞了。

然后我们将事件,注册进selector中,事件名字是Event_write,回调函数是write。

这里的意思就是,当sock连接完毕,可以进行「写」操作的时候,就会去调用write函数。

此时,我们在另外一个函数中,使用事件循环去驱动它,调用selector.select()函数,就会进行阻塞,如果有东西返回,那就直接进入到逻辑层里面去了。


我们看到,我们的逻辑流被打断了,分成了两个部分。第一个部分就是我们socket去连接,连接不知道什么时候完成,完成的时候,然后思路就跳到了write之中。这不仅影响我们阅读,如果我们的write函数,需要依赖之前的某个变量(比如连接后的sock),那么我们还得进行变量的传递。

当我们的socket写完以后,等对方发送的时候,又要再次进行非阻塞处理,添加回调函数,总之麻烦死了.....

3.3.2 使用协程

本章节中我们使用生成器,即yield来完成我们的协程工作。如果你不记得了,请回到头部去复习一下下,再来看

首先,我们规定一个类叫做future(写完了我才发现我拼错了...),这个future类,其实并不干嘛,只是用来使得我们的yield恢复运行的。在这里我写了一个hack,就是使用times来进行控制运行了多少次。不过没关系,这么写比较简单易懂。

resume函数就是这样的一个恢复函数。

接下来我们改造我们的fetch函数:

看!我们的代码,一路顺畅的走下来,毫无回调函数,分别完成了:等socket连接,写socket,读socket,三个阻塞操作

但是,逻辑是比较绕的。我简单说一下:f = future(),创建了一个future对象,future对象中保存一个一条协程,这条协程,就是在运行这个fetch()函数,通过事件循环机制,我们在sock可以读,可以写的时候,调用resume来恢复协程的运行!

那有同学就问了,这里并没有创建协程的代码呀?协程是如何保存在future中的?

全部的秘密就在Task函数之中,首先调用fetch函数,返回一个生成器,其实也就是协程啦。然后通过coro.send(None)函数调用,使得我们创建的coro,返回之前我们fetch的中的yield f(future对象)。

然后future对象再通过调用add_coro调用,把协程记录到future对象中去,自此,每一个协程中包含一个future对象,每一个future对象中又包含着这个协程。因此,我们就可以在协程之中任意的恢复跳出。

注意最后的耗时:0.56

最后,我们加入事件循环,这里有两个注意的点。

  1. 看代码的时候从main看起,main里面的for循环就是构建了我们之前规定的10条协程。
  2. 运行loop()的时候,注意观察callback函数,并没有传入任何变量!这个跟之前不同的就是我们的协程内部完全不需要做变量传递,因为协程内部就是「同步」的,没有任何「异步」代码,因此,我们无需传递参数给回调函数,这里的回调函数,只是用作恢复协程的运作而已!仅此而已!此而已!已....

3.3.3 使用纯事件驱动,多线程,多进程,以及同步方式

为了和协程做比较,我写了几个版本的「虐baidu」爬虫

多线程版本,10次请求,耗时0.79
多进程版本,10次请求耗时1.0231

当然了,最后还有一个蛋疼epoll版本

10次耗时,0.66秒

四)总结一下

第一点要说的就是epoll机制回调做法其实并不会比协程要慢,因为epoll机制同样使用的是单线程回调的方式。事实上,epoll内存消耗更低一些,协程还稍微偏高。但是现在内存条便宜到爆炸的情况下,那一丁点儿内存并不是什么瓶颈,如何把CPU跑满才是并发的关键!同时保持代码美观以及CPU发挥最大性能的关键就是使用协程。

第二点要说的就是,协程其实就是协作式线程,是以用户手工释放线程的使用权来进行调度的,你可以理解为,只要用户不释放使用权,那么协程绝对不会跑去执行其他的东西。这和抢占式的线程是不一样的,线程是有内核进行分配的。(就是左右来回暴力切换啦)

没了,这篇文章我是工作中抽忙慢慢写的,自己也从中学到了很多,尤其是手工不用asyncio搞协程那块。

如果大家想看代码,代码放在:「215566435/LuyWeb」这里,同时,luyWeb也是一款使用协程写的类flask框架,上次我也说过了,麻烦给点小星星!(昨天顺利加入了http流式返回的api....

至于文档么:「LuyA文档」

下一节,我们就把代码改造成py3.5版本的async/await版本,并且好好的学习一下asyncio这个库。

我们下周见...