深入理解NodeJs事件循环机制
看完这篇文章,你可以回答以下问题:
- 什么是NodeJs的事件循环?
- NodeJs是单线程的吗?
- Event Loop 的每个阶段都做了些什么?
- setTimeout实现原理
- setImmedate和process.nextTick的区别
一些误解
事件循环(event loop)独立于主线程(main thread)
我们经常在网上看到一些关于JavaScript事件循环的架构图,他们会把事件循环从主线程里面抽离出来。这样就会使我们容易产生以下的错觉:事件循环是由另外一个线程维持的,这个线程监听一个事件队列,当事件队列不为空时而且主执行栈没有被执行的代码时,事件循环就会把回调函数从事件队列里面拿出来放到主执行栈(main thread)里面执行。其实这种想法是错误的,对于nodejs,无论是关于事件循环的C代码,还是你自己写的JavaScript代码都会在同一个线程, 即是主线程里面执行。
Node是单线程的
这种说法其实是不全面的,只能说Node表现起来是单线程的,这样我们在编写Node代码时就不用考虑到多线程对共享数据进行操作时的安全问题。其实在Node里面某些异步操作,例如文件读写, dns解析等使用的是libuv的线程池(thread pool), 这些操作实际上和主线程(main thread)不是同一个线程,所以,NodeJs is not so single-thread.
一些和Event Loop 相关的C语言代码
要理解NodeJs异步操作实现的原理,要先看一些C代码
假如我们现在要实现一个tcp服务器,以下是这个服务的伪C代码
// 创建一个socket
int server = socket()
// 将这个socket和80端口绑定在一起
bind(server, 80)
// block操作,监听80端口的socket
while(int connection = accept(server)) {
// 对于每一个链接,创建一个新的线程去处理这个连接的读和写
pthread_create(echo, connection)
}
void echo(int connection) {
char buf[4096];
while(int size = read(connection, buffer, sizeof buf)) {
write(connection, buffer, size)
}
}
以上代码有个问题就是,对于每一个新的连接,都会有一个线程单独去处理,其实线程是非常消耗资源的东西,想象一下,如果一个服务器有几十万的连接,就会有几十万的线程被创建出来,这对服务器的性能要求会非常高。其实对于某一个连接,我们只关心这个连接对应的文件描述符fd(file descriptor),当这个fd有数据时我们就去读,同时我们也可以往这个fd写入数据。所以对于上面这个问题更好的解决方案是(better scalability)利用系统的一些api对某个连接的fd进行操作(read/write/poll), 在*nix系统里面,该系统函数式epoll。以下是使用epoll对上面代码进行优化的结果:
int server = ... // 和上面一样
int eventfd = epoll_create(0)
struct epoll_event events[10]
struct epoll_event ev = { .events = EPOLL, .data.fd = server }
epoll_ctl(epollfd, EPOLL_CTL_ADD, server, &ev)
// 这部分代码类似于Node里面事件循环的代码
while ((int max = epoll_wait(eventfd, events, 10, -1))) {
for(n = 0; n < max; n++) {
if (events[n].data.fd.fd == server) {
// Server 收到一个连接
int connection = accept(server);
ev.events = EPOLLIN; ev.data.fd = connection;
epoll_ctl(eventfd, EPOLL_CTL_ADD, connection, &ev);
} else {
// 连接接收到Data
char buf[4096];
int size = read(connection, buffer, sizeof buf);
write(connection, buffer, size);
}
}}
使用系统函数处理网络连接而不是新建线程来处理,这大大提升了代码的性能和节约了资源,其实node里面也是利用epoll等系统函数进行一些异步操作的,所以node具有很好的扩展性。到这里你可能会问是不是所有node的异步操作都是使用这种方式实现的,其实不是,node里面的异步操作可以分成以下两种类型:
- pollabe: 这就是利用上面这种方式实现的异步方法。主要是和网络连接相关的东西例如net/dgram/http/tls/https等库。注意和文件读写相关的东西不是pollable的。
- uv thread pool: node 里面实现异步操作的另外一种方法就是利用libuv提供的线程。fs.*相关的,dns.lookup, crypto.pbkdf2方法都是利用线程池来实现多线程。因为利用线程池实现的异步操作会在一个独立于主线程的线程里面执行,所以这些异步操作相对于同步操作会有更好的性能,这就是为什么我们推荐使用async方法而不使用sync方法的原因。当子线程里面的代码执行完后可以通过self-pipe唤醒主线程的event loop 进行下一步操作。
Node 事件循环的不同阶段
上面简叙了Node的异步操作的实现原理(pollable或thread pool),可是开发者大对数时间关心的是Node事件循环都经历了哪些不同的阶段。
以下是node事件循环的一个架构图:
![nodejs-event-loop-phase](https://user-images.githubusercontent.com/16237665/47011747-3bd51800-d175-11e8-985c-69fdcd77d117.png)
在上图中,每个盒子代表事件循环的一个阶段,node引擎会按照顺序依次进入每一个事件循环的阶段。每一个事件阶段都会有自己的一个数据结构(可能是queue,也可能是其他数据结构)来储存和这个阶段相关的一些任务。你还可以看到在上图正中间有两个队列,一个nextTickQueue
,另外一个是microTaskQueue
。nextTickQueue存储的是用process.nextTick
注册的回调函数,microTaskQueue存储的是一些resolved后的promise的回调函数。这两个队列不属于任何一个事件循环阶段,但是每个事件循环阶段都可以访问到这两个队列,如果它们发现这两个队列不是空的,就会执行这两个队列里面的回调函数, nextTickQueue比microTaskQueue具有更大的优先级。值得注意的是这两个队列里面的回调函数是as soon as possible
优先级的,它们不是在libUV库里面被实现的,而是在node js代码里面实现的。它们会在js和C/C++代码切换时被检查并执行(如果存在),为了便于理解以下是作者的原话:
These two are not really part of the event loop, i.e. not developed inside libUV library, but in node.js. They are called as soon as possible, whenever the boundary between C/C++ and JavaScript is crossed. So they are supposed to be called right after the currently running operation (not necessarily the currently executing JS function callback).
以下会分别介绍每一个事件阶段:
Timer Phase
这是事件循环的开始阶段。这个事件循环阶段维持了一个存储setTimeout回调函数的min-heap (最小二叉堆),每次从堆中拿出权重最小的setTimeout回调函数,判断这个setTimeout函数是不是超过了其预期执行的时间,如果是就执行这个回调函数,然后再取下一个回调函数进行判断,直到取到的第一个没有超时的任务为止。
Pending i/o callback phase
这个阶段执行的是在pending_queue
里面的回调函数。这些回调函数通常都是由先前的一些操作产生的。举个例子,如果你的代码有对一个tcp连接进行写入的操作,并且有指定onSuccess回调函数,当写入操作结束时,这个回调函数就会被放到这个pending_queue里面,同样,哪些onError的回调函数也会被放到这个队列。
Idle, Prepare phase
这个阶段比较特殊,会在每一个事件循环阶段之前被执行,涉及一些node内部的东西,我们开发node代码时不需要考虑这个阶段。
Poll phase
这可能是最重要的事件循环阶段了,在这个阶段里面会接收新的网络连接或者接收文件的数据(self-pipe)。这个阶段的工作可以大体分成以下的部分:
- 如果
watch_queue
里面有任务,这些任务会被依次按顺序同步执行,结束条件是这个任务队列空了,或者执行任务的次数超过了当前系统的限制的最大值。
- 当
watch_queue
这个队列变为空时,node会尝试等待一段时间(类似于上面server的例子)以等待新的网络连接的到来。node等待的时间由当前的上下文决定。
Check phase
这个阶段比较简单,就是用来服务setImmediate
的,所有setImmediate的回调函数都会在这个阶段被执行。
Close callbacks
涉及到close操作的回调函数会在这里被执行,例如 socket.on('close', () => {}),做一些资源的cleanup操作。
Node 代码的执行
为了更好地理解Node的event loop,我们来看一个实际简单的例子:
// index.js
console.log('hi')
setTimeout(() => {
console.log('setTimeout')
process.nextTick('next tick')
}, 0)
setImmediate(() => {
console.log('setImmediate')
})
这段代码的输出结果是
hi
setTimeout
next tick
setImmediate
或者
hi
setImmediate
setTimeout
next tick
为什么有两种可能,我们来一步一步看
- 首先你在命令行输入
node index.js
后,node会初始化一个事件队列,同时初始化一个变量reventsCount = 0用来记录现在的异步任务数量,然后才开始执行实际的代码。
- 主代码执行,输出
hi
, 然后将第一个setTimeout放进timer phase
对应的min-heap,将setImmediate放到check phase
对应的queue。这时候因为进行了两个异步操作,reventsCount = 2。
- Node代码执行到最后一行js代码时发现reventsCount > 0,开始进入事件循环。
- 第一个阶段是
timer phase
阶段,setTimeout会被拿出来,虽然设置的时间是0, 可是会被置为1(node当timeout等于0或者大于2147483647都会置为1),所以这时候这个任务不一定超时,这是个race condition, 如果已经超时, 第一个setTimeout就会被打印出来,reventsCounts减一,而且一个nextTick的回调函数会被加入到nextTickQueue
, reventsCounts加一。
- 之前说到
nextTickQueue
里面的回调函数是ASAP的,所以在进下一个phase前这个nextTick的任务就被执行输出nextTick,reventsCount会减一, 注意这是基于上面那个setTimeout任务已经超时的情况下的结果,如果上面setTimeout没有超时,这个任务也不会被加入到nextTickQueue。
Timer phase
过后因为没有pending i/o callbacks,也没有网络连接到来,所以会到check这个阶段,这个阶段会执行setImmediate指定的回调函数, reventsCount - 1。
- Close callbacks事件阶段后,node发现reventsCount已经是0了,也就是说没有正在进行的异步任务,系统就会退出这个process。
参考文档