彻底搞懂 nodejs 事件循环
nodejs 是单线程执行的,同时它又是基于事件驱动的非阻塞 IO 编程模型。这就使得我们不用等待异步操作结果返回,就可以继续往下执行代码。当异步事件触发之后,就会通知主线程,主线程执行相应事件的回调。
以上是众所周知的内容。今天我们从源码入手,分析一下 nodejs 的事件循环机制。
nodejs 架构
首先,我们先看下 nodejs 架构,下图所示:
如上图所示,nodejs 自上而下分为
用户代码 ( js 代码 )
用户代码即我们编写的应用程序代码、npm 包、nodejs 内置的 js 模块等,我们日常工作中的大部分时间都是编写这个层面的代码。
binding 代码或者三方插件(js 或 C/C++ 代码)
胶水代码,能够让 js 调用 C/C++的代码。可以将其理解为一个桥,桥这头是 js,桥那头是 C/C++,通过这个桥可以让 js 调用 C/C++。
在 nodejs 里,胶水代码的主要作用是把 nodejs 底层实现的 C/C++库暴露给 js 环境。
三方插件是我们自己实现的 C/C++库,同时需要我们自己实现胶水代码,将 js 和 C/C++进行桥接。
底层库
nodejs 的依赖库,包括大名鼎鼎的 V8、libuv。
V8: 我们都知道,是 google 开发的一套高效 javascript 运行时,nodejs 能够高效执行 js 代码的很大原因主要在它。
libuv:是用 C 语言实现的一套异步功能库,nodejs 高效的异步编程模型很大程度上归功于 libuv 的实现,而 libuv 则是我们今天重点要分析的。
还有一些其他的依赖库
http-parser:负责解析 http 响应
openssl:加解密
c-ares:dns 解析
npm:nodejs 包管理器
...
关于 nodejs 不再过多介绍,大家可以自行查阅学习,接下来我们重点要分析的就是 libuv。
libuv 架构
我们知道,nodejs 实现异步机制的核心便是 libuv,libuv 承担着 nodejs 与文件、网络等异步任务的沟通桥梁,下面这张图让我们对 libuv 有个大概的印象:
这是 libuv 官网的一张图,很明显,nodejs 的网络 I/O、文件 I/O、DNS 操作、还有一些用户代码都是在 libuv 工作的。既然谈到了异步,那么我们首先归纳下 nodejs 里的异步事件:
非 I/O:
定时器(setTimeout,setInterval)
microtask(promise)
process.nextTick
setImmediate
DNS.lookup
I/O:
网络 I/O
文件 I/O
一些 DNS 操作
...
网络 I/O
对于网络 I/O,各个平台的实现机制不一样,linux 是 epoll 模型,类 unix 是 kquene 、windows 下是高效的 IOCP 完成端口、SunOs 是 event ports,libuv 对这几种网络 I/O 模型进行了封装。参考 nodejs 进阶视频讲解:进入学习
文件 I/O、异步 DNS 操作
libuv 内部还维护着一个默认 4 个线程的线程池,这些线程负责执行文件 I/O 操作、DNS 操作、用户异步代码。当 js 层传递给 libuv 一个操作任务时,libuv 会把这个任务加到队列中。之后分两种情况:
1、线程池中的线程都被占用的时候,队列中任务就要进行排队等待空闲线程。
2、线程池中有可用线程时,从队列中取出这个任务执行,执行完毕后,线程归还到线程池,等待下个任务。同时以事件的方式通知 event-loop,event-loop 接收到事件执行该事件注册的回调函数。
当然,如果觉得 4 个线程不够用,可以在 nodejs 启动时,设置环境变量 UV_THREADPOOL_SIZE 来调整,出于系统性能考虑,libuv 规定可设置线程数不能超过 128 个。
nodejs 源码
先简要介绍下 nodejs 的启动过程:
1、调用 platformInit 方法 ,初始化 nodejs 的运行环境。
2、调用 performance_node_start 方法,对 nodejs 进行性能统计。
3、openssl 设置的判断。
4、调用 v8_platform.Initialize,初始化 libuv 线程池。
5、调用 V8::Initialize,初始化 V8 环境。
6、创建一个 nodejs 运行实例。
7、启动上一步创建好的实例。
8、开始执行 js 文件,同步代码执行完毕后,进入事件循环。
9、在没有任何可监听的事件时,销毁 nodejs 实例,程序执行完毕。
以上就是 nodejs 执行一个 js 文件的全过程。接下来着重介绍第八个步骤,事件循环。
我们看几处关键源码:
1、core.c,事件循环运行的核心文件。
2、timers 阶段,源码文件:timers.c。
3、 轮询阶段 源码,源码文件:kquene.c
uv__io_poll 阶段源码最长,逻辑最为复杂,可以做个概括,如下:当 js 层代码注册的事件回调都没有返回的时候,事件循环会阻塞在 poll 阶段。看到这里,你可能会想了,会永远阻塞在此处吗?
1、首先呢,在 poll 阶段执行的时候,会传入一个 timeout 超时时间,该超时时间就是 poll 阶段的最大阻塞时间。
2、其次呢,在 poll 阶段,timeout 时间未到的时候,如果有事件返回,就执行该事件注册的回调函数。timeout 超时时间到了,则退出 poll 阶段,执行下一个阶段。
所以,我们不用担心事件循环会永远阻塞在 poll 阶段。
以上就是事件循环的两个核心阶段。限于篇幅,timers 阶段的其他源码和 setImmediate、process.nextTick 的涉及到的源码就不罗列了,感兴趣的童鞋可以看下源码。
最后,总结出事件循环的原理如下,以上你可以不 care,记住下面的总结就好了。
事件循环原理
node 的初始化
初始化 node 环境。
执行输入代码。
执行 process.nextTick 回调。
执行 microtasks。
进入 event-loop
进入 timers 阶段
检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。
检查是否有 process.nextTick 任务,如果有,全部执行。
检查是否有 microtask,如果有,全部执行。
退出该阶段。
进入 IO callbacks 阶段。
检查是否有 pending 的 I/O 回调。如果有,执行回调。如果没有,退出该阶段。
检查是否有 process.nextTick 任务,如果有,全部执行。
检查是否有 microtask,如果有,全部执行。
退出该阶段。
进入 idle,prepare 阶段:
这两个阶段与我们编程关系不大,暂且按下不表。
进入 poll 阶段
首先检查是否存在尚未完成的回调,如果存在,那么分两种情况。
第一种情况:
如果有可用回调(可用回调包含到期的定时器还有一些 IO 事件等),执行所有可用回调。
检查是否有 process.nextTick 回调,如果有,全部执行。
检查是否有 microtaks,如果有,全部执行。
退出该阶段。
第二种情况:
如果没有可用回调。
检查是否有 immediate 回调,如果有,退出 poll 阶段。如果没有,阻塞在此阶段,等待新的事件通知。
如果不存在尚未完成的回调,退出 poll 阶段。
进入 check 阶段。
如果有 immediate 回调,则执行所有 immediate 回调。
检查是否有 process.nextTick 回调,如果有,全部执行。
检查是否有 microtaks,如果有,全部执行。
退出 check 阶段
进入 closing 阶段。
如果有 immediate 回调,则执行所有 immediate 回调。
检查是否有 process.nextTick 回调,如果有,全部执行。
检查是否有 microtaks,如果有,全部执行。
退出 closing 阶段
检查是否有活跃的 handles(定时器、IO 等事件句柄)。
如果有,继续下一轮循环。
如果没有,结束事件循环,退出程序。
细心的童鞋可以发现,在事件循环的每一个子阶段退出之前都会按顺序执行如下过程:
检查是否有 process.nextTick 回调,如果有,全部执行。
检查是否有 microtaks,如果有,全部执行。
退出当前阶段。
记住这个规律哦。
评论