Javascript 异步编程(二)Event Loop

Event Loop

const test=()=>{
  console.log(1)
  setTimeout(()=>{
    console.log(2)
  },0)
  Promise.resolve().then(()=>{
    console.log(3)
  })
  console.log(4)
}
test();
//1
//4
//3
//2

可以看出:

  1. Promise和setTimeout都是是异步
  2. Promise优先级高于setTimeout

为什么呢~~ 我们先来熟悉下基本概念
image_1.png

执行上下文 (execution context)

当一个函数被调用时,会创建一个活动记录(执行上下文),这个记录会包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息。

以下三种情况会分别创建上下文

  • 全局执行上下文:是为运行代码主体而创建的执行上下文,也就是说它是为那些存在于JavaScript 函数之外的任何代码而创建的。
  • 函数执行上下文:每个函数会在执行的时候创建自己的执行上下文。这个上下文就是通常说的 “本地上下文”。
  • 使用 eval() 函数

执行栈(call stack)

执行栈(也称为调用栈),是解释器(比如浏览器中的 JavaScript 解释器)追踪函数执行流的一种机制。当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到哪个函数正在执行,执行的函数体中又调用了哪个函数。
  • 每调用一个函数,解释器就会把该函数添加进调用栈并开始执行。
  • 正在调用栈中执行的函数还调用了其它函数,那么新函数也将会被添加进调用栈,一旦这个函数被调用,便会立即执行。
  • 当前函数执行完毕后,解释器将其清出调用栈,继续执行当前执行环境下的剩余的代码。
  • 当分配的调用栈空间被占满时,会引发“堆栈溢出”错误(如递归使用不当)RangeError:Maximum call stack size exceeded

分析以下程序:

let a = 'Hello World!';
function first() {  
  console.log('Inside first function');  
  second();  
  console.log('Again inside first function');  
}
function second() {  
  console.log('Inside second function');  
}
first();  
console.log('Inside Global Execution Context');

当上述代码执行时,Javascript引擎会创建 执行上下文栈,每个代码段开始执行的时候都会创建一个新的上下文来运行它,每个上下文创建时都会push到栈中,在代码退出的时候从上下文栈中pop,完成销毁。

其大致流程:

  1. 程序开始运行,创建全局执行上下文并压入执行栈中
  2. 当执行到first()时,会为该函数创建函数执行上下文,并push到栈中
  3. first()调用second(),创建一个新的函数执行上下文,并push到栈中
  4. second()执行完毕,将其上下文从栈中弹出并销毁,同时从栈中取栈顶的上下文并恢复执行,也就是执行程序剩余部分
  5. first()执行完毕后,其上下文中栈中弹出并销毁
  6. 程序结束,全局执行上下文从执行栈中弹出并销毁

通过执行栈机制,每个程序和函数都有自己对应的上下文,每一个上下文中都能够跟踪程序中的下一行需要执行的代码以及相应的上下文信息,方便我们调试程序,追踪异常。

众所周知,Javascript是单线程,也就意味着一个call stack,同一时间只执行一件事。
而我们在同步执行一些耗时代码片段时,会阻塞(block)当前的线程,在这个过程中,CPU是闲置的,为了充分利用资源,避免竞态条件出现,这一类任务被设计成允许暂时挂起,等到有了结果再执行的任务,从而引入异步。

任务队列(task queue)

在执行栈中,如果遇到异步函数,如setTimeout() 会交给指定模块处理,然后继续执行同步代码。而当异步函数达到触发条件时,会根据函数类型,压入指定的任务队列。
有两类任务队列:宏任务队列(macro tasks)和微任务队列(micro tasks)。宏任务队列可以有多个,微任务队列只有一个。

返回刚才的例子,当函数test()调用,执行到setTimeout()Promise()时,会将其放入任务队列,继续执行函数的剩余部分知道出栈销毁其上下文。当stack空时,从任务队列中取任务来执行,但是因浏览器和Node的event loop机制不同,所以分别对其分析。

image_6.png

浏览器环境中的Event Loop

基于HTML5标准中的Event Loop,其Event Loop 中的异步任务分到两个队列中。

macrotask queue 即我们所说的任务队列。一个事件循环中有一个或多个任务队列,其实并不是 queue,而是set,因为事件循环处理模型的第一步从选定的队列中获取第一个可运行任务,而不是使第一个任务出队。

microtask queue 并不真正的任务队列。一个事件循环中只有一个microtask queue

一个任务可以被放入到macrotask队列,也可以放入microtask队列

回到最开始的例子test(),其执行过程:

  1. 程序开始,创建全局执行上下文,压入栈中。
  2. 开始调用test(),创建其函数执行上下文,压入栈中
  3. 执行到setTimeout(cb)时,创建其上下文,并压入栈中,因为不是同步函数,交由webapi进行处理并出栈销毁其上下文(在0s后,压入macrotask queue),继续执行下一个代码片段。
  4. 执行到Promise(cb)时,创建其上下文,并压入栈中,因为不是同步函数,交由webapi进行处理并出栈销毁其上下文(并压入microtask queue),继续执行下一个代码片段。
  5. 函数test()执行完毕,弹出其上下文,并销毁
  6. 程序执行完毕,销毁全局执行上下文
  7. microtask queue中取出Promise任务,执行其回调函数cb
  8. 再从macrotask queue中取出setTimeout任务,执行其回调函数cb

那么加入UI Rendering呢?

let test2=()=>{
  console.log('process start');
  setTimeout(()=>{
    console.log('processing setTimeout')
  },100)
  let dom=document.getElementById('app')
  dom.style.backgroundColor="red"
  Promise.resolve().then(()=>{
    console.log("processing promise")
  })
  console.log('process end')
}
test2();

作为脚本的一部分,程序必须执行完成,浏览器才会执行渲染。

Event Loop可以保证任务在下一次渲染前执行完成。

我们可以使用requestAnimationFrame (RAF回调) ,会以16.6ms的频率执行

setTimeout实际会多出4ms左右的延时

那么完整的Event Loop 流程如下:

  1. 从宏任务队列出列并执行最前面的任务(比如“script”)。
  2. 调用栈为空,检查microtask queue
  3. 执行microtask队列,按照队列 先进先出 的原则,执行完所有microtask队列任务;
  4. 有需要执行渲染(在一帧以内的多次Dom变动浏览器不会立即响应,而是会积攒变动以最高60HZ的频率更新视图)。
  5. 执行任务队列,如果任务队列不为空,取出任务队列中第一个可运行的宏任务,执行完毕后,检查microtask queue;而如果任务队列为空,则直接检查microtask queue

注意事项:

  1. microtask ,一直执行,直到队列为空,但是如果过程中有新的任务加进来,且添加的速度比执行快,那么就会永远执行微任务,从而导致阻塞Event Loop。
  2. macrotask ,每次执行一个任务,如果有新的任务,就添加到队列尾部。
  3. animation cb,一直执行,直到队列中的所有任务完成,如果动画回调中又有动画回调,它们会在下一帧执行
  4. new Promise 构造函数内部是同步执行

总结:

  1. 调用栈清空时会立刻先处理所有微任务队列中的事件,然后再去宏任务队列中取出一个事件。同一次事件循环中,微任务永远在宏任务之前执行。
  2. 本质上来说 在一个事件循环中,Microtask的执行方式基本上就是用同步的
  3. 当引擎处理任务时不会执行渲染。如果执行需要很长一段时间也是如此。对于 DOM 的修改只有当任务执行完成才会被绘制

宏任务和微任务的区别

宏任务:由事件回调、程序启动或触发间隔运行的任何JavaScript代码,包括解析HTML,生成DOM,执行主线程JS代码以及其他事件,例如页面加载,输入,网络事件,计时器事件等。从浏览器的角度来看,Macrotasks代表了一些离散且独立的工作。
微任务:只是一个简短的函数(因此得名),它在创建函数退出后执行,是完成一些次要任务来更新应用程序状态,例如处理Promise的回调和DOM修改,以便可以在重新渲染浏览器之前执行这些任务。微任务应尽快异步执行,因此其成本低于Macrotask,它可使我们在UI呈现之前再次执行,从而避免不必要的UI呈现。

问题

// 同步
[1,2,3,4].forEach((i)=>{
  console.log(i);
})
// 异步
function asyncForEach(arr,cb){
  arr.forEach((i)=>{
    setTimeout(()=>{
      cb(i)
    },0)
  })
}
asyncForEach(['a','b','c','d'],(i)=>{
  console.log(i)
})

microtask的执行机制并不是稳定的,实际上是因调用栈的情况而有所不同

btn.addEventListener('click',()=>{
  Promise.resolve().then(()=>console.log('microtask 1'))
  console.log('Listener 1')
})

btn.addEventListener('click',()=>{
  Promise.resolve().then(()=>console.log('microtask 2'))
  console.log('Listener 2');
})

// Listener 1 
// microtask 1
// Listener 2
// Listener 2 
  1. 点击时,触发第一个cb,将Promise访入microtask
  2. 执行console.log('Listener 1')
  3. 此时调用栈为空,执行第一个microtask,即console.log('microtask 1')
  4. 同样的,触发第二个cb。。。。

但是如果是js 触发的click()

btn.addEventListener('click',()=>{
  Promise.resolve().then(()=>console.log('microtask 1'))
  console.log('Listener 1')
})

btn.addEventListener('click',()=>{
  Promise.resolve().then(()=>console.log('microtask 2'))
  console.log('Listener 2');
})
btn.click(); 
//?
  1. 首先栈中执行click()
  2. 事件调度,将执行第一个cb,将Promise访入microtask,执行console.log('Listener 1'),销毁监听器
  3. 时间调度,执行第二个cb,并将Promise访入microtask,执行console.log('Listener 2'),销毁监听器
  4. 调用栈中弹出click上下文并销毁
  5. 开始执行microtask,首先是console.log('microtask 1'),接着是console.log('microtask 2')

现实场景中,如果我们在执行自动化测试时,通过脚本控制执行事件,就可能会导致结果的差异。

保证条件性使用 promises 时的顺序

https://developer.mozilla.org...

Node.js的Event Loop

NodeJs是以非阻塞的I/O单线程,实现主要依赖于[libuv](http://docs.libuv.org/en/v1.x/design.html)

libuv,由C语言编写的事件驱动库,是NodeJs异步编程的基础,属于底层I/O引擎。主要负责Node API的执行,将不同的任务分配给不同的线程,从而形成了Node Event Loop,以异步的方式将执行结果返回给V8引擎

image_3.png
NodeJs Event Loop 的运行是这样的:

  1. timers:执行timer(setTimeout/setInterval)回调
  2. pending callbacks:执行系统操作的回调--内部
  3. idle, prepare:执行空闲/准备句柄回调-内部使用
  4. poll:等待新I/O事件
  5. check:执行setImmediate回调
  6. close callbacks :关闭回调--内部执行
  • 每个阶段都会有一个callbacks的先进先出的队列执行。
  • 当event loop 运行到一个指定阶段时,该阶段的fifo队列将被执行,当队列执行完或执行的callbacks数量超过该阶段的上线时,转入下一阶段
   ┌───────────────────────────┐
┌─>│           timers         │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks     │
   └───────────────────────────┘

Node.js的Event Loop过程

  1. 执行全局Script的同步任务
  2. 执行microtask微任务,先执行所有Next Tick Queue(process.nextTick)中的所有任务,再执行 Other Microtask queue中的所有任务
  3. 开始执行macrotask宏任务,共6个阶段,从第一个阶段开始执行对应阶段的macrotask queue中的所有任务
  4. Timer Queue->步骤2->I/O Queue ->步骤2->Check Queue->Close Callback Queue...

Poll阶段细节

Poll阶段的两个主要的功能:

  1. 计算应该被block多久
  2. 处理poll队列的事件

主要流程:

  1. 检测poll队列如果为空/达到阈值,继续第2步;否则执行第3步
  2. 如果设置了setImmediate(),进入check阶段,否则等待回调添加到队列,立即执行
  3. 同步执行poll队列中的回调
  4. 如果poll队列为空,检查是否有到达时间的timer,有则执行timers回调;否则继续等在callback加入poll队列

练习

Promise.resolve(123).then((res)=>console.log(res))
process.nextTick(()=>console.log(456))

解释:

这里process.nextTick()是一个异步的node Api,但不属于event loop的阶段,调用时会中断event loop优先执行process.nextTick的回调。

setTimeout(()=>console.log('setTimeout'),0)
setImmediate(()=>console.log('setImmediate'))

解释:

setImmediate()用于中断长时间运行的操作,并在完成其他操作后立即执行其回调。
setImmediate()setTimeout()执行顺序不固定,取决于node的准备时间。
setTimeout()setInterval()的第二个参数的取值范围是[1,2^32-1],如果超过这个范围,初始化为1,即setTimeout(fn,0)===setTimeout(fn,1)
我们知道setTimeout的回调函数在timer阶段执行,setImmediate的回调函数在check阶段执行,event loop 的开始会检查timer阶段,但是在开始之前timer阶段会消耗一定的时间;
  1. timer前的准备时间超过1ms,满足loop->time>=1,则执行timer阶段(setTimeout)的回调函数
  2. timer前的准备时间小于1ms,则先自行check阶段(setImmediate)的回调函数,下一次event loop再次开始执行timer阶段(setTimeout)的回调函数

如果我们想确保先执行setTimeout的回调

setTimeout(()=>console.log('setTimeout'),0)
setImmediate(()=>console.log('setImmediate'))
const start=new Date()
//睡眠10ms
while (Date.now()-start<10);

那如果我们想先执行setImmediate的回调呢?从正常的event loop开始一定是先执行timer的回调,如果我们可以在pending callbacks->idea/prepare->pool这个阶段内开始触发setTimeout,那么就可以先执行setImmediate的回调了。

const fs=require('fs')
fs.readFile(__dirname,()=>{
  setTimeout(()=>console.log('setTimeout'),0);
  setImmediate(()=>console.log('setImmediate'));
})

上段代码中,我们将event loop 的起始阶段放在了 poll阶段,等待i/o,然后就会先执行setImmediate的回调。

浏览器与Node区别

  1. 浏览器端,一次执行一个宏任务,两个宏任务间隔内执行微任务队列中的所有微任务
  2. Node端,一个阶段执行当前宏任务队列中的所有宏任务,每个阶段间隔轮询微任务队列中的微任务

NodeV11发生了变化

setTimeout(() => console.log('timeout1'));
setTimeout(() => {
    console.log('timeout2')
    Promise.resolve().then(() => console.log('promise resolve'))
});
setTimeout(() => console.log('timeout3'));
setTimeout(() => console.log('timeout4'));

请分别在浏览器、Node V10,Node V11 运行以上代码

MacroTask and MicroTask execution order

待学习

从event loop规范探究javaScript异步及浏览器更新渲染时机
【转向Javascript系列】深入理解Web Worker
Web Worker浅识
In depth: Microtasks and the JavaScript runtime environment

问题

image_4.png
Node.js v10.15.3版本
https://github.com/nodejs/nod...

References

tasks, microtasks, queues and schedules

阅读 281

推荐阅读