深入node.js事件轮询
尽管 JavaScript 是单线程的,但“事件轮询”机制允许 Node.js 通过尽可能将操作系统内核执行非阻塞的I/O操作。
由于大多数现代内核都是多线程的,因此它们可以在后台处理执行多个操作。当其中一个操作完成时,内核会告诉Node.js,以便可以将相应的回调添加到轮询队列中以最终执行。
线程模型
JavaScript 从设计之初起就是单线程,它的目的就是解决一些脚本语言的问题,因为设计的能力有限,性能不需要重点考虑。
由于 JavaScript 是单线程的,所有任务都在一个线程上完成,一旦遇到大量任务或者遇到一个耗时的任务,网页就会出现"假死"。Event Loop就是为了解决这个问题而提出的。
EventLoop
简单说,就是在程序中设置两个线程:一个负责程序本身的运行,称为"主线程";另一个负责主线程与其他进程(主要是各种I/O操作)的通信,被称为"Event Loop线程"(可以译为"消息线程")。
EventLoop 是 Javascript 对异步的具体实现,在程序执行过程中,直接执行同步代码,异步代码/函数会先放在异步队列中挂起,待同步的任务执行完成之后,轮询执行异步队列中的任务。
Node.js中的EventLoop
Node.js 中的 EventLoop 是通过 libuv
这个库实现的。当 Node.js 启动时,它会初始化事件循环,处理在 Node.js 中运行的代码,其中可能包含了异步API调用,然后开始运行事件循环。node.js 处理的事件循环一共有六个阶段,如下面所示:
┌───────────────────────┐
┌─>│ timers(定时器阶段) │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks(系统错误)│
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll(轮询阶段) │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
每个阶段的对应关系如下:
timers
(定时器阶段):此阶段执行由 setTimeout 和 setInterval 调度的回调;
pending callbacks
:此阶段执行系统错误处理,包括 socket、TCP、UDP等;
idle, prepare
:此阶段仅仅内部使用;
poll
:此阶段会检索新的I/O事件,执行对应I/O相关的回调(首先处理到期的定时器回调,然后处理 poll 队列中的回调,直到队列中的回调被清空,或者达到处理的上限;若队列不为空,且刚好有 setImmediate 回调,则终止当前 poll 阶段,前往 check 阶段,执行 setImmediate 回调;若没有 setImmediate 回调,node.js 回去查看有没有定时器任务到期,若有则前往 timer 阶段执行定时器任务,若没有则前往 check 阶段);
check
:此阶段会执行 setImmediate 回调(注意 setImmediate 回调只能在此阶段执行);
close callbacks
:一些关闭的回调,例如 ***.on('close',...);
以上六个阶段都是独立的,都存在有自己的消息队列,不同的回调一定会进入到不同的阶段(比如:setImmediate 一定会在 check 阶段,setTimeout 一定会在 timer 阶段)。
事件循环启动后,都是从上往下执行,每个阶段都会执行队列中回调,直到全部执行完,或者达到最大的处理数量。node.js 就会进入到下一个阶段去检查新的队列,直到执行完全部的阶段还有每个阶段中的队列。
注意:
node.js 在执行过程中,并不是严格按照这几个阶段的顺序执行的,有的阶段会被web事件触发。同时,在任意两个阶段切换之间,只要有 process.nextTick 回调没有执行,那么就优先执行它的回调,这也包括 Promise 这种 macrotask 回调。