Event Loop

172 阅读20分钟

JS 引擎

JavaScript 引擎的一个流行示例是 Google 的 V8 引擎。 例如,V8 引擎用于 Chrome 和 Node.js。 这是它的外观的非常简化的视图:

image.png 引擎由两个主要组件组成:

  • 内存堆——这是内存分配发生的地方
  • 调用堆栈——这是代码执行时堆栈帧所在的位置

runtime 运行时

浏览器中有几乎所有 JavaScript 开发人员都使用过的 API(例如“setTimeout”)。 但是,这些 API 不是由引擎提供的。

那么,它们来自哪里?

事实证明,实际情况要复杂一些。

image.png

所以,我们有引擎,但实际上还有更多。 我们有浏览器提供的称为 Web API 的东西,例如 DOM、AJAX、setTimeout 等等。 然后,我们有如此流行的事件循环和回调队列。

调用栈 Call Stacks

JavaScript 是一种单线程编程语言,这意味着它只有一个调用堆栈。 因此它一次只能做一件事。

调用栈是一种数据结构,它基本上记录了我们在程序中的位置。 如果我们进入一个函数,我们会将它放在栈顶。 如果我们从一个函数返回,我们会从栈顶弹出。 这就是堆栈可以做的所有事情。

让我们看一个例子。 看看下面的代码

function multiply(x, y) {
    return x * y;
}
function printSquare(x) {
    var s = multiply(x, x);
    console.log(s);
}
printSquare(5);

当引擎开始执行此代码时,调用堆栈将为空。 之后,步骤如下:

image.png

在单线程上运行代码非常容易,因为您不必处理多线程环境中出现的复杂场景 - 例如,死锁。

但是在单个线程上运行也非常有限。 由于 JavaScript 只有一个调用堆栈,所以当事情变慢时会发生什么?

并发和事件循环

如果调用堆栈中的函数调用需要花费大量时间来处理,会发生什么情况? 例如,假设您想在浏览器中使用 JavaScript 进行一些复杂的图像转换。

你可能会问——为什么这甚至是一个问题?

问题是,虽然调用堆栈有要执行的函数,但浏览器实际上不能做任何其他事情——它被阻塞了。 这意味着浏览器无法渲染,无法运行任何其他代码,它只是卡住了。 如果你想在你的应用中使用流畅的 UI,这就会产生问题。

这还不是唯一的问题。 一旦您的浏览器开始在调用堆栈中处理如此多的任务,它可能会在很长一段时间内停止响应。 大多数浏览器通过引发错误来采取行动,询问您是否要终止网页。

现在,这不是最好的用户体验,是吗?

那么,我们如何才能在不阻塞 UI 并使浏览器无响应的情况下执行繁重的代码呢? 嗯,解决方案是异步回调。

JS 是单线程的,现在无法完成的任务将异步完成,这样不会出现阻塞行为。

Event Loop

尽管允许异步 JavaScript 代码(如 setTimeout),但在 ES6 之前,JavaScript 本身实际上从未有任何内置的异步性的直接概念。 JavaScript 引擎除了在任何给定时刻执行单个程序块外,从未做过任何其他事情。

那么,谁告诉 JS 引擎执行你的程序块呢? 实际上,JS 引擎并不是孤立运行的——它在托管环境中运行,对于大多数开发人员来说,这是典型的 Web 浏览器或 Node.js。 实际上,如今,JavaScript 被嵌入到各种设备中,从机器人到灯泡。 每个设备都代表 JS 引擎的不同类型的托管环境。

所有环境中的共同点是一种称为事件循环的内置机制,它随着时间的推移处理程序的多个块的执行,每次调用 JS 引擎。 这意味着 JS 引擎只是任意 JS 代码的按需执行环境。是周围环境安排事件(JS 代码执行)。 因此,例如,当您的 JavaScript 程序发出 Ajax 请求以从服务器获取一些数据时,您在函数中设置“响应”代码(“回调”),并且 JS 引擎告诉托管环境: “嘿,我现在要暂停执行,但是当你完成那个网络请求,并且你有一些数据时,请调用这个函数。” 然后设置浏览器来监听来自网络的响应,当它有东西要返回给你时,它会通过将它插入到事件循环中来安排要执行的回调函数。 我们看下图:

image.png

这些 Web API 是什么? 本质上,它们是您无法访问的线程,您可以调用它们。 它们是启动并发的浏览器部分。如果您是 Node.js 开发人员,那么这些就是 C++ API。

那么到底什么是事件循环呢?

image.png

事件循环有一项简单的工作——监视调用堆栈和回调队列。 如果调用堆栈为空,则事件循环将从队列中取出第一个事件并将其推送到调用堆栈,后者有效地运行它。

这种迭代在事件循环中称为 Tick。 每个事件只是一个函数回调。

console.log('Hi'); 
setTimeout(function cb1() { 
    console.log('cb1'); 
}, 5000); 
console.log('Bye');

让我们“执行”这段代码,看看会发生什么:

  1. 状态一目了然。 浏览器控制台清晰,Call Stack为空。

image.png

  1. 将 console.log('Hi') 添加到调用堆栈中。

image.png

  1. console.log('Hi') 被执行。

image.png

  1. console.log('Hi') 从调用堆栈中删除。

image.png

  1. setTimeout(function cb1() { ... }) 添加到调用堆栈中。

image.png

  1. setTimeout(function cb1() { ... }) 被执行。 浏览器创建一个计时器作为 Web API 的一部分。 它会为你处理倒计时。

image.png

  1. setTimeout(function cb1() { ... }) 本身完成并从调用堆栈中删除。

image.png

  1. console.log('Bye') 添加到调用栈中。

image.png 9. console.log('Bye') 被执行。

image.png 10. console.log('Bye') 从调用堆栈中删除。

image.png 11. 至少 5000 毫秒后,计时器完成并将 cb1 回调推送到回调队列。

image.png 12. 事件循环从回调队列中取出 cb1 并将其推送到调用堆栈。

image.png 13. 执行 cb1 并将 console.log('cb1') 添加到调用堆栈。

image.png 14. console.log('cb1') 被执行。

image.png 15. console.log('cb1') 从调用堆栈中删除。

image.png 16. cb1 从调用堆栈中删除。

image.png

快速回顾:

1_TozSrkk92l8ho6d8JxqF_w.gif

有趣的是,ES6 指定了事件循环应该如何工作,这意味着从技术上讲它在 JS 引擎的职责范围内,不再只是扮演托管环境的角色。 这一变化的一个主要原因是在 ES6 中引入了 Promise,因为后者需要对事件循环队列上的调度操作进行直接、细粒度的控制(我们将在后面更详细地讨论它们)。

task任务源非常宽泛,比如ajaxonloadclick事件,基本上我们经常绑定的各种事件都是task任务源,还有数据库操作(IndexedDB ),需要注意的是setTimeoutsetIntervalsetImmediate也是task任务源。总结来说task任务源:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

每一个event loop都有一个microtask队列,一个microtask会被排进microtask队列而不是task队列。

有两种microtasks:分别是solitary callback microtasks和compound microtasks。规范值只覆盖solitary callback microtasks。

如果在初期执行时,spin the event loop,microtasks有可能被移动到常规的task队列,在这种情况下,microtasks任务源会被task任务源所用。通常情况,task任务源和microtasks是不相关的。

microtask 队列和task 队列有些相似,都是先进先出的队列,由指定的任务源去提供任务,不同的是一个
event loop里只有一个microtask 队列。

HTML Standard没有具体指明哪些是microtask任务源,通常认为是microtask任务源有:

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

event loop的处理过程(Processing model)

image.png

在规范的Processing model定义了event loop的循环过程:

一个event loop只要存在,就会不断执行下边的步骤: 1.在tasks队列中选择最老的一个task,用户代理可以选择任何task队列,如果没有可选的任务,则跳到下边的microtasks步骤。
2.将上边选择的task设置为正在运行的task
3.Run: 运行被选择的task。
4.将event loop的currently running task变为null。
5.从task队列里移除前边运行的task。
6.Microtasks: 执行microtasks任务检查点。(也就是执行microtasks队列里的任务)
7.更新渲染(Update the rendering)...
8.如果这是一个worker event loop,但是没有任务在task队列中,并且WorkerGlobalScope对象的closing标识为true,则销毁event loop,中止这些步骤,然后进行定义在Web workers章节的run a worker
9.返回到第一步。

event loop会不断循环上面的步骤,概括说来:

  • event loop会不断循环的去取tasks队列的中最老的一个任务推入栈中执行,并在当次循环里依次执行并清空microtask队列里的任务。
  • 执行完microtask队列里的任务,有可能会渲染更新。(浏览器很聪明,在一帧以内的多次dom变动浏览器不会立即响应,而是会积攒变动以最高60HZ的频率更新视图)

javaScript是单线程,也就是说只有一个主线程,主线程有一个栈,每一个函数执行的时候,都会生成新的execution context(执行上下文),执行上下文会包含一些当前函数的参数、局部变量之类的信息,它会被推入栈中, running execution context(正在执行的上下文)始终处于栈的顶部。当函数执行完后,它的执行上下文会从栈弹出。

event loop中的Update the rendering(更新渲染)

这是event loop中很重要部分,在执行完成后会进行Update the rendering(更新渲染),规范允许浏览器自己选择是否更新视图。也就是说可能不是每轮事件循环都去更新视图,只在有必要的时候才更新视图。

www.html5rocks.com/zh/tutorial… 这篇文章较详细的讲解了渲染机制。

渲染的基本流程: image.png

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树, 将 DOM 与 CSSOM 合并成一个渲染树。
  3. 根据渲染树来布局,以计算每个节点的几何信息。
  4. 将各个节点绘制到屏幕上。

Note: 可以看到渲染树的一个重要组成部分是CSSOM树,绘制会等待css样式全部加载完成才进行,所以css样式加载的快慢是首屏呈现快慢的关键点。

小结

  • 在一轮event loop中多次修改同一dom,只有最后一次会进行绘制。
  • 渲染更新(Update the rendering)会在event loop中的tasks和microtasks完成后进行,但并不是每轮event loop都会更新渲染,这取决于是否修改了dom和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在一帧的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7ms只是估算并不准确)修改了多处dom,浏览器可能将变动积攒起来,只进行一次绘制,这是合理的。
  • 如果希望在每轮event loop都即时呈现变动,可以使用requestAnimationFrame。

对于一些简单的场景,同步完全可以胜任,如果得对dom反复修改或者进行大量计算时,使用异步可以作为缓冲,优化性能。

setTimeout(...) 如何工作

重要的是要注意 setTimeout(...) 不会自动将您的回调放在事件循环队列中。 它设置了一个计时器。 当计时器到期时,环境会将您的回调放入事件循环中,以便将来某个 Tick 将其捡起并执行。 看看这段代码:

setTimeout(myCallback, 1000); 

这并不意味着 myCallback 将在 1,000 毫秒内执行,而是在 1,000 毫秒内, myCallback 将被添加到事件循环队列中。 然而,队列中可能有其他先前添加的事件——您的回调将不得不等待。

有很多关于 JavaScript 异步代码入门的文章和教程都建议执行 setTimeout(callback, 0)。 好吧,现在您知道事件循环的作用以及 setTimeout 的工作原理:使用 0 作为第二个参数调用 setTimeout 只是将回调推迟到调用堆栈清除为止。

看看下面的代码:

console.log('Hi');
setTimeout(function() {
    console.log('callback');
}, 0);
console.log('Bye');

尽管等待时间设置为 0 毫秒,但浏览器控制台中的结果将如下所示:

Hi
Bye
callback

嵌套回调

看下面的代码:

listen('click', function (e){
    setTimeout(function(){
        ajax('https://api.example.com/endpoint', function (text){
            if (text == "hello") {
	        doSomething();
	    }
	    else if (text == "world") {
	        doSomethingElse();
            }
        });
    }, 500);
});

我们有一个嵌套在一起的三个函数链,每个函数代表异步系列中的一个步骤。

这种代码通常被称为“回调地狱”。 但是“回调地狱”实际上与嵌套/缩进几乎无关。 这是一个比这更深层次的问题。

首先,我们等待“点击”事件,然后等待计时器触发,然后等待 Ajax 响应返回,此时它可能会再次重复。 乍一看,这段代码似乎将其异步性自然地映射到顺序步骤,例如:

listen('click', function (e) {
	// ..
});

然后我们有:

setTimeout(function(){
    // ..
}, 500);

然后我们有:

ajax('https://api.example.com/endpoint', function (text){
    // ..
});

最后:

if (text == "hello") {
    doSomething();
}
else if (text == "world") {
    doSomethingElse();
}

所以,这种表达异步代码的顺序方式似乎更自然,不是吗? 一定有这样的方法吧?

Promise

看看下面的代码:

var x = 1;
var y = 2;
console.log(x + y);

这一切都非常简单:它将 x 和 y 的值相加并将其打印到控制台。 但是,如果 x 或 y 的值缺失并且仍有待确定怎么办? 比如说,我们需要从服务器检索 x 和 y 的值,然后才能在表达式中使用它们。 假设我们有一个函数 loadX 和 loadY 分别从服务器加载 x 和 y 的值。 然后,假设我们有一个函数 sum,一旦 x 和 y 都被加载,它就会对 x 和 y 的值求和。 它可能看起来像这样(很丑,不是吗):

function sum(getX, getY, callback) {
    var x, y;
    getX(function(result) {
        x = result;
        if (y !== undefined) {
            callback(x + y);
        }
    });
    getY(function(result) {
        y = result;
        if (x !== undefined) {
            callback(x + y);
        }
    });
}
// A sync or async function that retrieves the value of `x`
function fetchX() {
    // ..
}


// A sync or async function that retrieves the value of `y`
function fetchY() {
    // ..
}
sum(fetchX, fetchY, function(result) {
    console.log(result);
});

这里有一些非常重要的东西——在那个片段中,我们将 x 和 y 视为未来值,并且我们表达了一个操作 sum(...),它(从外部)不关心 x 或 y 或两者是否可用 马上。 当然,这种基于回调的粗略方法还有很多不足之处。 这只是了解推理未来价值的好处的第一步,而不必担心它们何时可用。

Promise Value

让我们简单地看一下如何用 Promises 表达 x + y 示例:

function sum(xPromise, yPromise) {
	// `Promise.all([ .. ])` takes an array of promises,
	// and returns a new promise that waits on them
	// all to finish
	return Promise.all([xPromise, yPromise])

	// when that promise is resolved, let's take the
	// received `X` and `Y` values and add them together.
	.then(function(values){
		// `values` is an array of the messages from the
		// previously resolved promises
		return values[0] + values[1];
	} );
}

// `fetchX()` and `fetchY()` return promises for
// their respective values, which may be ready
// *now* or *later*.
sum(fetchX(), fetchY())

// we get a promise back for the sum of those
// two numbers.
// now we chain-call `then(...)` to wait for the
// resolution of that returned promise.
.then(function(sum){
    console.log(sum);
});

在这个片段中有两层 Promises。 fetchX() 和 fetchY() 被直接调用,它们返回的值(承诺!)被传递给 sum(...)。这些承诺所代表的潜在价值可能现在或以后准备就绪,但每个承诺都将其行为规范化为无论如何都相同。我们以与时间无关的方式推理 x 和 y 值。它们是未来的价值,时期。

第二层是 sum(...) 创建的 promise (通过 Promise.all([ ... ]))并返回,我们通过调用 then(...) 来等待。当 sum(...) 操作完成时,我们的 sum 未来值就准备好了,我们可以打印出来。我们隐藏了在 sum(...) 中等待 x 和 y 未来值的逻辑。

注意:在 sum(...) 中, Promise.all([ ... ]) 调用创建了一个 promise(等待 promiseX 和 promiseY 解决)。对 .then(...) 的链式调用创建了另一个承诺,它返回 values[0] + values[1] 行立即解析(加上加法的结果)。因此,我们将 sum(...) 调用的末尾链接起来的 then(...) 调用——在代码片段的末尾——实际上是在操作返回的第二个 promise,而不是由 Promise 创建的第一个 promise。全部([ ... ])。此外,虽然我们没有链接到那一秒的结尾 then(...),但它也创造了另一个承诺,如果我们选择观察/使用它。本章后面将更详细地解释这种 Promise 链接。

使用 Promises, then(...) 调用实际上可以采用两个函数,第一个用于实现(如前所示),第二个用于reject:

sum(fetchX(), fetchY())
.then(
    // fullfillment handler
    function(sum) {
        console.log( sum );
    },
    // rejection handler
    function(err) {
    	console.error( err ); // bummer!
    }
);

如果在获取 x 或 y 时出现问题,或者在添加过程中出现某种方式失败,则 sum(...) 返回的承诺将被拒绝,并且传递给 then(...) 的第二个回调错误处理程序将收到拒绝 来自承诺的价值。

因为 Promises 从外部封装了依赖于时间的状态——等待底层值的实现或拒绝——,Promise 本身是时间无关的,因此 Promises 可以以可预测的方式组合(组合),而不管时间或结果如何 下。 此外,一旦 Promise 被解析,它就会永远保持这种状态——在那个时候它变成一个不可变的值——然后可以根据需要多次观察。

实际上可以链接 promises 真的很有用:

调用 delay(2000) 创建一个将在 2000 毫秒内完成的承诺,然后我们从第一个 then(...) 完成回调中返回它,这导致第二个 then(...) 的承诺等待 2000 毫秒的承诺 .

注意:因为 Promise 一经解析就在外部是不可变的,所以现在可以安全地将该值传递给任何一方,因为它不会被意外或恶意修改。 对于观察 Promise 解决方案的多方来说尤其如此。 一方不可能影响另一方遵守 Promise 解决方案的能力。 不变性听起来像是一个学术话题,但它实际上是 Promise 设计中最基础、最重要的方面之一,不应该被随便忽略。

To Promise or not to Promise

关于 Promise 的一个重要细节是确定某个值是否是实际的 Promise。 换句话说,它是一个表现得像 Promise 的值吗?

我们知道 Promises 是由 new Promise(...) 语法构造的,你可能认为 p instanceof Promise 就足够了。 嗯,不完全是。

主要是因为您可以从另一个浏览器窗口(例如 iframe)接收一个 Promise 值,该窗口有自己的 Promise,与当前窗口或框架中的不同,并且该检查无法识别 Promise 实例。

此外,库或框架可能会选择提供自己的 Promise,而不是使用原生的 ES6 Promise 实现来这样做。 事实上,你很可能在没有 Promise 的旧浏览器中使用带有库的 Promises。

ES8 中发生了什么?async/await

JavaScript ES8 引入了 async/await,使使用 Promise 的工作变得更容易。我们将简要介绍 async/await 提供的可能性以及如何利用它们编写异步代码。

如何使用 async/await? 您可以使用 async 函数声明来定义异步函数。此类函数返回一个 AsyncFunction 对象。 AsyncFunction 对象表示执行包含在该函数中的代码的异步函数。

当异步函数被调用时,它返回一个 Promise 。当 async 函数返回一个不是 Promise 的值时,将自动创建一个 Promise 并使用函数的返回值解析它。当 async 函数抛出异常时,Promise 将被抛出的值拒绝。

async 函数可以包含一个 await 表达式,它会暂停函数的执行并等待传递的 Promise 的解析,然后恢复异步函数的执行并返回解析的值。

您可以将 JavaScript 中的 Promise 视为 Java 的 Future 或 C# 的 Task 的等价物。

async/await 的目的是简化使用 Promise 的行为。

async function f() {
  await p
  console.log('ok')
}

简单理解为:

function f() {
  return RESOLVE(p).then(() => {
    console.log('ok')
  })
}

让我们看一下下面的例子:

// Just a standard JavaScript function
function getNumber1() {
    return Promise.resolve('374');
}
// This function does the same as getNumber1
async function getNumber2() {
    return 374;
}

类似地,抛出异常的函数等价于返回被reject的promise的函数:

function f1() {
    return Promise.reject('Some error');
}
async function f2() {
    throw 'Some error';
}

await 关键字只能在异步函数中使用,并允许您同步等待 Promise。 如果我们在异步函数之外使用 promise,我们仍然需要使用 then 回调:

async function loadData() {
    // `rp` is a request-promise function.
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Currently, both requests are fired, concurrently and
    // now we'll have to wait for them to finish
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}
// Since, we're not in an `async function` anymore
// we have to use `then`.
loadData().then(() => console.log('Done'));

您还可以使用“异步函数表达式”定义异步函数。 异步函数表达式与异步函数语句非常相似并且具有几乎相同的语法。 异步函数表达式和异步函数语句之间的主要区别在于函数名称,在异步函数表达式中可以省略该名称以创建匿名函数。 异步函数表达式可以用作 IIFE(立即调用函数表达式),它在定义后立即运行。

它看起来像这样:

var loadData = async function() {
    // `rp` is a request-promise function.
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Currently, both requests are fired, concurrently and
    // now we'll have to wait for them to finish
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}

编写高度可维护、非脆弱的异步代码的 5 个技巧

  1. 干净的代码:使用 async/await 可以让您编写更少的代码。 每次使用 async/await 时,都会跳过一些不必要的步骤:编写 .then,创建一个匿名函数来处理响应,命名来自该回调的响应.
// `rp` is a request-promise function.
rp('https://api.example.com/endpoint1').then(function(data) {
 // …
});

相对:

var response = await rp(‘https://api.example.com/endpoint1');
  1. 错误处理:Async/await 可以使用相同的代码结构(众所周知的 try/catch 语句)处理同步和异步错误。 让我们看看它在 Promises 中的表现:
function loadData() {
    try { // Catches synchronous errors.
        getJSON().then(function(response) {
            var parsed = JSON.parse(response);
            console.log(parsed);
        }).catch(function(e) { // Catches asynchronous errors
            console.log(e); 
        });
    } catch(e) {
        console.log(e);
    }
}

相对:

async function loadData() {
    try {
        var data = JSON.parse(await getJSON());
        console.log(data);
    } catch(e) {
        console.log(e);
    }
}
  1. 条件:使用 async/await 编写条件代码要简单得多:
function loadData() {
    return getJSON()
      .then(function(response) {
        if (response.needsAnotherRequest) {
          return makeAnotherRequest(response)
            .then(function(anotherResponse) {
              console.log(anotherResponse)
              return anotherResponse
            })
        } else {
          console.log(response)
          return response
        }
      })
  }

相对:

async function loadData() {
    var response = await getJSON();
    if (response.needsAnotherRequest) {
      var anotherResponse = await makeAnotherRequest(response);
      console.log(anotherResponse)
      return anotherResponse
    } else {
      console.log(response);
      return response;    
    }
  }
  1. 堆栈帧:与 async/await 不同,从承诺链返回的错误堆栈不提供错误发生位置的线索。 请看以下内容:
function loadData() {
    return callAPromise()
      .then(callback1)
      .then(callback2)
      .then(callback3)
      .then(() => {
        throw new Error("boom");
      })
  }
  loadData()
    .catch(function(e) {
      console.log(err);
  // Error: boom at callAPromise.then.then.then.then (index.js:8:13)
  });

相对:

async function loadData() {
    await callAPromise1()
    await callAPromise2()
    await callAPromise3()
    await callAPromise4()
    await callAPromise5()
    throw new Error("boom");
  }
  loadData()
    .catch(function(e) {
      console.log(err);
      // output
      // Error: boom at loadData (index.js:7:9)
  });

5.调试:如果你使用过promise,你就会知道调试它们是一场噩梦。 例如,如果您在 .then 块中设置断点并使用“stop-over”等调试快捷方式,则调试器将不会移动到后面的 .then,因为它只会“步进”同步代码。

使用 async/await,您可以像普通同步函数一样单步执行 await 调用。

Node.js 中的 Event Loop

TODO

引擎、运行时、调用堆栈

v8如何工作

内存管理

Event Loop