阅读 164

浏览器与node事件循环

      我们都知道在浏览器中由于dom操作,js是单线程的。 为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
      多线程并不意味着速度就快,因为切换时间片需要时间。因为单线程的原因,事件循环就应运而生了。由于nodejs基于js,所以两者都在处理异步事件时都依赖于事件循环,不过两者的事件循环机制有相同又有不同。在了解两者的事件循环机制之前先了解关于异步任务的两个概念:



微任务(Microtask) 通常来说就是在当前 task 执行结束后立即执行的任务(也就是总是先于宏任务),例如需要对一系列的任务做出回应,或者是需要异步的执行任务而又不需要分配一个新的 task,这样便可以减小一点性能的开销。microtask 任务队列是一个与 task 任务队列相互独立的队列,microtask 任务将会在每一个 task 任务执行结束之后执行。每一个 task 中产生的 microtask 都将会添加到 microtask 队列中,microtask 中产生的 microtask 将会添加至当前队列的尾部,并且 microtask 会按序的处理完队列中的所有任务。

vue中的nextTick实现:Vue 在内部尝试对异步队列使用原生的setImmediate Promise.then和MessageChannel(旧版本采用mutationObserver,因为兼容性问题后面弃用),如果当前执行环境不支持,就采用setTimeout(fn, 0)代替。
// MutationObserver示例:
    let observe = new MutationObserver(function () {
        console.log('dom全部塞进去了');
    });
    // 也是一个微任务
    observe.observe(div,{childList:true});
    for (let i = 0; i < 100; i++) {
      let p = document.createElement('p');
      div.appendChild(p);
    }
    console.log(1);
    let img = document.createElement('p');
    div.appendChild(img);
复制代码
// MessageChannel用法:
    console.log(1);
    let channel = new MessageChannel();
    let port1 = channel.port1;
    let port2 = channel.port2;
    // 异步代码 vue 就是宏任务
    port1.postMessage('hello');
    port2.onmessage = function (e) {
      console.log(e.data);
    }
    console.log(2);

    // 1 2 hello
复制代码
// webwork(不能操作dom)相当于开了一个线程,用法:
// index.html中的js
    console.log(1);
    let worker = new Worker('./worker.js');
    worker.postMessage(1000); // 发消息
    worker.onmessage = function (e) { // 接收消息
      console.log(e.data); // 消息中的数据
    }
    console.log(2);

// worker.js
onmessage = function (e) {
  let sum = 0;
  for(var i = 0;i<e.data;i++){
    sum += i;
  }
  this.postMessage(sum)
}
复制代码

通过代码运行来理解浏览器和node事件循环机制的不同:

// 在浏览器,node下分别测试下面的一段代码:
setTimeout(() => {
  console.log('1')
  Promise.resolve('123').then(data => {
    console.log(2)
  });
});
setTimeout(() => {
  console.log('3');
});

//浏览器下  1 2 3

//node环境下:node 11及以上的版本一直输出: 1 2 3,跟浏览器一样。node 10 及以下的输出既有 1 2 3 也有 1 3 2,但是 1 3 2的次数比较多。
复制代码

原理:
浏览器 :执行栈中内容执行后执行微任务,微任务清空后再执行宏任务,到达条件的宏任务最终会在栈中执行,不停的循环event loop。所以上面的代码在输出1后,会先执行Promise微任务,然后再去执行任务队列里面;
node:微任务总是在新一轮事件循环开始之前执行,所以先执行完所有到达时间的setTimeout,然后在进入下一轮事件循环之前再执行Promise.resolve。下面的node事件环可以加强理解:

node启动过程

  • 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 实例,程序执行完毕。

在libuv(是用C语言实现的一套异步功能库,nodejs高效的异步编程模型很大程度上归功于libuv的实现)内部有这样一个事件环机制。在node启动时会初始化事件环


            ┌───────────────────────┐
        ┌─>│     timers(计时器)     │
        |  |   执行setTimeout以及   |
        |  |   setInterval的回调。  |
        │  └──────────┬────────────┘
                    微任务
        │  ┌──────────┴────────────┐
        │  │     I/O callbacks     |
        │  | 处理网络、流、tcp的错误 |
        |  | callback              |
        │  └──────────┬────────────┘
                    微任务
        │  ┌──────────┴────────────┐
        │  │     idle, prepare     │
        |  |     node内部使用       |
        │  └──────────┬────────────┘      
                    微任务
        │  ┌──────────┴────────────┐       ┌───────────────┐ 
        │  │       poll(轮询)      │       │   incoming:   │
        |  | 执行poll中的i/o队列    | <─────┤  connections, │
        |  | 检查定时器是否到时      |       │   data, etc.读取文件  |     
        │  └──────────┬────────────┘       └───────────────┘    
                    微任务
        │  ┌──────────┴────────────┐      
        │  │      check(检查)      │
        |  | 存放setImmediate回调   |
        │  └──────────┬────────────┘
                    微任务
        │  ┌──────────┴────────────┐
        └──┤    close callbacks    |
           │ 关闭的回调例如         |
           | sockect.on('close')   |
           └───────────────────────┘
  
  这里每一个阶段都对应一个事件队列,当event loop执行到某个阶段时会将当前阶段对应的队列依次执行。当队列执行完毕或者执行的数量超过上线时,会转入下一个阶段。微任务总是在开始新一轮循环时执行。
复制代码

结合上面的流程图,可以总结出node事件循环原理:

  • node 的初始化

    • 初始化 node 环境。
    • 执行输入代码。
    • 执行 process.nextTick 回调。
    • 执行 microtasks。
  • 进入 event-loop

    • 进入 timers 阶段

      • 检查 timer 队列是否有到期的 timer 回调,如果有,将到期的 timer 回调按照 timerId 升序执行。(node 10 及以下的版本:会清空所有的 timer 回调,再去执行检查下面的微任务。node 11 及以上的版本:每执行一个 timer 回调就会去检查一次微任务队列,如果有,全部执行;然后再继续执行下一个 timer ,跟浏览器一致。
      • 检查是否有 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,如果有,全部执行。 退出当前阶段。

下面通过一些可能遇到的面试题加强理解:

// node下执行以下代码
setImmediate(function(){
console.log('1');
});
setTimeout(function(){
console.log('2');
});

// 由于node存在准备时间,两者的输出顺序是不一定的。
复制代码

// 但是稍做修改:
let fs =require('fs')
fs.readFile('./1.txt','utf8',()=>{
    setImmediate(function(){
      console.log('1');
    });
    setTimeout(function(){
      console.log('2');
    });
})

// 1 2 结果永远是 1 2,即使setTimeout放在setImmediate前面。因为poll(轮询) 后就执行setImmediate。而setTimeout必须是在新一轮循环中才会执行
复制代码

//node下执行:

process.nextTick(function A() {
   console.log(1);
   process.nextTick(function B(){console.log(2);});
});

setTimeout(function timeout() {
   console.log('TIMEOUT FIRED');
}, 0)

// 1  2  TIMEOUT FIRED ;不管有多少个process.nextTick语句(不管它们是否嵌套),将全部在当前"执行栈"执行完之后执行。也就是说微任务总是在新一轮事件循环之前执行。
复制代码

//node下执行:

setImmediate(function () {
  console.log('4')
})
setImmediate(function () {
  console.log('5')
})
process.nextTick(function () {
  console.log('1')
  process.nextTick(function () {
    console.log('2')
    process.nextTick(function () {
      console.log('3')
    })
  })
})

console.log('next')

// 总是输出 next 1 2 3 4 5。在同步代码执行完后,执行微任务,然后进入下一轮事件循环
复制代码

总结:

异步任务分为:微任务和宏任务

  1. 浏览器端:宏任务主要有setTimeout,setInterval,setImmediate(ie),messageChannel,ajax请求,click事件等。微任务主要有Promise.then,mutationObserver。先执行栈里面的内容,栈执行完后,执行微任务,然后再读取异步队列,有到达执行条件的,读取到执行栈中执行。如此循环。
  2. node端:宏任务主要有setTimeout,setInterval,setImmediate,文件读写等。微任务主要有Promise.then,process.nextTick(比Promise.then更快执行)。

参考:
译文:JS事件循环机制(event loop)之宏任务、微任务: segmentfault.com/a/119000001…
[译] 深入理解 JavaScript 事件循环(二)— task and microtask:
www.cnblogs.com/dong-xu/p/7…
剖析nodejs的事件循环:juejin.cn/post/684490…
从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理:juejin.cn/post/684490…

阮一峰 node循环:www.ruanyifeng.com/blog/2018/0…