理解事件驱动与协程
2023-08-14 10:03:12 阿炯

事件驱动协程是两种目前业界主流的编程设计方式,是在不同时期、不同背景下的改良设计,本文总结了互联网上的相关资料,对其在使用上做了一定的对比分析。

应用独占式

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

通常在开发设计服务器处理模型的程序时,有以下几种模型:
1.每收到一个请求,创建一个新的进程,来处理该请求;
2.每收到一个请求,创建一个新的线程,来处理该请求;
3.每收到一个请求,放入一个事件列表,让主进程通过非阻塞I/O方式来处理请求。

上面的3种方式,各有千秋,
第1种方法,由于创建新的进程的开销比较大,所以会导致服务器性能比较差,但实现比较简单。
第2种方式,由于要涉及到线程的同步,有可能会面临死锁等问题。
第3种方式,在写应用程序代码时,逻辑比前面两种都复杂。

综合考虑各方面因素,一般普遍认为第3种方式是大多数网络服务器采用的方式。

用一个计算机结构图来说一说。假设是最原始的计算机,没有OS或者说我们的程序就是一个简单的OS,程序完成的工作是:
1.用户输入hello
2.从磁盘中读取world
3.组合成 hello world并写入磁盘

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


操作系统(OS)协调式-为了并发

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


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


高并发下的挑战

有了线程之后,处理并发最直观的做法就是加线程,为了减少线程的启动时间,开始使用线程池,预先启动一些线程。随着并发进一步提高,加上外部请求基本上都是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编程中,常常要对鼠标点击进行相应,首先如何获得鼠标点击呢?
创建一个线程,该线程一直循环检测是否有鼠标点击,那么这个方式有以下几个缺点:
1.CPU资源浪费,可能鼠标点击的频率非常小,但是扫描线程还是会一直循环检测,这会造成很多的CPU资源浪费;如果扫描鼠标点击的接口是阻塞的呢?
2.如果是堵塞的,又会出现下面这样的问题,如果我们不但要扫描鼠标点击,还要扫描键盘是否按下,由于扫描鼠标时被堵塞了,那么可能永远不会去扫描键盘;
3.如果一个循环需要扫描的设备非常多,这又会引来响应时间的问题。

所以该方式是非常不好的。

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


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


netty这个结构可能比较复杂,还是以处理网络连接为例,下图更简单的描述了事件驱动,用一个线程处理所有的连接,这个线程通常是一个循环的方法,当处理一个连接遇到阻塞的操作就将任务丢给其它的线程,主线程接着处理下一个连接,有没有感觉和Android的UI模型出奇的相似。


对比上面的两个例子,UI主线程相当于netty中的那个bossEventLoop,同样适用epoll机制,通过系统调用的阻塞等待事件的到来,之后将事件分发出去,让相应的handler处理。

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

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

目前大部分的UI编程都是事件驱动模型,如很多UI平台都会提供onClick()事件,这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:
1.有一个事件(消息)队列;
2.鼠标按下时,往这个队列中增加一个点击事件(消息);
3.有个循环,不断从队列取出事件,根据不同的事件,调用不同的函数,如onClick()、onKeyDown()等;
4.事件(消息)一般都各自保存各自的处理函数指针,这样每个消息都有独立的处理函数。

事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。另外两种常见的编程范式是(单线程)同步以及多线程编程。


用例子来比较和对比一下单线程、多线程以及事件驱动编程模型。下图展示了随着时间的推移,这三种模式下程序所做的工作。这个程序有3个任务需要完成,每个任务都在等待I/O操作时阻塞自身。阻塞在I/O操作上所花费的时间已经用灰色框标示出来了。


在单线程同步模型中,任务按照顺序执行。如果某个任务因为I/O而阻塞,其他所有的任务都必须等待,直到它完成之后它们才能依次执行。这种明确的执行顺序和串行化处理的行为是很容易推断得出的。如果任务之间并没有互相依赖的关系,但仍然需要互相等待的话这就使得程序不必要的降低了运行速度。

在多线程版本中,这3个任务分别在独立的线程中执行。这些线程由操作系统来管理,在多处理器系统上可以并行处理,或者在单处理器系统上交错执行。这使得当某个线程阻塞在某个资源的同时其他线程得以继续执行。与完成类似功能的同步程序相比,这种方式更有效率,但程序员必须写代码来保护共享资源,防止其被多个线程同时访问。多线程程序更加难以推断,因为这类程序不得不通过线程同步机制如锁、可重入函数、线程局部存储或者其他机制来处理线程安全问题,如果实现不当就会导致出现微妙且令人苦恼的bug。

在事件驱动版本的程序中,3个任务交错执行,但仍然在一个单独的线程控制中。当处理I/O或者其他昂贵的操作时,注册一个回调到事件循环中,然后当I/O操作完成时继续执行。回调描述了该如何处理某个事件。事件循环轮询所有的事件,当事件到来时将它们分配给等待处理事件的回调函数。这种方式让程序尽可能的得以执行而不需要用到额外的线程。事件驱动型程序比多线程程序更容易推断出行为,因为程序员不需要关心线程安全问题。

当面对如下的环境时,事件驱动模型通常是一个好的选择:
1.程序中有许多任务,而且…
2.任务之间高度独立(因此它们不需要互相通信,或者等待彼此)而且…
3.在等待事件到来时,某些任务会阻塞。

当应用程序需要在任务间共享可变的数据时,这也是一个不错的选择,因为这里不需要采用同步处理。网络应用程序通常都有上述这些特点,这使得它们能够很好的契合事件驱动编程模型。


协程

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

协程,又称微线程,纤程;英文名Coroutine。是一种用户态的轻量级线程。

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

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

协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此:协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。线程和进程的操作是由程序触发系统接口,最后的执行者是系统;协程的操作执行者则是用户自身程序。

简单定义:
寄存在线程中,单线程下可以实现多并发效果;
修改共享数据不需加锁;
用户程序里自己保存多个控制流的上下文栈;
一个协程遇到IO操作自动切换到其它协程。

优点:
无需线程上下文切换的开销;
无需原子操作锁定及同步的开销:"原子操作(atomic operation)是不需要synchronized",所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序是不可以被打乱,或者切割掉只执行部分。视作整体是原子性的核心;
方便切换控制流,简化编程模型;
高并发+高扩展性+低成本:一个CPU支持上万的协程都不是问题。所以很适合用于高并发处理。

缺点:
无法利用多核资源:协程的本质是个单线程,它不能同时将单个CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上。当然日常所编写的绝大部分应用都没有这个必要,除非是cpu密集型应用;
进行阻塞(Blocking)操作(如IO时)会阻塞掉整个程序。

协程的适用场景:当程序中存在大量不需要CPU的操作时(也就是平时所说的IO密集型程序),就适用于协程。

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


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


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

两者对比

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

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

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

其实很多技术不是几句话就能说清楚的,开发人员在掌握基础后需要进行大量的实践才能比较出色的掌握。