Event Loop 事件循环

216 阅读9分钟

Event Loop

Event Loop(EL)是一个比较重要的概念,关系到是否理解 Node,是否可以写好 Node 代码。同时 EL 难以理解并产生了很多困惑或误解,网上流传着很多对 EL 理解的版本,这些文章大多数从技术的角度去分析。直接从技术实现去探讨 EL,可能容易陷入具体的技术细节,而忽略 EL 本身是什么。先从概念上解释 EL,再到具体如何实现 EL,这样可能会更通俗易懂。

从名字来看,Event Loop 由两个单词组成(复合名词),Event 和 Loop:

  • Event:事件,比如定时器到时、收到新的请求、文件可读
  • Event Source:事件源,产生事件的对象,比如定时器
  • Event Queue:事件队列,记录产生的事件
  • Loop:循环,反复地连续做某事(处理事件)

EL 字如其意,经过咬文嚼字,可以猜出 EL 的大概意思。EL 不断等待事件源产生事件,然后处理产生的事件。比如服务端收到新的请求(事件)事件就可以等待接收客户端的数据(处理)。

同一时间可能产生很多同类型事件,需要保存在 EQ 中按顺序处理。如果事件类型复杂多样,还需要把不同类型的事件分别保存在不同的 EQ 中,并根据事件类型的优先级采取不同的处理。比如在 Node EL 中 Next tick 队列的事件比其他 micro tasks 队列的事件优先级高。

Abstract

Event Loop 是一个抽象的概念,不是具体的。 对于计算机来说,EL 是一个程序结构/模型,Nginx EL、Redis EL 和 Node.js EL 是 EL 的实现,是具体的。 每种语言可以有自己的 EL 实现,甚至同一种语言中 EL 会有不同的实现,但是大多数的 EL 都是大同小异的。在计算机中 EL 往往使用一个线程实现。

JS EL Visualization

Philip Roberts 曾在 JSConf 2014 分享了《What the heck is the event loop anyway》,介绍了他对 EL 的理解,以及 EL 的可视化。虽然这个可视化比较简单,但是可以帮助更好的理解 EL。

Dinner

做饭是一件很平常的事,同时也需要花费时间和心思。一般做饭有这几件事:煮米饭、煲汤、炒菜。

假如分别用这三种方式去做饭:一个人一件事情一件事情按顺序做;多个人每个人做一件事件;一个人同时做多件事情。

One by One

一个人按部就班,做好一件事再做下一件事。煮米饭 -> 煲汤 -> 炒菜。

虽然可以专注做好一件事,但是耗费大量时间。

Collaboration

多个人同时一起做,每个人做一件事。A 煮米饭,B 煲汤,C 炒菜。

相比之前一个人做饭效率提高了,但是浪费了人力资源。(但是一起做饭有乐趣)

Busy

一个人同时一起做多件事,一边煮米饭一边煲汤一边炒菜。

比一个人做饭效率提高了,比多个人减少了人力,但是一个人的能力是有限的。

“Busy”模式的 Timeline 可能是这样:

time1:淘米
time2:入锅煮米饭
time3:汤锅水加热
time4:洗汤料
time5:入锅煲汤
time6:切菜
time7:汤锅开了,转文火
time8:继续切菜
time9:炒菜
time10:汤时间够了,关火
time11:继续炒菜
...

做饭的每件事都可以细分为多个更小的步骤(还可以进一步细分)

  • 煮饭:淘米 -> 入锅煮 -> 盛饭
  • 炒菜:洗菜 -> 切菜 -> 入锅炒 -> 加调料 -> 装盘
  • 煲汤:洗汤料 -> 入锅煲 -> 加调料 -> 文火 -> 盛汤

把大事情分解成多个小步骤,把每个步骤做好,最后组合起来煮饭就完成了。这个也是我们平时使用的方式,或者接近的方式。

假如一个人的速度快十倍、一百倍、一万倍 …… 这个效果就会更加的明显。

Dinner & Event Loop

每个步骤之间往往需要一定时间的等待,这个时候人是闲下来的,可以去做其它事情。比如把米放入电饭锅后,电饭锅会自动煮,煮熟后会有提示;把汤料入锅后,看着手表过一段时间再换温火。一顿饭就是一个人在这样一个个步骤之间切换完成的。

电饭锅的提示、手表到了某个时间(定时器)等都是事件,收到事件后就可以进行下一个步骤(做出相应的处理)。做饭就是在这样不断处理各种事件,这就是 Event Loop。

Server

HTTP 服务器是常见的服务器类型,往往需要同时处理大量的请求。 服务器对请求的处理一般分为三个阶段:1.Receive;2.Handle;3.Return。

假如分别使用这三种方式处理请求:

  • 一个线程处理完一个请求再处理下一个请求
  • 多个线程每个线程处理一个请求
  • 一个请求同时处理多个请求

Serialization

单线程串行,一个个请求顺序处理:处理请求1 -> 处理请求2 -> 处理请求 X。

简单,资源消耗少。但是资源利用率不高,效率低。如果阻塞会导致等待,并发能力低。

Parallellism

一个线程处理一个请求:A 线程处理请求 1,B 线程处理请求 2,N 线程处理请求 X。

复杂,并发性高,但是消耗大量资源,还有线程切换成本。这个是 Java Web 常用的模式。

Concurrency

一个线程同时处理多个请求:

Time1 处理请求 1
Time2 处理请求 2
Time3 处理请求1
Time4 处理请求 2
TimeN 处理请求 X

略复杂,并发性高,资源消耗低,效率高,但是一个线程的处理能力有限。通过 I/O Multiplexing 实现,比如 Select、Epoll 等。这个 Nginx 和 Node.js 用的模式。 “Concurrency” 模式的 timeline 可能是这样:

time1:建立请求1
time2:建立请求2
time3:等待请求1数据
time4:等待请求2数据
time5:读取请求1数据
time6:处理请求1
time7:读取请求2数据
time8:返回请求1
...

处理请求的三个阶段可以进一步细分为更多小的步骤

  • Receive:建立连接 -> 等待可读 -> 接收数据1 -> 接收数据 N (可能需要多次读取) -> 数据接收完成
  • Handle:可能进行很多的操作,读写数据库、文件、压缩等。每个又会继续分为更多的小步骤,比如读取数据库会有网络 I/O
  • Return:等待可写 -> 写入数据1 -> 写书数据 N (可能多次写入) -> 数据写入完成 -> 关闭连接

把请求分解成多个步骤,把每个步骤完成,最后组合起来,就完成了一个请求处理。

Server & Event Loop

每个步骤之间往往需要一定时间的等待,这个时间线程是闲下来的,可以去做其它事情。比如在等待读取请求数据的时候,可以去处理其它请求。CPU、内存、网卡等之间的速度差异是非常大的,等待的时间 CPU 可以做的事情是很明显的。

建立连接、数据接收完成等都是事件,收到事件后就会进行下一个步骤(做出相应的处理)。服务器就是在这样不断处理各种事件,这个就是 Event Loop。

Why Event Loop

每个人只有一个大脑、两只手、两条腿,我们每天只有 24 小时,所以如何高效的做事情尤为重要。生活或工作中我们往往可以同时应对多任务,我们可以轻松同时应对煮饭、炒菜、煲汤等事情。

对于 CPU 来说,CPU 的计算能力也是一定的,也就是每个时间段可以做的事情也是有限的,所以如何高效的利用 CPU 尤为重要。

无论是做饭还是服务器处理客户端请求,我们都在把每个任务拆解成很多小的步骤,每个步骤之间都有时间差,我们利用时间差可以同时做多个任务。所以 EL 本质上是个体高效处理多任务的一种方式。

CPU 时间片分给线程,目的是为了可以同时处理多任务。但是多线程带来了线程资源的消耗以及线程切换带来的时间片损耗。如果我们可以在一个线程同时处理多个任务,那么就可以避免这个问题,并且保持 CPU 的高效利用。I/O 多路复用让单线程可以同时处理多网络请求,同时 CPU 的速度远远大于网络速度,明显的时间差,所以使用 EL 模型处理多网络请求是一种比较高效的的方式。我们熟知的高性能网络应用 Nginx 和 Redis 都使用了 EL。

图片

Latency numbers every programmer should know

Node Event Loop

Node 为服务端开发设计,不止是网络功能,还需要支持定时器、文件管理、加解密等。所以 Node EL 面临的任务场景更加复杂,如果是定时器这样的非 CPU 密集任务,只要往事件队列加一个事件队列处理即可(时间到了就有事件触发)。如果是面对文件这样的阻塞操作,或是面对加解密这样的 CPU 密集型操作怎处理呢?

再次回到做饭,假如你正在做饭,没有酱油了,如果你去楼下便利店买需要耗时 10 分钟左右,这样可能你煮的菜就糊了。这个时候你可以喊正在写作业的小盆友帮你打酱油,你接着做饭,等酱油买回来你再做用到酱油的菜。买酱油其实有点像计算机耗时任务,所以对于阻塞和 CPU 密集型操作,我们可以在 EL 主线程开一个线程完成这些任务,EL 继续处理其他事件。等其他线程把耗时任务完成,通过事件通知 EL,EL 根据优先级处理这些事件,这样 EL 就可以应对阻塞和 CPU 密集型的耗时任务了。