javascript异步I/O及其Event Loop

吐槽君 分类:javascript

前前后后做了几年的javascript开发,中间断断续续。始终有些问题没有弄明白,例如本文提到的javascript异步。从最早接触到的 setTimeout,setInterval,callback再到后来的Promise,async, node的setImmediate,process.nextTick等等。只是知道用法,但还是不能理解一个单线程运行时是如何处理异步处理的,看了朴灵老师的深入浅出node,也只是稍微有了一点感觉。

于是乎,在年末闲来无事之余,找了找相关资料。当然也是前人栽树,后人乘凉。自己做的事情,无外乎是收集收集、整理整理、再让自己想明白梳理清晰罢liao...

以下都是个人理解、说错不喜勿喷。JJ短、长得又丑受不了刺激。

先来看个问题

setTimeout(()=>{
    console.log('timer1')

    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)

setTimeout(()=>{
    console.log('timer2')

    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)
 

请问在浏览器和node中的结果一样么?
浏览器的结果

timer1
promise1
timer2
promise2
 

node环境结果

timer1
timer2
promise1
promise2
 

我们再来想一想单线程是指js引擎中负责解析执行js代码的线程只有一个主线程,即一次只做一件事,而我们知道一个ajax请求,主线程在等待响应的同时是会去做其他事的。

Event Loop

创造代码的人也是人,他们的灵感多数来自于生活。我们这里打个比方(朴灵老师也这样比喻),javascript处理异步就像去餐馆吃饭,服务员会先为顾客下单,下完单把顾客点的单子给后厨让其准备,然后就去服务下一位顾客,,而不是一直等待在出餐口。
javascript将顾客下单的行为进行了细分。无外乎两种酒水类和非酒水类。对应着我们javascript中的macroTask和microTask。
具体如下划分:

 macrotasks: script(整体代码),setTimeout,setInterval,setImmediate,I/O,UI render
 microtasks: process.nextTick, Promises, Object.observe(废弃),MutationObserver
 

但是在不同场景下的步骤是不一样的,就像西餐和中餐。西餐划分的非常详细:头盘->汤->副菜->主菜->蔬菜类菜肴->甜品->咖啡,中餐就相对简单许多:凉菜->热菜->汤。
在不同场景的实现不同,HTML标准和NODE标准的差异,正如第一个例子一样。

浏览器中

image.png

为了更好地说明浏览器是如何处理event loop看代码

console.log('start')

setTimeout(function() {
  console.log('setTimeout')
}, 0)

Promise.resolve().then(function() {
  console.log('promise1')
}).then(function() {
  console.log('promise2')
})

console.log('end')
 

他是怎么工作的呢?一张图搞定:

15987855-7f8b431396409cd6.gif
结合我们之前发的web_process图片是不是豁然开朗。

小知识点:视图渲染的时机

一次macrotasks + microtasks 称为一次ticket,ticket结束之后会触发浏览器的重绘操作(不一定每次都执行)

换言之,执行任务的耗时会影响视图渲染的时机,通常浏览器以每秒60帧(60fps)的速率刷新页面,据说这个帧率最适合人眼交互,大概16.7ms渲染一帧,所以如果要让用户觉得顺畅,单个macrotask及它相关的所有microtask最好能在16.7ms内完成。

但也不是每轮事件循环都会执行视图更新,浏览器有自己的优化策略,例如把几次的视图更新累积到一起重绘,重绘之前会通知requestAnimationFrame执行回调函数,也就是说requestAnimationFrame回调的执行时机是在一次或多次事件循环的UI render阶段

可以自己来尝试一下:

setTimeout(function() {console.log('timer1')}, 0)

requestAnimationFrame(function(){
    console.log('requestAnimationFrame')
})

setTimeout(function() {console.log('timer2')}, 0)

new Promise(function executor(resolve) {
    console.log('promise 1')
    resolve()
    console.log('promise 2')
}).then(function() {
    console.log('promise then')
})

console.log('end')
 

小小总结一下:

  1. 事件循环是js实现异步的核心

  2. 每轮事件循环分为3个步骤:

  • a) 执行macrotask队列的一个任务
  • b) 执行完成当前microtask队列的所有任务
  • c) UI render
  1. 浏览器只保证requestAnimationFrame的重绘在重回之前执行,没有确定的时间,何时重绘由浏览器决定!

Node环境

我们再回头看看 刚开始的Demo1

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
 

是不是你的思路跟下图一致呢?

15987855-0f2c2f288d3b56ea.gif

我们再来看看node中的输出

timer1
timer2
promise1
promise2
 

node用的是chrome的v8解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。
核心源码参考:/deps/uv/src/unix/core.c
有兴趣的同学可以自己撸一撸。
这里有一篇文章
juejin.cn/post/694570…

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);
    uv__run_timers(loop);
    ran_pending = uv__run_pending(loop);
    uv__run_idle(loop);
    uv__run_prepare(loop);

    timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);

    uv__io_poll(loop, timeout);
    uv__run_check(loop);
    uv__run_closing_handles(loop);

    if (mode == UV_RUN_ONCE) {
      /* UV_RUN_ONCE implies forward progress: at least one callback must have
       * been invoked when it returns. uv__io_poll() can return without doing
       * I/O (meaning: no callbacks) when its timeout expires - which means we
       * have pending timers that satisfy the forward progress constraint.
       *
       * UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
       * the check.
       */
      uv__update_time(loop);
      uv__run_timers(loop);
    }

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

  /* The if statement lets gcc compile it to a conditional store. Avoids
   * dirtying a cache line.
   */
  if (loop->stop_flag != 0)
    loop->stop_flag = 0;

  return r;
}
 

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

image.png

  1. timers 阶段:这个阶段执行timer(setTimeout、setInterval)的回调
  2. I/O callbacks 阶段:执行一些系统调用错误,比如网络通信的错误回调
  3. idle, prepare 阶段:仅node内部使用
  4. poll 阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里
  5. check 阶段:执行 setImmediate() 的回调
  6. 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阶段。
当所有阶段被顺序执行一次后,称 event loop 完成了一个 tick。
试一试:

const fs = require('fs')

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

用一张图来说明:

image.png

再来看看开头的例子在node中是如何运行的:

15987855-6a8227d853613915.gif

扩充问题:

node里面经常会用到的两个函数,效率要比setTimeout高,setTimeout的实现是靠红黑树,时间复杂度为logn

process.nextTick() VS setImmediate()

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

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()
 

运行结果:

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
 

参考:

  • jakearchibald.com/2015/tasks-…
  • www.cnblogs.com/yzfdjzwl/p/…
  • lynnelv.github.io/js-event-lo…
  • lynnelv.github.io/js-event-lo…
  • juejin.cn/post/694570…

回复

我来回复
  • 暂无回复内容