宿主环境中的Event Loop

253 阅读7分钟

在介绍 Event Loop之前,首先看下 JS引擎 和 渲染引擎。

JS引擎

image.png

  1. parser:分析器。负责把JavaScript源码转成AST(抽象语法树)
  2. interpreter:解释器。负责把AST转成字节码,并解释执行。
  3. JIT compiler:对执行时的热点函数进行编译,将字节码转成机器码,之后可以直接执行机器码,提高效率。
  4. gc:垃圾回收器,清理堆内存中不再使用的对象。

渲染引擎

每一次渲染流程叫做一帧,浏览器会有一个帧率(比如一秒60帧)来刷新。

image.png

Event Loop

首先我们应该知道,一般是在主线程中更新UI。

为什么要在主线程,或者为什么不能在子线程中更新UI?

因为在多个线程中操作一个UI时,很容易导致反向加锁和死锁的问题。

同样的道理在几乎所有编程领域中都是这样的,这背后是线程同步的开销问题。

通俗的讲,两个线程不能同时draw,否则屏幕会花;不能同时insert map,否则内存会花;

所以需要互斥,比如锁。结果就是同一时刻只有一个线程可以做UI。

顺着这个思路,我们联想下客户端UI架构和微信小程序的双线程架构,应该立马秒懂。

在主进程完成UI的更新、事件的绑定,其他逻辑可以放到别的线程中完成。

然后完成以后在消息队列中放一个消息,主线程不断循环地取消息来执行。

JavaScript不同于以上的多线程架构,它是单线程的,为何呢?

因为JS最开始只是被设计用来做表单处理,不会有特别大的计算量,故没采用多线程架构,

而是在一个线程内进行DOM操作和逻辑计算,这导致了渲染引擎和JS引擎执行时相互阻塞。

JS引擎只知道执行JS,渲染引擎只知道渲染,

它们两个并不知道彼此,而Event Loop就是连接的桥梁。

JS引擎并不提供Event Loop(可能很多同学以为Event Loop是JS引擎提供的,其实不是),

它是宿主环境为了集合渲染引擎和JS引擎执行,为了处理JS执行时的高优先级任务而设计的机制。

宿主环境有浏览器、Node、跨端引擎等,不同环境有一些区别。这里我们只关心浏览器的Event Loop。

浏览器每执行一个JS任务就是一个Event Loop,

每个Loop结束后都会检查下是否需要渲染,是否需要处理Worker的消息。

这样,就解决了渲染、JS执行、Worker这三者的调度问题。

异步I/O 与 非阻塞I/O

从实际效果而言,异步和非阻塞都达到了我们并行I/O的目的,

但是从计算机内核I/O而言,异步/同步和阻塞/非阻塞实际上是两回事。

阻塞、非阻塞说的是调用者,同步、异步说的是被调用者。

有人认为阻塞和同步是一回事儿,非阻塞和异步是一回事。但这是不对的。

先来看同步场景中是如何包含阻塞和非阻塞情况的。

  • 我们是用传统的水壶烧水,在水烧开之前我们一直坐在水壶前面,等着水开。这就是阻塞的。
  • 我们是用传统的水壶烧水,在水烧开之前我们先去客厅看电视了,但是水壶不会主动通知我们,需要我们时不时的去厨房看一下水有没有开。这就是非阻塞的。

再来看异步场景中是如何包含阻塞和非阻塞情况的。

  • 我们是用带提醒功能的水壶烧水,在水烧开发出提醒之前我们一直坐在水壶前面,等着水开。这就是阻塞的。
  • 我们是用带提醒功能的水壶烧水,在水烧开发出提醒之前我们先去客厅看电视了,等水壶发出声音提醒我们。这就是非阻塞的。

任何技术都并非完美的。

阻塞I/O造成CPU等待浪费,非阻塞带来的麻烦却是需要轮询去确认是否完全完成数据获取,它会让CPU处理状态判断,是对CPU资源的浪费。

这里我们且看轮询技术是如何演进的,以减小I/O状态判断的CPU损耗。

  • read:它是最原始、性能最低的一种,通过重复调用来检查I/O的状态来完成完整数据的读取。在得到最终数据前,CPU一直耗用在等待上。
  • select:它是在read的基础上改进的一种方案,通过对文件描述符上的事件状态来进行判断。select轮询具有一个较弱的限制,那就是由于它采用一个1024长度的数组来存储状态,所以它最多可以同时检查1024个文件描述符。
  • poll:该方案较select有所改进,采用链表的方式避免数组长度的限制,其次它能避免不需要的检查。但是当文件描述符较多的时候,它的性能还是十分低下的。
  • epoll:该方案是Linux下效率最高的I/O事件通知机制,在进入轮询的时候如果没有检查到I/O事件,将会进行休眠,直到事件发生将它唤醒。它是真实利用了事件通知、执行回调的方式,而不是遍历查询,所以不会浪费CPU,执行效率较高。
  • kqueue:该方案的实现方式与epoll类似,不过它仅在FreeBSD系统下存在

宏任务 与 微任务

在异步模式下,创建异步任务主要分为 宏任务 与 微任务 两种。

ES6 规范中,

宏任务(macrotask)称为 Task,由宿主(浏览器、Node)发起;

微任务(microtask)称为 Jobs,由 JavaScript 自身发起。

宏任务(macrotask) | 微任务(microtask) | | -------------- || ---------------------------- | | setTimeout | Promise.[then/catch/finally] | | setInterval | process.nextTick(Node环境) | | MessageChannel | MutationObserver(浏览器环境) | | I/O,事件队列 | queueMicrotask |

一次Loop的执行顺序:

graph TD
执行宏任务 --> 执行所有微任务 --> check_渲染线程 --> check_worker线程

代码测试

测试1

先来看一段代码,打印的结果会是?

console.log(1)

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

Promise.resolve().then(() => {
	console.log(3)
}).then(() => {
	console.log(4)
})

console.log(5)

这个非常简单,正确结果是:1、5、3、4、2。

测试2

console.log(1)

setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(4)
        resolve()
    }).then(() => {
        console.log(5)
    })
})

new Promise(resolve => {
    console.log(7)
    resolve()
}).then(() => {
    console.log(8)
})

setTimeout(() => {
    console.log(9)
    new Promise(resolve => {
        console.log(11)
        resolve()
    }).then(() => {
        console.log(12)
    })
})

分析步骤:

  1. 同步代码首先输出:1、7
  2. 接着,清空microtask队列:8
  3. 第一个task执行:2、4
  4. 接着,清空microtask队列:5
  5. 第二个task执行:9、11
  6. 接着,清空microtask队列:12

测试3

加入 process.nextTick 后结果又是怎样?

console.log(1)

setTimeout(() => {
    console.log(2)
    new Promise(resolve => {
        console.log(4)
        resolve()
    }).then(() => {
        console.log(5)
    })
    process.nextTick(() => {
        console.log(3)
    })
})

new Promise(resolve => {
    console.log(7)
    resolve()
}).then(() => {
    console.log(8)
})

process.nextTick(() => {
    console.log(6)
})

注意:process.nextTick 的优先级高于 Promise

正确结果:1、7、6、8、2、4、3、5

测试4

setTimeout的最低延时是多少?

setTimeout(() => {
    console.log(2)
}, 2)

setTimeout(() => {
    console.log(1)
}, 1)

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

因为 MDN的setTimeout文档 写到最低延时为4ms,那么它应该输出:2、1、0。

但是在实测的过程中,不管是浏览器还是Node,运行结果都是1、0、2。

也就是说1ms和0ms的延时效果是一致的,那么会不会最低延时为1ms,而不是4ms了。

Node源码:github.com/nodejs/node…

function createSingleTimeout(callback, after, args) {
    after *= 1; // coalesce to number or NaN
    if (!(after >= 1 && after <= TIMEOUT_MAX))
        after = 1; // schedule on next tick, follows browser behavior

    ...
}

代码中的注释直接说明了,设置最低1ms的行为是为了向浏览器行为看齐。

测试5

setImmediate 与 setTimeout 哪个先执行?

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