# Node 事件循环

By [Murraya](https://paragraph.com/@murraya) · 2022-05-10

---

_Node_ 事件循环
===========

经典真题
----

*   请简述一下 _Node.js_ 中的事件循环，和浏览器环境的事件循环有何不同？
    

_Node.js_ 与浏览器的事件循环有何区别？
------------------------

### 进程与线程

我们经常说 _JavaScript_ 是一门单线程语言，指的是一个进程里只有一个主线程，那到底什么是线程？什么是进程？

首先需要把这个问题搞明白。

进程是 _CPU_ 资源分配的最小单位，而线程是 _CPU_ 调度的最小单位。举个例子，看下面的图：

![](https://storage.googleapis.com/papyrus_images/3bf0e04b5eeb77de974d5a8baf63f51b7ad9cc88b97b6a060a1b47994ca74ddb.png)

*   进程好比图中的工厂，有单独的专属自己的工厂资源。
    
*   线程好比图中的工人，多个工人在一个工厂中协作工作，工厂与工人是 _1:n_ 的关系。也就是说**一个进程由一个或多个线程组成，线程是一个进程中代码的不同执行路线**。
    
*   工厂的空间是工人们共享的，这象征**一个进程的内存空间是共享的，每个线程都可用这些共享内存**。
    
*   多个工厂之间独立存在。
    

接下来我们回过头来看多进程和多线程的概念：

*   多进程：在同一个时间里，同一个计算机系统中如果允许两个或两个以上的进程处于运行状态。多进程带来的好处是明显的，比如你可以听歌的同时，打开编辑器敲代码，编辑器和听歌软件的进程之间丝毫不会相互干扰。
    
*   多线程：程序中包含多个执行流，即在一个程序中可以同时运行多个不同的线程来执行不同的任务，也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。
    

以 _Chrome_ 浏览器中为例，当你打开一个 _Tab_ 页时，其实就是创建了一个进程。

![](https://storage.googleapis.com/papyrus_images/cd2c2c486e63c21dfa7e337f66ebbaa84d1c1cfd1feda78673d81961e1a51e68.png)

一个进程中可以有多个线程，比如渲染线程、_JS_ 引擎线程、_HTTP_ 请求线程等等。当你发起一个请求时，其实就是创建了一个线程，当请求结束后，该线程可能就会被销毁。

### 浏览器内核

简单来说浏览器内核是通过取得页面内容、整理信息（应用 _CSS_ ）、计算和组合最终输出可视化的图像结果，通常也被称为渲染引擎。

浏览器内核是多线程，在内核控制下各线程相互配合以保持同步，一个浏览器通常由以下常驻线程组成：

*   _GUI_ 渲染线程
    
*   _JavaScript_ 引擎线程
    
*   定时触发器线程
    
*   事件触发线程
    
*   异步 _http_ 请求线程
    

#### _GUI_ 渲染线程

*   主要负责页面的渲染，解析 _HTML_、_CSS_，构建 _DOM_ 树，布局和绘制等。
    
*   当界面需要重绘或者由于某种操作引发回流时，将执行该线程。
    
*   该线程与 _JS_ 引擎线程互斥，当执行 _JS_ 引擎线程时，_GUI_ 渲染会被挂起，当任务队列空闲时，主线程才会去执行 _GUI_ 渲染。
    

#### _JavaScript_ 引擎线程

*   该线程当然是主要负责处理 _JavaScript_ 脚本，执行代码。
    
*   也是主要负责执行准备好待执行的事件，即定时器计数结束，或者异步请求成功并正确返回时，将依次进入任务队列，等待 _JS_ 引擎线程的执行。
    
*   当然，该线程与 _GUI_ 渲染线程互斥，当 _JS_ 引擎线程执行 _JavaScript_ 脚本时间过长，将导致页面渲染的阻塞。
    

#### 定时触发器线程

*   负责执行异步定时器一类的函数的线程，如：_setTimeout、setInterval_。
    
*   主线程依次执行代码时，遇到定时器，会将定时器交给该线程处理，当计数完毕后，事件触发线程会将计数完毕后的事件加入到任务队列的尾部，等待 _JS_ 引擎线程执行。
    

#### 事件触发线程

*   主要负责将准备好的事件交给 _JS_ 引擎线程执行。
    

比如 _setTimeout_ 定时器计数结束， _ajax_ 等异步请求成功并触发回调函数，或者用户触发点击事件时，该线程会将整装待发的事件依次加入到任务队列的队尾，等待 _JS_ 引擎线程的执行。

#### 异步 _http_ 请求线程

*   负责执行异步请求一类的函数的线程，如：_Promise、fetch、ajax_ 等。
    
*   主线程依次执行代码时，遇到异步请求，会将函数交给该线程处理，当监听到状态码变更，如果有回调函数，事件触发线程会将回调函数加入到任务队列的尾部，等待 _JS_ 引擎线程执行。
    

### 浏览器中的事件循环

#### 宏任务和微任务

事件循环中的异步队列有两种：宏任务（ _macro_ ）队列和微任务（ _micro_ ）队列。

**宏任务队列有一个，微任务队列只有一个**。

*   常见的宏任务有：_setTimeout、setInterval、requestAnimationFrame、script_等。
    
*   常见的微任务有：_new Promise( ).then(回调)、MutationObserver_ 等。
    

#### 事件循环流程

一个完整的事件循环过程，可以概括为以下阶段：

![](https://storage.googleapis.com/papyrus_images/1fba977c8a2c07e1ce25c9f25f585c9b705d3261364b44e3fa825ecc78b1feec.png)

*   一开始执行栈空，我们可以把**执行栈认为是一个存储函数调用的栈结构，遵循先进后出的原则**。微任务队列空，宏任务队列里有且只有一个 _script_ 脚本（整体代码）。
    
*   全局上下文（ _script_ 标签）被推入执行栈，同步代码执行。在执行的过程中，会判断是同步任务还是异步任务，通过对一些接口的调用，可以产生新的宏任务与微任务，它们会分别被推入各自的任务队列里。同步代码执行完了，_script_ 脚本会被移出宏任务队列，这个过程本质上是队列的宏任务的执行和出队的过程。
    
*   上一步我们出队的是一个宏任务，这一步我们处理的是微任务。但需要注意的是：当一个宏任务执行完毕后，会执行所有的微任务，也就是将整个微任务队列清空。
    
*   执行渲染操作，更新界面
    
*   检查是否存在 _Web worker_ 任务，如果有，则对其进行处理
    
*   上述过程循环往复，直到两个队列都清空
    

宏任务和微任务的执行流程，总结起来就是：

**当某个宏任务执行完后，会查看是否有微任务队列。如果有，先执行微任务队列中的所有任务，如果没有，会读取宏任务队列中排在最前的任务，执行宏任务的过程中，遇到微任务，依次加入微任务队列。栈空后，再次读取微任务队列里的任务，依次类推。**

执行流程如下图所示：

![](https://storage.googleapis.com/papyrus_images/963ec63a7709366d0da01de1f0aef9f0dc9b6573324eb11eb60de0192aa7c967.png)

这里我们可以来看两道具体的代码加深理解：

    console.log('script start');
    setTimeout(function() {
        console.log('setTimeout');
    }, 0);
    
    Promise.resolve().then(function() {
        console.log('promise1');
    }).then(function() {
        console.log('promise2');
    });
    
    console.log('script end');
    

上面的代码输出的结果为：

    script start
    script end
    promise1
    promise2
    setTimeout
    

原因很简单，首先会执行同步的任务，输出 _script start_ 以及 _script end_。接下来是处理异步任务，异步任务分为宏任务队列和微任务队列，在执行宏任务队列中的每个宏任务之前先把微任务清空一遍，由于 _promise_ 是微任务，所以会先被执行，而 _setTimeout_ 由于是一个宏任务，会在微任务队列被清空后再执行。

    Promise.resolve().then(()=>{
      console.log('Promise1')
      setTimeout(()=>{
        console.log('setTimeout2')
      },0)
    })
    setTimeout(()=>{
      console.log('setTimeout1')
      Promise.resolve().then(()=>{
        console.log('Promise2')
      })
    },0)
    

上面的代码输出的结果为：

一开始执行栈的同步任务（这属于宏任务）执行完毕，会去查看是否有微任务队列，上题中存在（有且只有一个），然后执行微任务队列中的所有任务输出 _Promise1_，同时会生成一个宏任务 _setTimeout2_。

然后去查看宏任务队列，宏任务 _setTimeout1_ 在 _setTimeout2_ 之前，先执行宏任务 _setTimeout1_，输出 _setTimeout1_。在执行宏任务 _setTimeout1_ 时会生成微任务 _Promise2_ ，放入微任务队列中，接着先去清空微任务队列中的所有任务，输出 _Promise2_。

清空完微任务队列中的所有任务后，就又会去宏任务队列取一个，这回执行的是 _setTimeout2_。

### _Node.js_ 中的事件循环

#### _Node.js_ 事件循环介绍

_Node.js_ 中的事件循环和浏览器中的是完全不相同的东西。

_Node.js_ 采用 _V8_ 作为 _JS_ 的解析引擎，而 _I/O_ 处理方面使用了自己设计的 _libuv_，_libuv_ 是一个基于事件驱动的跨平台抽象层，封装了不同操作系统一些底层特性，对外提供统一的 _API_，事件循环机制也是它里面的实现。

![](https://storage.googleapis.com/papyrus_images/73d4f19b548fdf21aeb83a9081e806d323d24f739e2ae2ba7a470701015ea869.png)

可以看出 _Node.JS_ 的事件循环比浏览器端复杂很多。_Node.js_ 的运行机制如下:

*   _V8_ 引擎解析 _JavaScript_ 脚本。
    
*   解析后的代码，调用 _Node API_。
    
*   _libuv_ 库负责 _Node API_ 的执行。它将不同的任务分配给不同的线程，形成一个事件循环，以异步的方式将任务的执行结果返回给 _V8_ 引擎。
    
*   _V8_ 引擎再将结果返回给用户。
    

整个架构图如下所示：

![](https://storage.googleapis.com/papyrus_images/9301736385034f37511252d453f8b4ccd7f445cd4af8829d70f89ba488e3ca34.png)

#### 事件循环的 _6_ 个阶段

其中 _libuv_ 引擎中的事件循环分为 _6_ 个阶段，它们会按照顺序反复运行。每当进入某一个阶段的时候，都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值，就会进入下一阶段。

![](https://storage.googleapis.com/papyrus_images/5aaa9cf333f5f48e9cfc0ced9285c0b6f304918e33e9c998348f2d8a3ce0b76b.jpg)

从上图中，大致看出 _Node.js_ 中的事件循环的顺序：

外部输入数据 –-> 轮询阶段（ _poll_ ）-–> 检查阶段（ _check_ ）-–> 关闭事件回调阶段（ _close callback_ ）–-> 定时器检测阶段（ _timer_ ）–-> _I/O_ 事件回调阶段（ _I/O callbacks_ ）-–>闲置阶段（ _idle、prepare_ ）–->轮询阶段（按照该顺序反复运行）...

以上 _6_ 个阶段所做的事情如下：

*   _timers_ 阶段：这个阶段执行 _timer_（ _setTimeout、setInterval_ ）的回调
    
*   _I/O callbacks_ 阶段：处理一些上一轮循环中的少数未执行的 _I/O_ 回调
    
*   _idle、prepare_ 阶段：仅 _Node.js_ 内部使用
    
*   _poll_ 阶段：获取新的 _I/O_ 事件, 适当的条件下 _Node.js_ 将阻塞在这里
    
*   _check_ 阶段：执行 _setImmediate( )_ 的回调
    
*   _close callbacks_ 阶段：执行 _socket_ 的 _close_ 事件回调
    

注意：**上面六个阶段都不包括 _process.nextTick( )_**

接下去我们详细介绍 _timers、poll、check_ 这 _3_ 个阶段，因为日常开发中的绝大部分异步任务都是在这 _3_ 个阶段处理的。

**_timer_ 阶段**

_timers_ 阶段会执行 _setTimeout_ 和 _setInterval_ 回调，并且是由 _poll_ 阶段控制的。同样，**在 _Node.js_ 中定时器指定的时间也不是准确时间，只能是尽快执行**。

**_poll_ 阶段**

_poll_ 是一个至关重要的阶段，这一阶段中，系统会做两件事情：

*   回到 _timer_ 阶段执行回调
    
*   执行 _I/O_ 回调
    

并且在进入该阶段时如果没有设定了 _timer_ 的话，会发生以下两件事情：

*   如果 _poll_ 队列不为空，会遍历回调队列并同步执行，直到队列为空或者达到系统限制
    
*   如果 _poll_ 队列为空时，会有两件事发生：
    
    *   如果有 _setImmediate_ 回调需要执行，_poll_ 阶段会停止并且进入到 _check_ 阶段执行回调
        
    *   如果没有 _setImmediate_ 回调需要执行，会等待回调被加入到队列中并立即执行回调，这里同样会有个超时时间设置防止一直等待下去
        

当然设定了 _timer_ 的话且 _poll_ 队列为空，则会判断是否有 _timer_ 超时，如果有的话会回到 _timer_ 阶段执行回调。

假设 _poll_ 被堵塞，那么即使 _timer_ 已经到时间了也只能等着，这也是为什么上面说定时器指定的时间并不是准确的时间。例如：

    const start = Date.now();
    setTimeout(function f1() {
        console.log("setTimeout", Date.now() - start);
    }, 200);
    
    const fs = require('fs');
    
    fs.readFile('./index.js', 'utf-8', function f2() {
        console.log('readFile');
        const start = Date.now();
        // 强行延时 500 毫秒
        while (Date.now() - start < 500) { }
    })
    

**_check_ 阶段**

_setImmediate( )_ 的回调会被加入 _check_ 队列中，从事件循环的阶段图可以知道，_check_ 阶段的执行顺序在 _poll_ 阶段之后。

我们先来看个例子：

    console.log('start')
    setTimeout(() => {
      console.log('timer1')
      Promise.resolve().then(function() {
        console.log('promise1')
      })
    }, 0)
    setTimeout(() => {
      console.log('timer2')
      Promise.resolve().then(function() {
        console.log('promise2')
      })
    }, 0)
    Promise.resolve().then(function() {
      console.log('promise3')
    })
    console.log('end')
    // 输出结果：start => end => promise3 => timer1 => promise1 => timer2 => promise2
    

一开始执行同步任务，依次打印出 _start end_，并将 _2_ 个 _timer_ 依次放入 _timer_ 队列，之后会立即执行微任务队列，所以打印出 _promise3_。

然后进入 _timers_ 阶段，执行 _timer1_ 的回调函数，打印 _timer1_，发现有一个 _promise.then_ 回调将其加入到微任务队列并且立即执行，之后同样的步骤执行 _timer2_，打印 _timer2_ 以及 _promise2_。

#### 一些注意点

**_setTimeout_ 和 _setImmediate_ 区别**

二者非常相似，区别主要在于调用时机不同。

*   _setImmediate_ 设计在 _poll_ 阶段完成时执行，即 _check_ 阶段
    
*   _setTimeout_ 设计在 _poll_ 阶段为空闲时，且设定时间到达后执行，但它在 _timer_ 阶段执行
    

来看一个具体的示例：

    setTimeout(function timeout () {
      console.log('timeout');
    },0);
    setImmediate(function immediate () {
      console.log('immediate');
    });
    

对于以上代码来说，_setTimeout_ 可能执行在前，也可能执行在后。首先 _setTimeout(fn, 0) === setTimeout(fn, 1)_，这是由源码决定的，进入事件循环也是需要成本的，如果在准备时候花费了大于 _1ms_ 的时间，那么在 _timer_ 阶段就会直接执行 _setTimeout_ 回调。如果准备时间花费小于 _1ms_，那么就是 _setImmediate_ 回调先执行了。

但当二者在异步 _I/O callback_ 内部调用时，总是先执行 _setImmediate_，再执行 _setTimeout_，例如：

    const fs = require('fs')
    fs.readFile(__filename, () => {
        setTimeout(() => {
            console.log('timeout');
        }, 0)
        setImmediate(() => {
            console.log('immediate')
        })
    })
    // immediate
    // timeout
    

在上述代码中，_setImmediate_ 永远先执行。因为两个代码写在 _I/O_ 回调中，_I/O_ 回调是在 _poll_ 阶段执行，当回调执行完毕后队列为空，发现存在 _setImmediate_ 回调，所以就直接跳转到 _check_ 阶段去执行回调了。

**_process.nextTick_**

这个函数其实是独立于事件循环之外的，它有一个自己的队列。当每个阶段完成后，如果存在 _nextTick_ 队列，就会清空队列中的所有回调函数，并且优先于其他 _microtask_ 执行。

    setTimeout(() => {
     console.log('timer1')
     Promise.resolve().then(function() {
       console.log('promise1')
     })
    }, 0)
    process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
       process.nextTick(() => {
         console.log('nextTick')
         process.nextTick(() => {
           console.log('nextTick')
         })
       })
     })
    })
    // nextTick => nextTick => nextTick => nextTick => timer1 => promise1
    

**_Promise.then_**

_Promise.then_ 也是独立于事件循环之外的，有一个自己的队列，但是优先级要比 _process.nextTick_ 要低，所以当微任务中同时存在 _process.nextTick_ 和 _Promise.then_ 时，会优先执行前者。

    setTimeout(()=>{
        console.log('timer1')
        Promise.resolve().then(function() {
            console.log('promise1')
        })
        process.nextTick(() => {
            console.log('nexttick');
        })
    }, 0)
    setTimeout(()=>{
        console.log('timer2')
        Promise.resolve().then(function() {
            console.log('promise2')
        })
    }, 0)
    // timer1、nexttick、promise1、timer2、promise2
    

#### _Node.js_ 与浏览器的事件队列的差异

浏览器环境下，就两个队列，一个宏任务队列，一个微任务队列。微任务的任务队列是每个宏任务执行完之后执行。

在 _Node.js_ 中，每个任务队列的每个任务执行完毕之后，就会清空这个微任务队列。

![eventloop](https://storage.googleapis.com/papyrus_images/733e9d8567936e94c4ef32f1c5181fb73487bf9928934d2c7d5664e09a221598.png)

eventloop

---

*Originally published on [Murraya](https://paragraph.com/@murraya/node)*
