事件驱动与协程概念

徐平

xiyangyang

在一个完美的世界中,不存在战争和饥饿,所有的API都将使用异步方式编写,兔兔和小羊羔将会在阳光明媚的绿色草地上手牵手地跳舞

从IO讲起

应用独占式

在计算机发展的初期,每个应用都是独占式的,没有OS进行调度,每次只加载一个进程,学过单片机的朋友应该有过这样的体验,例如常用的8086系列芯片,我当时学习微机原理课程是使用仿真软件Proteus,写出汇编,编译成二级制文件,load的仿真软件上,就可以运行。通常我们写的汇编程序会控制一些基本外设,例如键盘,灯,蜂鸣器,定时器之类的,其中比较关键的就是中断,当外设被触发,会向CPU发出一个中断信号,CPU的中断处理机制,会保护好发起中断的现场,然后去执行中断处理函数的地址,处理完以后,会回到刚刚保存的现场。

由于单片机的只是我已经忘得差不多了,就拿一个完整的计算机结构图来说一说。假设是最原始的计算机,没有OS或者说我们的程序就是一个简单的OS,程序完成的工作是:

1.用户输入hello
2.从磁盘中读取world
3.组合成 hello world并写入磁盘

1和2这个两个任务其实是没有先后顺序的,但是在一个进程的世界里,必须要有先后顺序,并不能并发执行,于是整个执行流程就是这样:CPU进入中断,程序等待用户输入,用户输入之后,CPU中断返回,将IO总线上拿到的hello的字节写入主存的某个地址,接着发送指令读取磁盘的world的地址并等待字节返回,然后写入主存的hello的前一个地址,然后发送指令将hello world对应地址的内容写入磁盘。
exclusive-application-say-hello

OS协调式-为了并发

由于硬件发展,只有一个程序在CPU上跑有些浪费,于是OS作为一个大管家来协调大家的资源需求,于是抽象了进程的概念,当多个进程并发在一个CPU上跑时,一个被IO阻塞,OS可以让CPU执行别的进程。 process-for-server

后来由于进程切换比较消耗CPU,并且也不能资源共享,于是抽象出线程,线程的CPU使用也是由OS协调,OS通过时间片的方法进行强占式CPU资源分配,程序的编写者不用关注什么时候让出资源,什么时候执行代码,全都由OS管理,这时看起来已经很完美了,世界一片明亮。 thread-for-server

高并发下的挑战

有了线程之后,我们处理并发最直观的做法就是加线程,为了减少线程的启动时间,我们开始使用线程池,预先启动一些线程。随着并发进一步提高,加上外部请求基本上都是IO密集型,使用线程带来的效益开始下降,也就是说在线程的生命周期中IO等待时间远远大于CPU计算时间,另外每个线程大约需要4M的内存,由于内存的限制,单机线程数不会很多。所以初期的Apache、tomcat服务器通常只能处理几千的并发。为了突破单机下的并发问题,以nginx为首的一种叫事件驱动的方案开始流行。

为了代码好看、好写

事件驱动其实充分利用了线程,对于有阻塞的操作,就扔过去一个回调,主流程继续执行,当阻塞的流程执行完成就会调用回调函数。这种异步的方法与之前的同步写法不一样,例如一件事需要1,2,3,4这样的顺序执行,假如这四个步骤都是阻塞的话,就需要三层回调,要是步骤再多一点就会产生回调地域,代码可读性很差,还容易写错。于是一些语言就出了第三方库就出来帮忙,声明出叫做协程的概念,可以用同步的方式写异步,例如c++的libgo,java的Quasar,还有一些新颖的语言,直接将这个特性加入官方库,例如go、python3、kotlin、java11

事件驱动

事件驱动的最初应用是在UI编程上,其中很重要的一点就是需要感知一些外设的操作,从本质上还是IO,我们的一次鼠标点击,键盘敲击,触摸屏的滑动都是一个事件,会放在OS的队列中,最初的做法就是专门有个线程去轮序各个队列看看有没有相关事件,但是这样比较浪费CPU资源,于是OS说你应用不要来不断问我了,你先来告诉我你关注哪些事件类型,等事件发生了我告诉你得了。于是应用的UI线程开开心心等着事件通知,不用再跑着去问OS了。之后这种模型在后端也发扬广大,下面我么来举两个栗子。

UI方面以Android为例,在应用启动时会有创建一个UI主线程,在主线程中会调用Looper.loop方法,该方法是一个死循环,用来更新UI,但是不会卡死,内部使用了linux的epoll机制。Android应用程序的主线程在进入消息循环过程前,会在内部创建一个Linux管道(Pipe),这个管道的作用是使得Android应用程序主线程在消息队列为空时可以进入空闲等待状态,并且使得当应用程序的消息队列有消息需要处理时唤醒应用程序的主线程。在线程没有消息处理时,虽然有死循环,但是通过linux I/O阻塞机制让程处于空闲状态,有能力去执行其他操作,所以不会因为looper死循环导致线程卡死,当然主线程的UI也不会卡顿。

android-ui-thread-model

后端方面以Netty为例, 有一个主线程对应bossEventLoopGroup中的唯一的一个EventLoop,其中也是一个循环,通过NIO的方式(在Linux上底层依然是使用Epoll)或者Epoll的方式,调用操作系统的阻塞方法等待事件到来,然后将事件放入WorkEventLoopGroup的队列中,等待EventLoop来执行。

netty-thread-model

netty这个结构可能比较复杂,还是以处理网络连接为例,下图更简单的描述了事件驱动,用一个线程处理所有的连接,这个线程通常是一个循环的方法,当处理一个连接遇到阻塞的操作就将任务丢给其它的线程,主线程接着处理下一个连接,有没有感觉和Android的UI模型出奇的相似。
event-drive-for-server 对比上面的两个例子,UI主线程相当于netty中的那个bossEventLoop,同样适用epoll机制,通过系统调用的阻塞等待事件的到来,之后将事件分发出去,让相应的handler处理。

看了上面的例子,觉得世界应该很美好了,但是不一定,虽然我们接受到消息之后将业务逻辑放入Work Thread Pool进行处理,看似可以同时处理很多请求,但是如果业务处理中也会进行其它的IO操作的话对于整个应用的并发来说是没有什么帮助的,因为每个请求要执行比较长的时间,其中大部分时间用于,读写磁盘、等待数据库,其它接口等IO操作的返回,为了同时处理更多的请求,我们只好加线程,这又回到了最初的问题:线程的使用是比较昂贵的。

最好的办法就是消除阻塞IO,也就将空等的操作全部去除,也就是从底层库一直到业务代码全部改造为异步,但是这对开发者提出了更高的要求,异步的代码比同步的代码难写还难理解,所以这并不是理想的解决方案。

协程

前面说到事件驱动虽然可以通过异步的方式提升效率,但是对开发者的要求也高了,代码逻辑也不清楚了,那么有没有同步的方式来写非阻塞的代码呢,当然有,那就是协程。

协程是从本质上讲是一种非抢占式的用户态线程,结合文章开头说的独占式应用,其实有一定的相似性,那就是都是协作式(非抢占)的,如果把独占式应用看做是单个线程执行,三个步骤看做是三个函数,那就和同步逻辑一样,与其不同的,协程通过非抢占式的调度来达到并发,简单的说当一个函数遇到阻塞,会让出CPU,主动跳到别的没到阻塞状态的函数去执行,以次来达到并发的目的,当然如果所有的函数都是纯计算(非IO)的,那么协程并没有什么用处,因为没有CPU时钟被浪费。

协程与线程和进程有什么区别呢,线程和进程是操作系统通过强占式的调度,强硬把正在使用CPU的线程或进程踢走,让给别的线程或进程,由于切换的速度比较快,从而达到感觉是并发执行的效果。而协程通常是通过一个线程去运行所有协程方法,每个协程让不让出线程资源自己说了算。

如下面的伪代码所示,从main方法进入,执行coroutine1,当满足i<10这个条件之后,便让出CPU,保存此时的上下文信息,进而去执行coroutine2,同样的coroutine2满足i%2==1也让出CPU并保存上下文,因为这个程序只有两个协程,于是又跳回到coroutine1接着之前的上下文继续执行。 coroutine-pseudo

于是对于处理连接这样的事情就变成下图这样,每个协程处理一个连接,当阻塞的时候就yield,让出CPU去执行别的协程,并且由于上下文切换过程在用户态执行,花费比较小,于是性能就得到了提升。

coroutine-for-server

协程看起来如此美好,那我们快用上协程呀。且慢,你现在项目用的什么语言,GO?恭喜你,放心的用,GO的整个体系中所有的IO底层库全部是协程,仅仅一个go关键字你就能体会同步编写异步并发的代码,体会协程在IO方面的强大。但是如果别的语言,还是小心为好,因为用要配合异步IO库来使用,如果用成同步的库,完了,你的程序就要hang住了。

两者对比

其实对于事件驱动和协程的对比还是比较好说的

共性

  • 其目的都是为了消除IO阻塞的问题
  • 都是使用单个或少量的线程,减少线程切换带来的性能消耗

不同点

  • 事件驱动较之比较初级,需要用异步回调的方式来写代码
  • 协程可以使用同步的方式写代码,通过库或者语言的调度来实现并发

参考内容