JavaScript底层原理,并发模型、EventLoop、异步编程总结篇
JavaScript是基于事件循环的并发模型,不同于其他多线程语言,早期JavaScript只维护了一个单线程的EventLoop。随着硬件的发展,计算机已经发展成为强大的多核系统,而 JavaScript 已经成为计算世界中使用最广泛的语言之一。大量最流行的应用程序至少有一部分是基于 JavaScript代码的。为了支持这一点,有必要找到方法让项目摆脱单线程语言的限制。相应的JavaScript的多线程编程也被提上了日程。
为了增强JavaScript的EventLoop的优势,从早期的setTimeout()
setInterval()
异步任务模型,也扩展到Promise()
、queueMicrotask()
、requestAnimationFrame()
、requestIdleCallback()
。同时也引入了微任务队列,和子线程worker实现。
一、并发模型与事件循环
从最基础的java的事件模型开始讲起,现代 JavaScript 引擎实现并着重优化了以下描述的这些语义。
栈
函数调用形成了一个由若干帧组成的栈。
function foo(b) {
let a = 10;
return a + b + 11;
}
function bar(x) {
let y = 3;
return foo(x * y);
}
console.log(bar(7)); // 返回 42
当调用 bar
时,第一个帧被创建并压入栈中,帧中包含了 bar
的参数和局部变量。当 bar
调用 foo
时,第二个帧被创建并被压入栈中,放在第一个帧之上,帧中包含 foo
的参数和局部变量。当 foo
执行完毕然后返回时,第二个帧就被弹出栈(剩下 bar
函数的调用帧)。当 bar
也执行完毕然后返回时,第一个帧也被弹出,栈就被清空了。
堆
对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。
队列
一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。
在 事件循环 期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。
函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。
事件循环
之所以称之为 事件循环,是因为它经常按照类似如下的方式来被实现:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
queue.waitForMessage()
会同步地等待消息到达 (如果当前没有任何消息等待被处理)。
执行至完成
每一个消息完整地执行后,其他消息才会被执行。这为程序的分析提供了一些优秀的特性,包括:当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。这与 C 语言不同,例如,如果函数在线程中运行,它可能在任何位置被终止,然后在另一个线程中运行其他代码。
这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web 应用程序就无法处理与用户的交互,例如点击或滚动。为了缓解这个问题,浏览器一般会弹出一个“这个脚本运行时间过长”的对话框。一个良好的习惯是缩短单个消息处理时间,并在可能的情况下将一个消息裁剪成多个消息。
添加消息
在浏览器里,每当一个事件发生并且有一个事件监听器绑定在该事件上时,一个消息就会被添加进消息队列。如果没有事件监听器,这个事件将会丢失。所以当一个带有点击事件处理器的元素被点击时,就会像其他事件一样产生一个类似的消息。
函数 setTimeout
接受两个参数:待加入队列的消息和一个时间值(可选,默认为 0)。这个时间值代表了消息被实际加入到队列的最小延迟时间。如果队列中没有其他消息并且栈为空,在这段延迟时间过去之后,消息会被马上处理。但是,如果有其他消息,setTimeout
消息必须等待其他消息处理完。因此第二个参数仅仅表示最少延迟时间,而非确切的等待时间。
下面的例子演示了这个概念(setTimeout
并不会在计时器到期之后直接执行):
const s = new Date().getSeconds();
setTimeout(function() {
// 输出 "2",表示回调函数并没有在 500 毫秒之后立即执行
console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
}, 500);
while(true) {
if(new Date().getSeconds() - s >= 2) {
console.log("Good, looped for 2 seconds");
break;
}
}
零延迟
零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout
并不表示在 0 毫秒后就立即调用回调函数。
其等待的时间取决于队列里待处理的消息数量。在下面的例子中,"这是一条消息"
将会在回调获得处理之前输出到控制台,这是因为延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。
基本上,setTimeout
需要等待当前队列中所有的消息都处理完毕之后才能执行,即使已经超出了由第二参数所指定的时间。
(function() {
console.log('这是开始');
setTimeout(function cb() {
console.log('这是来自第一个回调的消息');
});
console.log('这是一条消息');
setTimeout(function cb1() {
console.log('这是来自第二个回调的消息');
}, 0);
console.log('这是结束');
})();
// "这是开始"
// "这是一条消息"
// "这是结束"
// "这是来自第一个回调的消息"
// "这是来自第二个回调的消息"
永不阻塞
JavaScript 的事件循环模型与许多其他语言不同的一个非常有趣的特性是,它永不阻塞。处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其他事情,比如用户输入。
由于历史原因有一些例外,如 alert 或者同步 XHR,但应该尽量避免使用它们。注意,例外的例外也是存在的(但通常是实现错误而非其他原因)。
二、运行时环境
在执行 JavaScript 代码的时候,JavaScript 运行时实际上维护了一组用于执行 JavaScript 代码的代理。每个代理由一组执行上下文的集合、执行上下文栈、主线程、一组可能创建用于执行 worker 的额外的线程集合、一个任务队列以及一个微任务队列构成。除了主线程(某些浏览器在多个代理之间共享的主线程)之外,其他组成部分对该代理都是唯一的。
现在我们来更加详细的了解一下运行时是如何工作的。
事件循环
每个代理都是由事件循环(Event loop)驱动的,事件循环负责收集事件(包括用户事件以及其他非用户事件等)、对任务进行排队以便在合适的时候执行回调。然后它执行所有处于等待中的 JavaScript 任务,然后是微任务,然后在开始下一次循环之前执行一些必要的渲染和绘制操作。
网页或者 app 的代码和浏览器本身的用户界面程序运行在相同的线程中,共享相同的事件循环。该线程就是主线程,它除了运行网页本身的代码之外,还负责收集和派发用户和其他事件,以及渲染和绘制网页内容等。
事件循环驱动着浏览器中发生的一切,因为它与用户的交互有关,但对于我们这里的目的来说,更重要的是它负责调度和执行在其线程中运行的每一段代码。
有如下三种事件循环:
Window 事件循环
window 事件循环驱动所有共享同源的窗口(尽管这有进一步的限制,如下所述)。
Worker 事件循环
worker 事件循环驱动 worker 的事件循环。这包括所有形式的 worker,包括基本的 web worker、shared worker 和 service worker。Worker 被保存在一个或多个与“主”代码分开的代理中;浏览器可以对所有特定类型的工作者使用一个事件循环,也可以使用多个事件循环来处理它们。
Worklet 事件循环
worklet (en-US) 事件循环驱动运行 worklet 的代理。这包含了 Worklet (en-US)、AudioWorklet (en-US) 以及 PaintWorklet (en-US)。
多个同源窗口可能运行在相同的事件循环中,每个队列任务进入到事件循环中以便处理器能够轮流对它们进行处理。记住这里的网络术语“window”实际上指的是“用于运行网页内容的浏览器级容器”,包括实际的 window、标签页或者一个 frame。
在特定情况下,同源窗口之间共享事件循环,例如:
如果一个窗口打开了另一个窗口,它们可能会共享一个事件循环。 如果窗口是包含在 中的容器,则它可能会和包含它的窗口共享一个事件循环。 在多进程浏览器中多个窗口碰巧共享了同一个进程。 这种特定情况依赖于浏览器的具体实现,各个浏览器可能并不一样。
三、异步编程
JavaScript本质上只是一个单线程的语言,如果一个任务占用时间过长,就会造成浏览器的假死,并阻塞后面的任务,所以异步编程是JavaScript处理任务核心特性。
1.回调
事件处理程序是一种特殊类型的回调函数。而回调函数则是一个被传递到另一个函数中的会在适当的时候被调用的函数。正如我们刚刚所看到的:回调函数曾经是 JavaScript 中实现异步函数的主要方式。
然而,当回调函数本身需要调用其他同样接受回调函数的函数时,基于回调的代码会变得难以理解。当你需要执行一些分解成一系列异步函数的操作时,这将变得十分常见。例如下面这种情况:
function doStep1(init) {
return init + 1;
}
function doStep2(init) {
return init + 2;
}
function doStep3(init) {
return init + 3;
}
function doOperation() {
let result = 0;
result = doStep1(result);
result = doStep2(result);
result = doStep3(result);
console.log(`结果:${result}`);
}
doOperation();
回调有明显的缺陷,很容易写成“回调炼狱”形式的函数,给阅读和理解造成了麻烦。
2.事件
JavaScript是基于事件编程实现异步操作的,回调和事件组成了JavaScript的基石。JavaScript提供了addEventListener
语法处理事件。
<button id="btn"> 点我哦 </button>
<script>
const btn = document.getElementById('btn');
// 单击时触发
btn.addEventListener('click', event => console.log('click!'));
// 鼠标移入触发
btn.addEventListener('mouseover', event => console.log('mouseover!'));
// 鼠标移出触发
btn.addEventListener('mouseout', event => console.log('mouseout!'));
</script>
2.基于契约
Promise 是现代 JavaScript 中异步编程的基础,是一个由异步函数返回的可以向我们指示当前操作所处的状态的对象。在 Promise 返回给调用者的时候,操作往往还没有完成,但 Promise 对象可以让我们操作最终完成时对其进行处理(无论成功还是失败)。
const fetchPromise = fetch('https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json');
fetchPromise
.then( response => {
return response.json();
})
.then( json => {
console.log(json[0].name);
});
4.使用queueMicrotask()
Window 或 Worker 接口的 queueMicrotask() 方法,将微任务加入队列以在控制返回浏览器的事件循环之前的安全时间执行。
微任务是一个简短的函数,它将在当前任务完成其工作后运行,并且在执行上下文的控制权返回到浏览器的事件循环之前没有其他代码等待运行时运行。
MyElement.prototype.loadData = function (url) {
if (this._cache[url]) {
queueMicrotask(() => {
this._setData(this._cache[url]);
this.dispatchEvent(new Event("load"));
});
} else {
fetch(url)
.then((res) => res.arrayBuffer())
.then((data) => {
this._cache[url] = data;
this._setData(data);
this.dispatchEvent(new Event("load"));
});
}
};
5.使用setTime()、 setInterval()、 requestAnimationFrame()
window对象下这三个函数都能实现异常操作
6.requestIdleCallback()
window.requestIdleCallback() 方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。
你可以在空闲回调函数中调用 requestIdleCallback(),以便在下一次通过事件循环之前调度另一个回调。
6.并发编程worker线程
一个 worker 是使用一个构造函数创建的一个对象(例如 Worker())运行一个命名的 JavaScript 文件——这个文件包含将在 worker 线程中运行的代码; worker 运行在另一个全局上下文中,不同于当前的window。因此,在 Worker 内通过 window 获取全局作用域(而不是self)将返回错误。
在专用 worker 的情况下,DedicatedWorkerGlobalScope 对象代表了 worker 的上下文(专用 worker 是指标准 worker 仅在单一脚本中被使用;共享 worker 的上下文是 SharedWorkerGlobalScope (en-US) 对象)。一个专用 worker 仅能被首次生成它的脚本使用,而共享 worker 可以同时被多个脚本使用。
四、任务队列与微任务队列
一个任务就是指计划由标准机制来执行的任何 JavaScript,如程序的初始化、事件触发的回调等。除了使用事件,你还可以使用 setTimeout()
或者 setInterval()
来添加任务。
任务队列
setTimeOut()
、setInterval()
函数添加- 各种事件回调,浏览器
addEventListener
,nodeon
- XHR
微任务队列
- promse()
- queueMicrotask()
- Mutation observer()
任务队列和微任务队列的区别很简单,但却很重要:
- 当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行。
- 每次当一个任务退出且执行上下文栈为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行——即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,这些新的微任务将在下一个任务开始运行之前,在当前事件循环迭代结束之前执行。
通过使用像 promise 这样的异步 JavaScript 技术可以使得主线程在等待请求返回结果的同时继续往下执行,这能够更进一步减轻上面提到的情况。然而,一些更接近于基础功能的代码——比如一些框架代码,可能更需要将代码安排在主线程上一个安全的时间来运行,它与任何请求的结果或者任务无关。
微任务是另一种解决该问题的方案,通过将代码安排在下一次事件循环开始之前运行而不是必须要等到下一次开始之后才执行,这样可以提供一个更好的访问级别。
微任务队列已经存在有一段时间了,但之前它仅仅被内部使用来驱动诸如 promise 这些任务。queueMicrotask()
的加入可以让开发者创建一个统一的微任务队列,在任何需要有能力安排代码在 JavaScript 执行上下文栈上没有执行上下文时安全运行的地方使用。在多个实例、浏览器和 JavaScript 运行时中,标准化的微队列机制意味着这些微任务将以相同的顺序可靠地运行,从而避免潜在的难以发现的错误。
五、应用案例
任务和微任务输出顺序
- 常见的笔试题,输出下列执行顺序:
console.log('start')
setTimeout(() => {
console.log('setTimeout')
}, 0)
new Promise((resolve) => {
console.log('promise')
resolve()
})
.then(() => {
console.log('then1')
})
.then(() => {
console.log('then2')
})
console.log('end')
答案: start promise end then1 then2 setTimeout
特别注意,new Promise() 函数内部代码是同步执行,只有then是添加到微任务对立
转载自:https://juejin.cn/post/7253437782333472826