JavaScript的事件循环

Node.js 事件循环一: 浅析

理解事件循环系列第一步 浅析和总览

多数的网站不需要大量计算,程序花费的时间主要集中在磁盘 I/O 和网络 I/O 上面

SSD读取很快,但和CPU处理指令的速度比起来也不在一个数量级上,而且网络上一个数据包来回的时间更慢:

一个数据包来回的延迟平均320ms(我网速慢,ping国内网站会更快),这段时间内一个普通 cpu 执行几千万个周期应该没问题

因此异步IO就要发挥作用了,比如用多线程,如果用 Java 去读一个文件,这是一个阻塞的操作,在等待数据返回的过程中什么也干不了,因此就开一个新的线程来处理文件读取,读取操作结束后再去通知主线程。

这样虽然行得通,但是代码写起来比较麻烦。像 Node.js V8 这种无法开一个线程的怎么办?

先看下面函数执行过程

栈 Stack

当我们调用一个函数,它的地址、参数、局部变量都会压入到一个 stack 中

function fire() {
    const result = sumSqrt(3, 4)
    console.log(result);
}
function sumSqrt(x, y) {
    const s1 = square(x)
    const s2 = square(y)
    const sum = s1 + s2;
    return Math.sqrt(sum)
}
function square(x) {
    return x * x;
}

fire()

下面的图都是用 keynote 做的 keynote地址

函数 fire 首先被调用

fire 调用 sumSqrt 函数 参数为3和4

之后调用 square 参数为 x, x==3

square 执行结束返回时,从 stack 中弹出,并将返回值赋值给 s1
s1加入到 sumSqrt 的 stack frame 中

以同样的方式调用下一个 square 函数

在下一行的表达式中计算出 s1+s2 并赋值给 sum

之后调用 Math.sqrt 参数为sum

现在就剩下 sumSqrt 函数返回计算结果了

返回值赋值给 result

在 console 中打印出 result

最终 fire 没有任何返回值 从stack中弹出 stack也清空了

当函数执行完毕后本地变量会从 stack 中弹出,这只有在使用 numbers string boolean 这种基本数据类型时才会发生。而对象、数组的值是存在于 heap(堆) 中的,stack 只存放了他们对应的指针。

当函数之行结束从 stack 中弹出来时,只有对象的指针被弹出,而真正的值依然存在 heap 中,然后由垃圾回收器自动的清理回收。

事件循环

通过一个例子来了解函数的执行顺序

'use strict'

const express = require('express')
const superagent = require('superagent')
const app = express()

app.get('/', getArticle)

function getArticle(req, res) {
    fetchArticle(req, res)
    print()
}

const aids = [4564824, 4506868, 4767667, 4856099, 7456996];

function fetchArticle(req, res) {
    const aid = aids[Math.floor(Math.random() * aids.length)]
    superagent.get(`http://news-at.zhihu.com/api/4/news/${aid}`)
        .end((err, res) => {
            if(err) {
                console.log('error ......');
                return res.status(500).send('an error ......')
            }
            const article = res.body
            res.send(article)
            console.log('Got an article')
        })

    console.log('Now is fetching an article')
}

function print(){
    console.log('Print something')
}


app.listen('5000')

请求 http://localhost:5000/ 后打印出

Now is fetching an article

Print something

Got an article

虽然 V8 是单线程的,但底层的 C++ API 却不是。这意味着当我们执行一些非阻塞的操作,Node会调用一些代码,与引擎里的js代码同时执行。一旦这个隐藏的线程收到了等待的返回值或者抛出一个异常,之前提供的回调函数就会执行。

上面的说的Node调用的一些代码其实就是 libuv,一个开源的跨平台的异步 I/O 。最初就是为 Node.js 开发的,现在很多项目都在用

任务队列

javascript 是单线程事件驱动的语言,那我们可以给时间添加监听器,当事件触发时,监听器就能执行回调函数。

当我们去调用 setTimeout`http.getfs.readFile`, Node.js 会把这些定时器、http、IO操作发送给另一个线程以保证V8继续执行我们的代码。

然而我们只有一个主线程和一个 call-stack ,这样当一个读取文件的操作还在执行时,有一个网络请求request过来,那这时他的回调就需要等stack变空才能执行。

回调函数正在等待轮到自己执行所排的队就被称为任务队列(或者事件队列、消息队列)。每当主线程完成前一个任务,回调函数就会在一个无限循环圈里被调用,因此这个圈被称为事件循环。

我们前面那个获取文章的例子的执行顺序就会如下:

  1. express 给 request 事件注册了一个 handler,并且当请求到达路径 ‘/‘ 时来触发handler
  2. 调过各个函数并且在端口 5000 上启动监听
  3. stack 为空,等待 request 事件触发
  4. 根据传入的请求,事件触发,express 调用之前提供的函数 getArticle
  5. getArticle 压入(push) stack
  6. fetchArticle 被调用 同时压入 stack
  7. Math.floorMath.random 被调用压入 stack 然后再 弹出(pop), 从 aids 里面取出的一个值被赋值给变量 aid
  8. superagent.get 被执行,参数为 'http://news-at.zhihu.com/api/4/news/${aid}' ,并且回调函数注册给了 end 事件
  9. http://news-at.zhihu.com/api/4/news/${aid} 的HTTP请求被发送到后台线程,然后函数继续往下执行
  10. 'Now is fetching an article' 打印在 console 中。 函数 fetchArticle 返回
  11. print 函数被调用, 'Print something' 打印在 console 中
  12. 函数 getArticle 返回,并从 stack 中弹出, stack 为空
  13. 等待 http://news-at.zhihu.com/api/4/news/${aid} 发送相应信息
  14. 响应信息到达,end 事件被触发
  15. 注册给 end 事件的匿名回调函数被执行,这个匿名函数和他闭包中的所有变量压入 stack,这意味着这个匿名函数可以访问并修改 express, superagent, app, aids, req, res, aid 的值以及之前所有已经定义的函数
  16. 函数 res.send() 伴随着 200 或 500 的状态码被执行,但同时又被放入到后台线程中,因此 响应流 不会阻塞我们函数的执行。匿名函数也被 pop 出 stack。

Microtasks Macrotasks

任务队列不止一个,还有 microtasks 和 macrotasks

microtasks:

  • process.nextTick
  • promise
  • Object.observe

macrotasks:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O

这两个的详细区别下一篇再写,先看一段代码

console.log('start')

const interval = setInterval(() => {  
  console.log('setInterval')
}, 0)

setTimeout(() => {  
  console.log('setTimeout 1')
  Promise.resolve()
      .then(() => {
        console.log('promise 3')
      })
      .then(() => {
        console.log('promise 4')
      })
      .then(() => {
        setTimeout(() => {
          console.log('setTimeout 2')
          Promise.resolve()
              .then(() => {
                console.log('promise 5')
              })
              .then(() => {
                console.log('promise 6')
              })
              .then(() => {
                clearInterval(interval)
              })
        }, 0)
      })
}, 0)

Promise.resolve()
    .then(() => {  
        console.log('promise 1')
    })
    .then(() => {
        console.log('promise 2')
    })

理解了node的事件循环还是比较容易得出答案的:

start
promise 1
promise 2
setInterval
setTimeout 1
promise 3
promise 4
setInterval
setTimeout 2
promise 5
promise 6

根据 WHATVG 的说明,在一个事件循环的周期(cycle)中一个 (macro)task 应该从 macrotask 队列开始执行。当这个 macrotask 结束后,所有的 microtasks 将在同一个 cycle 中执行。在 microtasks 执行时还可以加入更多的 microtask,然后一个一个的执行,直到 microtask 队列清空。

规范理解起来有点晦涩,来看下上面的例子

Cycle 1

1)setInterval 被列为 task

2)setTimeout 1 被列为 task

3)Promise.resolve 1 中两个 then 被列为 microtask

4) stack 清空 microtasks 执行

任务队列: setInterval`setTimeout 1`

Cycle 2

5) microtasks 队列清空 setInteval 的回调可以执行。另一个 setInterval 被列为 task , 位于 setTimeout 1后面

任务队列: setTimeout 1`setInterval`

Cycle 3

6) microtask 队列清空,setTimeout 1 的回调可以执行,promise 3promise 4 被列为 microtasks

7)promise 3promise 4 执行。 setTimeout 2 被列为 task

任务队列 setInterval`setTimeout 2`

Cycle 4

8) microtask 队列清空 setInteval 的回调可以执行。然后另一个 setInterval 被列为 task ,位于 setTimeout 2 后面

任务队列: setTimeout 2`setInterval`

9)setTimeout 2 的回调执行, promise 5promise 6 被列为 microtasks

现在 promise 5promise 6 的回调应该执行,并且 clear 掉 interval。 但有的时候不知道为什么 setInterval 还会在执行一遍,变成下面结果

...
setTimeout 2
setInterval
promise 5
promise 6

但是把上面的代码放入 chrome console 中执行却没有问题。这一点还要再根据不同的 node版本 查一下。

关于 macrotask 和 microtask

用例子简单理解了下 macrotask 和 microtask

这里再详细的总结下两者的区别和使用

简介

一个事件循环(EventLoop)中会有一个正在执行的任务(Task),而这个任务就是从 macrotask 队列中来的。在whatwg规范中有 queue 就是任务队列。当这个 macrotask 执行结束后所有可用的 microtask 将会在同一个事件循环中执行,当这些 microtask 执行结束后还能继续添加 microtask 一直到真个 microtask 队列执行结束。

怎么用

基本来说,当我们想以同步的方式来处理异步任务时候就用 microtask(比如我们需要直接在某段代码后就去执行某个任务,就像Promise一样)。

其他情况就直接用 macrotask。

两者的具体实现

  • macrotasks: setTimeout setInterval setImmediate I/O UI渲染
  • microtasks: Promise process.nextTick Object.observe MutationObserver

从规范中理解

whatwg规范:https://html.spec.whatwg.org/multipage/webappapis.html#task-queue

  • 一个事件循环(event loop)会有一个或多个任务队列(task queue) task queue 就是 macrotask queue
  • 每一个 event loop 都有一个 microtask queue
  • task queue == macrotask queue != microtask queue
  • 一个任务 task 可以放入 macrotask queue 也可以放入 microtask queue 中
  • 当一个 task 被放入队列 queue(macro或micro) 那这个 task 就可以被立即执行了

再来回顾下事件循环如何执行一个任务的流程

当执行栈(call stack)为空的时候,开始依次执行:

  1. 把最早的任务(task A)放入任务队列
  2. 如果 task A 为null (那任务队列就是空),直接跳到第6步
  3. 将 currently running task 设置为 task A
  4. 执行 task A (也就是执行回调函数)
  5. 将 currently running task 设置为 null 并移出 task A
  6. 执行 microtask 队列
  • a: 在 microtask 中选出最早的任务 task X
  • b: 如果 task X 为null (那 microtask 队列就是空),直接跳到 g
  • c: 将 currently running task 设置为 task X
  • d: 执行 task X
  • e: 将 currently running task 设置为 null 并移出 task X
  • f: 在 microtask 中选出最早的任务 , 跳到 b
  • g: 结束 microtask 队列
  1. 跳到第一步

上面就算是一个简单的 event-loop 执行模型

再简单点可以总结为:

  1. 在 macrotask 队列中执行最早的那个 task ,然后移出
  2. 执行 microtask 队列中所有可用的任务,然后移出
  3. 下一个循环,执行下一个 macrotask 中的任务 (再跳到第2步)

其他

  • 当一个task(在 macrotask 队列中)正处于执行状态,也可能会有新的事件被注册,那就会有新的 task 被创建。比如下面两个
  1. promiseA.then() 的回调就是一个 task
  • promiseA 是 resolved或rejected: 那这个 task 就会放入当前事件循环回合的 microtask queue
  • promiseA 是 pending: 这个 task 就会放入 事件循环的未来的某个(可能下一个)回合的 microtask queue 中
  • setTimeout 的回调也是个 task ,它会被放入 macrotask queue 即使是 0ms 的情况

  • microtask queue 中的 task 会在事件循环的当前回合中执行,因此 macrotask queue 中的 task 就只能等到事件循环的下一个回合中执行了

  • click ajax setTimeout 的回调是都是 task, 同时,包裹在一个 script 标签中的js代码也是一个 task 确切说是 macrotask。

两者的具体实现

  • macrotasks: setTimeout ,setInterval, setImmediate, I/O ,UI渲染,requestAnimationFrame
  • microtasks: Promise, process.nextTick, Object.observe, MutationObserver

再简单点可以总结为:

  1. 在 macrotask 队列中执行最早的那个 task ,然后移出
  2. 再执行 microtask 队列中所有可用的任务,然后移出
  3. 下一个循环,执行下一个 macrotask 中的任务 (再跳到第2步)

这里解释一下,一共有Promise、MutationObserver以及setTimeout三种尝试得到timerFunc的方法。
优先使用Promise,在Promise不存在的情况下使用MutationObserver,这两个方法的回调函数都会在microtask中执行,它们会比setTimeout更早执行,所以优先使用。
如果上述两种方法都不支持的环境则会使用setTimeout,在task尾部推入这个函数,等待调用执行。

为什么要优先使用microtask?我在顾轶灵在知乎的回答中学习到:

JS 的 event loop 执行时会区分 task 和 microtask,引擎在每个 task 执行完毕,从队列中取下一个 task 来执行之前,会先执行完所有 microtask 队列中的 microtask。
setTimeout 回调会被分配到一个新的 task 中执行,而 Promise 的 resolver、 MutationObserver 的回调都会被安排到一个新的 microtask 中执行,会比 setTimeout 产生的 task 先执行。
要创建一个新的 microtask,优先使用 Promise,如果浏览器不支持,再尝试 MutationObserver。
实在不行,只能用 setTimeout 创建 task 了。
为啥要用 microtask?
根据 HTML Standard,在每个 task 运行完以后,UI 都会重渲染,那么在 microtask 中就完成数据更新,当前 task 结束就可以得到最新的 UI 了。
反之如果新建一个 task 来做数据更新,那么渲染就会进行两次。

首先是Promise,(Promise.resolve()).then()可以在microtask中加入它的回调,

MutationObserver新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入microtask,即textNode.data = String(counter)时便会加入该回调。

setTimeout是最后的一种备选方案,并且默认有4ms延时,setTimeout延时0不会老老实实立即执行:

setTimeout(function(){
    console.log("我不是立即执行的,一般我会延时4ms,哈哈");
},0);

它会将回调函数加入task中,等到执行。

setTimeout(function(){console.log(4)},0);
new Promise(function(resolve){
    console.log(1)
    for( var i=0 ; i<10000 ; i++ ){
        i==9999 && resolve()
    }
    console.log(2)
}).then(function(){
    console.log(5)
});
console.log(3);
结果是:
1,2,3,5,4

再看这个,两个自执行同时执行:

<script>
(function test() {
  setTimeout(function () {
    console.log(4)
  }, 0);
  new Promise(function executor (resolve) {
    console.log(1);
    for(var i = 0; i < 10000; i++) {
      i == 9999 && resolve();
    }
    console.log(2);
  }).then(function() {
    console.log(5);
  });
  console.log(3);
})()

(function test2() {
  setTimeout(function () {
    console.log(42)
  }, 0);
  new Promise(function executor (resolve) {
    console.log(12);
    for(var i = 0; i < 10000; i++) {
      i == 9999 && resolve();
    }
    console.log(22);
  }).then(function() {
    console.log(52);
  });
  console.log(32);
})()
</script>


整个执行过程是一个 main thread 【主线程】 ,但并不意味着先执行第一个自执行后再执行第二个,因为两个自执行中的setTimeout进入的是同一个事件循环中等待,因此他俩在最后分别输出了了 4 和 42。

当一个程序有:setTimeout, setInterval ,setImmediate, I/O, UI渲染,Promise ,process.nextTick, Object.observe, MutationObserver的时候:

1.先执行 macrotasks:I/O -》 UI渲染

2.再执行 microtasks :process.nextTick -》 Promise -》MutationObserver ->Object.observe

3.再把setTimeout setInterval setImmediate 塞入一个新的macrotasks,依次:

setTimeout ,setInterval –》setImmediate

综上,nextTick的目的就是产生一个回调函数加入task或者microtask中,当前栈执行完以后(可能中间还有别的排在前面的函数)调用该回调函数,起到了异步触发(即下一个tick时触发)的目的。

setImmediate(function(){
    console.log(1);
},0);
setTimeout(function(){
    console.log(2);
},0);
new Promise(function(resolve){
    console.log(3);
    resolve();
    console.log(4);
}).then(function(){
    console.log(5);
});
console.log(6);
process.nextTick(function(){
    console.log(7);
});
console.log(8);
结果是:3 4 6 8 7 5 2 1