深刻认识:事件循环 Microtasks (微任务) 的运行时机——探索v8源码

2022/2/16 8:11:42

本文主要是介绍深刻认识:事件循环 Microtasks (微任务) 的运行时机——探索v8源码,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

Microtasks(微任务)是事件循环中一类优先级比较高的任务,本文通过一个有趣的例子探索其运行时机。从两年前被动接受知识 “当浏览器JS引擎调用栈弹空的时候,才会执行 Microtasks 队列”,到两年后主动深入探索源码后了解到的 “当 V8 执行完调用要返回 Blink 时,由于 MicrotasksScope 作用域失效,在其析构函数中检查 JS 调用栈是否为空,如果为空就会运行 Microtasks。”。同时文章中介绍了用于探索浏览器运行原理的一些工具。

刚学前端那会学习事件循环,说事件循环存在的意义是由于 JavaScript 是单线程的,所以需要事件循环来防止JS阻塞,让网络请求等I/O操作不阻塞主线程。

而 Microtasks 是一类优先级比较高的任务,我们不能像 Macrotasks(宏任务) 一样插入 Macrotasks 队列末端,等待多个事件循环后才执行,而需要插入到 Microtasks 的队列里面,在本轮事件循环中执行。

比如下面这个有趣的例子:

document.body.innerHTML=`

btn

`;

const button=document.getElementById(‘btn’)

button.addEventListener(‘click’,()=>{

Promise.resolve().then(()=>console.log(‘promise resolved 1’))

console.log(‘listener 1’)

})

button.addEventListener(‘click’,()=>{

Promise.resolve().then(()=>console.log(‘promise resolved 2’))

console.log(‘listener 2’)

})

// 1. 手动点击按钮

// button.click() // 2. 解开这句注释,用JS触发点击行为

当我手动点击按钮的时候,大家觉得浏览器的输出是下面的A还是B?

A. listener1 -> promise resolved 1 -> listener2 -> promise resolved 2B. listener1 -> listener2 -> promise resolved 1 -> promise resolved 2

大家可以在这里试一下:

codesandbox/static/img/play-codesandbox.svg

当我将上面代码中的最后一行注释打开,使用JS触发点击行为的时候,浏览器的输出是A还是B?

大家觉得上面1、2两种情况的输出顺序是否一样?

答案非常有意思

当我们使用1. 手动点击按钮时,浏览器的输出是A当我们使用2. 用JS触发点击行为时,浏览器的输出是B

为什么会出现这种情况呢? 这个 Microtasks 的运行时机有关。两年前当我带着这个问题搜索资料并询问大佬的时,大佬告诉我:

当浏览器JS引擎调用栈弹空的时候,才会执行Microtasks队列

按照这个结论,我使用 Chrome Devtool 中的 Performance 做了一次探索

人工点击的时候输出为 listener1 -> promise resolved 1 -> listener2 -> promise resolved 2 。

深刻认识:事件循环 Microtasks (微任务) 的运行时机——探索v8源码

从上图中我们可以看到,一次点击事件之后,浏览器会调用 Function Call 进入JS引擎,执行 listener1,输出listener1。弹栈时发现JS调用栈为空,这时候就会执行 Microtasks 队列中的所有 Microtask,输出promise resolved 1。接着浏览器调用 Function Call 进入JS引擎,执行 listener2,输出listener 2。弹栈时发现JS调用栈为空,这时候就会执行 Microtasks 队列中的所有Microtask,输出promise resolved 2。

在JS代码中触发点击时输出为 listener1 -> listener2 -> promise resolved 1 -> promise resolved 2

深刻认识:事件循环 Microtasks (微任务) 的运行时机——探索v8源码

从上图中我们可以看到,浏览器运行JS代码时,调用了 button.click 这个函数进入事件处理,执行 listener1,输出listener1。弹栈时发现JS调用栈非空(button.click函数还在运行)执行 listener2,输出listener 2。弹栈时发现JS调用栈为空,这时候就会执行 Microtasks 队列中的所有 Microtask,输出promise resolved 1、promise resolved 2。

Chrome Devtool 中的 Performance 是一个 sample profiler (采样分析仪),即它的运行机制是每1ms暂停一下vm,将当前的调用栈记录下来,最后利用这部分信息做出可视化。

由于它是一种 sample 的机制,所以在两个 sample 之间的运行状态可能会被丢失,所以我们在使用这个工具的时候可以

使CPU变慢:在 Devtool 中打开 "CPU 6x slowdown"在要探索的函数中执行一段比较长的for循环占用CPU时间(如上面的 heavy)

强烈建议大家学会使用这个工具,本文例子的 profile 结果文件也会文章最后给到大家,大家有兴趣可以导入试一试。

两年的时间过去了,在上周整理笔记的时候,我开始质疑这一个知识,“当浏览器 JS 引擎调用栈弹空的时候,才会执行 Microtasks 队列”。

因为它其实是个表现,我想知道浏览器和 JS 引擎到底是怎么实现这样的机制的。

因此我使用chrome://tracing进行探索,

下面探索基于 Chrome Version 88.0.4324.192 (Official Build) (x86_64),不同浏览器的实现有不同

深刻认识:事件循环 Microtasks (微任务) 的运行时机——探索v8源码

从上图中我们可以看到,一次点击事件之后,Blink(Blink是一个渲染引擎,Chrome 的 Renderer 进程中的主线程大部分时间会在 Blink 和 V8 两者切换)会调用 v8.callFunction 进入 V8 引擎,执行 listener1,输出listener1。返回 Blink 时发现 V8 调用栈为空,这时候就会执行 V8.RunMicrotasks 执行 Microtasks 队列中的所有 Microtask,输出promise resolved 1。Blink 调用 v8.callFunction 进入 V8 引擎,执行 listener2,输出listener 2。返回 Blink 时发现 V8 调用栈为空,这时候就会执行 Microtasks 队列中的所有 Microtask,输出promise resolved 2。

注意,chrome://tracing 中的v8.xxx小写v开头的为 Blink 的调用,V8.xxx大写的V才是真正的V8引擎。

tracing 工具还有一个非常好用的功能,点击下图中的放大镜,就可以直接打开 Chromium Code Search 查看 Chromium 的技术教程源码。这个工具也自带搜索功能,可以查看函数的声明、定义以及调用。

下面源码的探索基于commit e8b6574c 的Chromium,并且为了简化隐藏了无关的代码,用…替代

比如我们在上面的 tracing 里面看到有v8.callFunction的调用,我们点击可以找到这个这个函数调用,是在 Blink 中调用 V8 的入口。

third_party/blink/renderer/bindings/core/v8/v8_script_runner

v8::MaybeLocal V8ScriptRunner::CallFunction(

v8::Local function,

ExecutionContext* context,

v8::Local receiver,

int argc,

v8::Local args[],

v8::Isolate* isolate

){

TRACE_EVENT0(“v8”, “v8.callFunction”); // 这就是我们在 tracing 中看到的 v8.callFunction

v8::MicrotaskQueue* microtask_queue=ToMicrotaskQueue(context); // 拿到 microtask 队列

v8::MicrotasksScope microtasks_scope(isolate, microtask_queue,

v8::MicrotasksScope::kRunMicrotasks); // 这个 scope 很可疑,这里构造之后在这个函数后面并没有使用

probe::CallFunction probe(context, function, depth);

v8::MaybeLocal result=function->Call(isolate->GetCurrentContext(), receiver, argc, args); // 函数调用

CHECK(!isolate->IsDead());

}

这里类型为v8::MicrotasksScope的变量很可疑,在创建之后并没有在后续的函数里面使用,所以我们来看一下他的声明和定义

v8/include/v8.h

/**

* This scope is used to control microtasks when MicrotasksPolicy::kScoped

* is used on Isolate. In this mode every non-primitive call to V8 should be

* done inside some MicrotasksScope.

* Microtasks are executed when topmost MicrotasksScope marked as kRunMicrotasks

* exits.

* kDoNotRunMicrotasks should be used to annotate calls not intended to trigger

* microtasks.

*/

class V8_EXPORT V8_NODISCARD MicrotasksScope {

public:

enum Type { kRunMicrotasks, kDoNotRunMicrotasks };

MicrotasksScope(Isolate* isolate, Type type);

MicrotasksScope(Isolate* isolate, MicrotaskQueue* microtask_queue, Type type);

~MicrotasksScope(); // 注意这个析构函数

上面这段注释告诉我们,这个类是用来控制 Microtasks 的(当 MicrotasksPolicy::kScoped这个策略被使用的时候,我们在后面会拎出来讲,这里大家先默认 Blink 是设置了这个策略)。

这里的析构函数非常的可疑,因为我们在前面一步发现变量microtasks_scope创建之后并没有在后续的函数里面使用,而析构函数会在变量被销毁时执行。我们继续来看 v8::MicrotasksScope 的定义

v8/src/api/api

MicrotasksScope::~MicrotasksScope() {

if (run_) {

microtask_queue_->DecrementMicrotasksScopeDepth(); // 这里将函数调用栈减少一层

if (MicrotasksPolicy::kScoped==microtask_queue_->microtasks_policy() && // 这里检查策略是否是 MicrotasksPolicy::kScoped

!isolate_->has_scheduled_exception()) {

DCHECK_IMPLIES(isolate_->has_scheduled_exception(),

isolate_->scheduled_exception()== i::ReadOnlyRoots(isolate_).termination_exception());

microtask_queue_->PerformCheckpoint(reinterpret_cast



这篇关于深刻认识:事件循环 Microtasks (微任务) 的运行时机——探索v8源码的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程