高拍仪 js调用demo_深入理解js事件循环机制(Node.js篇)

原文链接:

http://lynnelv.github.io/js-event-loop-nodejs

在浏览器篇已经对事件循环机制和一些相关的概念作了详细介绍,但主要是针对浏览器端的研究,Node环境是否也一样呢?先看一个demo:

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

在浏览器的运行结果如下,道理都懂,就不赘述了。

timer1
promise1
timer2
promise2

那么Node下执行看看,跟浏览器的运行结果 不一样

timer1
timer2
promise1
promise2

例子说明,浏览器和Node.js的事件循环机制是有区别的,一起来看下究竟吧

Node.js的事件处理

Node.js采用v8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的api,事件循环机制也是它里面的实现,核心源码参考:

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;

  r = uv__loop_alive(loop);
  if (!r)
    uv__update_time(loop);

  while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop);
    // timers阶段
    uv__run_timers(loop);
    // I/O callbacks阶段
    ran_pending = uv__run_pending(loop);
    // idle阶段
    uv__run_idle(loop);
    // prepare阶段
    uv__run_prepare(loop);

    timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);
    // poll阶段
    uv__io_poll(loop, timeout);
    // check阶段
    uv__run_check(loop);
    // close callbacks阶段
    uv__run_closing_handles(loop);

    if (mode == UV_RUN_ONCE) {
      uv__update_time(loop);
      uv__run_timers(loop);
    }

    r = uv__loop_alive(loop);
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }

  if (loop->stop_flag != 0)
    loop->stop_flag = 0;

  return r;
}

根据Node.js官方介绍,每次事件循环都包含了6个阶段,对应libuv源码中的实现,如下图所示

89ea336c8abb72404c2d5b4ef64bf3cb.png
  • timers阶段:这个阶段执行timer(setTimeout、setInterval)的回调
  • I/O callbacks阶段:执行一些系统调用错误,比如网络通信的错误回调
  • idle,prepare阶段:仅node内部使用
  • poll阶段:获取新的I/O事件,适当的条件下node将阻塞在这里
  • check阶段:执行setImmediate的回调
  • close callbacks阶段:执行socket的close事件回调

我们重点看timers、poll、check这3个阶段就好,因为日常开发中的绝大部分异步任务都是在这3个阶段处理的。

timers阶段

timers是事件循环的第一个阶段,Node会去检查有无已过期的timer,如果有则把它的回调压入timer的任务队列中等待执行,事实上,Node并不能保证timer在预设时间到了就会立即执行,因为Node对timer的过期检查不一定靠谱,它会受机器上其它运行程序影响,或者那个时间点主线程不空闲。比如下面的代码,setTimeout和setImmediate的执行顺序是不确定的。

setTimeout(() => {
  console.log('timeout')
}, 0)

setImmediate(() => {
  console.log('immediate')
})

但是把他们放在一个I/O回调里面,就一定是setImmediate先执行,因为poll阶段后面就是check阶段。

poll阶段

poll阶段主要有2个功能:

  • 处理poll队列的事件
  • 当有已超时的timer,执行它的回调函数

event loop将同步执行poll队列里的回调,直到队列为空或执行的回调达到系统上限(上限具体多少未详),接下来event loop会去检查有无预设的setImmediate,分两种情况:

  1. 若有预设的setImmediate,event loop将结束poll阶段进入check阶段,并执行check阶段的任务队列
  2. 若没有预设的setImmediate,event loop将阻塞在该阶段等待

注意一个细节,没有setImmediate会导致event loop阻塞在poll阶段,这样之前设置的timer岂不是执行不了了?所以,在poll阶段event loop会有一个检查机制,检查timer队列是否为空,如果timer队列非空,event loop就开始下一轮事件循环,即重新进入到timer阶段。

check阶段

setImmediate的回调会被加入check队列中,从event loop的阶段图可以知道,check阶段的执行顺序在poll阶段之后。

小结

  • event loop的每个阶段都一个任务队列
  • 当event loop到达某个阶段时,将执行该阶段的任务队列,直到队列清空或执行的回调达到系统上限后,才会转入下一个阶段
  • 当所有阶段被顺序执行一次后,称event loop完成了一个tick

讲的好有道理,可是没有demo我还是理解不全啊,别急,now!

const fs = require('fs')

fs.readFile('test.txt', () => {
  console.log('readFile')
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
})

执行结果应该都没有疑问了

readFile
immediate
timeout

Node.js 与浏览器的 Event Loop 差异

回顾上一篇,浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。

f8dd43a1e99b96b37fa1935a05e63aac.png

而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。

4018c1dbd115cbda0eceed4655c74aba.png

demo回顾

回顾文章最开始的demo,全局脚本(main())执行,将2个timer依次放入timer队列,main()执行完毕,调用栈空闲,任务队列开始执行;

f5711ed31bd2eb24741757d39adc04d9.gif

首先进入timers阶段,执行timer1的回调函数,打印timer1,并将promise1.then回调放入microtask队列,同样的步骤执行timer2,打印timer2

至此,timer阶段执行结束,event loop进入下一个阶段之前,执行microtask队列的所有任务,依次打印promise1promise2

对比浏览器端的处理过程:

14a84a785483158789879330d7923349.gif

process.nextTick() VS setImmediate()

In essence, the names should be swapped. process.nextTick() fires more immediately than setImmediate()

来自官方文档有意思的一句话,从语义角度看,setImmediate() 应该比 process.nextTick() 先执行才对,而事实相反,命名是历史原因也很难再变。

process.nextTick() 会在各个事件阶段之间执行,一旦执行,要直到nextTick队列被清空,才会进入到下一个事件阶段,所以如果递归调用 process.nextTick(),会导致出现I/O starving(饥饿)的问题,比如下面例子的readFile已经完成,但它的回调一直无法执行:

const fs = require('fs')
const starttime = Date.now()
let endtime

fs.readFile('text.txt', () => {
  endtime = Date.now()
  console.log('finish reading time: ', endtime - starttime)
})

let index = 0

function handler () {
  if (index++ >= 1000) return
  console.log(`nextTick ${index}`)
  process.nextTick(handler)
  // console.log(`setImmediate ${index}`)
  // setImmediate(handler)
}

handler()

process.nextTick()的运行结果:

nextTick 1
nextTick 2
......
nextTick 999
nextTick 1000
finish reading time: 170

替换成setImmediate(),运行结果:

setImmediate 1
setImmediate 2
finish reading time: 80
......
setImmediate 999
setImmediate 1000

这是因为嵌套调用的setImmediate()回调,被排到了下一次event loop才执行,所以不会出现阻塞。

总结

  1. Node.js 的事件循环分为6个阶段
  2. 浏览器和Node 环境下,microtask 任务队列的执行时机不同
  • Node.js中,microtask 在事件循环的各个阶段之间执行
  • 浏览器端,microtask 在事件循环的 macrotask 执行完之后执行
  1. 递归的调用process.nextTick()会导致I/O starving,官方推荐使用setImmediate()

[参考资料]

  1. event-loop-timers-and-nexttick
  2. Node.js Event Loop 的理解 Timers,process.nextTick()
  3. libuv/core.c
  4. nodejs/next_tick.js
  5. 深入理解js事件循环机制(浏览器篇)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值