引言
浏览器事件循环和渲染帧相关概念对大多数前端开发人员来说是比较耳熟能详的,但本系列将通过chromium源码,html 规范,实际浏览器执行顺序测试等多个角度,展示它们不为人知的一面。
本篇涉及的问题
- 任务队列到底是一个怎样的数据结构(并非 FIFO 的队列)?
- 时间循环真的只是一个js相关的概念吗?
- html规范中的事件循环具体是什么?
下篇涉及的问题
- 微任务会堵塞页面渲染吗?
- raf 注册的回调和宏任务谁先执行?
- raf 内注册大量回调,会堵塞页面渲染吗?
- 微任务是否只在宏任务之后执行?
事件循环(event loop)
从输入 url 开始,浏览器要负责 html 解析,资源文件解析执行,样式计算,布局,渲染,合成,响应用户交互等等多种任务。为了保证浏览器高效运行,需要设计一套合理的机制协调这些任务,这套机制就是事件循环。
从名称都可以得出这是一个循环结构,至于为什么要设计出循环结构主要是和另外一个重要概念任务队列相关,任务队列中会根据用户交互,数据请求等在不确定的时机被添加不同的任务,事件循环则要在任务队列中有任务时,执行任务队列中的任务,所以就设计成了循环结构,以便轮训查找任务。
任务队列在 chromium 中的是如何生成和存储的?
在 chromium 有一个TaskQueueImpl,它内部会有四个主要的队列immediate_incoming_queue,immediate_work_queue,delayed_incoming_queue,delayed_work_queue。两个work_queue中存放的是可以执行的任务,immediate_incoming_queue中存放的任务将在immediate_work_queue清空以后进入immediate_work_queue等待执行。delayed_incoming_queue中的任务,将在延迟时间到期以后进入delayed_work_queue等待执行。而 task 产生以后会进入到相应的incoming_queue中。
task_queue在创建时就会被分配优先级 browserTaskPriority
enum class BrowserTaskPriority
: base::sequence_manager::TaskQueue::QueuePriority {
// Priorities are in descending order.
kControlPriority = 0,
kHighestPriority = 1,
kHighPriority = 2,
kNormalPriority = 3,
kDefaultPriority = kNormalPriority,
kLowPriority = 4,
kBestEffortPriority = 5,
// Must be the last entry.
kPriorityCount = 6,
};
kControlPriority级别的task_queue内任务不会为任何其他优先级task_queue内的任务让路,kBestEffortPriority级别task_queue内的任务则要在其他task_queue都空闲的时候才会被执行。在task_queue被创建以后,它通过TaskQueueSelector.AddQueue方法将两个work_queue添加到了对应的work_queue_sets中。
void TaskQueueSelector::AddQueueImpl(internal::TaskQueueImpl* queue,
TaskQueue::QueuePriority priority) {
#if DCHECK_IS_ON()
DCHECK(!CheckContainsQueueForTest(queue));
#endif
// 两个work_queue进入到对应的work_queue_sets中
delayed_work_queue_sets_.AddQueue(queue->delayed_work_queue(), priority);
immediate_work_queue_sets_.AddQueue(queue->immediate_work_queue(), priority);
#if DCHECK_IS_ON()
DCHECK(CheckContainsQueueForTest(queue));
#endif
}
当然添加到对应 sets 也是有一定逻辑的,它会通过work_queue->GetFrontTaskOrder()获取到work_queue中第一个任务的enqueue order,该值为一个整数,由EnqueueOrderGenerator对象产生,它可以被所有的线程访问,每次调用GenerateNext()都会产生一个新的递增的数字,对于immediate task而言,它在 post 的时候就决定了 order 的数字,而对于delayed task而言,它是在 enqueue 的时候决定的 order,这个 order 命名为OldestTaskEnqueueOrder。
而 task_queue 最终会进入对应 sets 的work_queue_heaps中。work_queue_heaps是一个数组,以当前的work_queue的优先级作为索引,存储元素为则是一个小根堆,堆内元素以OldestTaskEnqueueOrder为key,以当前work_queue为值。work_queue_heaps的长度为6,因为目前task_queue只有6种优先级。基于这种数据结构设计,能从work_queue_heaps得到每个优先级中,头部 task 的 enqueue_order 最小的 work queue。
void WorkQueueSets::AddQueue(WorkQueue work_queue, size_t set_index) {
DCHECK(!work_queue->work_queue_sets());
DCHECK_LT(set_index, work_queue_heaps_.size());
DCHECK(!work_queue->heap_handle().IsValid());
// 这里获取对应work_queue中第一个task的enqueue order。
absl::optional<TaskOrder> key = work_queue->GetFrontTaskOrder();
work_queue->AssignToWorkQueueSets(this);
work_queue->AssignSetIndex(set_index);
if (!key)
return;
bool was_empty = work_queue_heaps_[set_index].empty();
// 在对应权重的heap中插入 {OldestTaskEnqueueOrder:work_queue}
work_queue_heaps_[set_index].insert({key, work_queue});
if (was_empty)
observer_->WorkQueueSetBecameNonEmpty(set_index);
}
work_queue_heaps数据结构如下图所示:
任务队列在 chromium 中的是如何调度的?
上面主要是任务的的生成和存储逻辑,接下来则是其调度逻辑,本章开始说的事件循环每次从任务队列中取出一个任务执行,就发生在这个阶段。
该阶段主要是考虑任务的优先级,任务饥饿次数等。具体顺序是先选择本次要执行任务的优先级,然后选择 immediate or delayed 对应的 sets,由这两点可以确定到上图中具体的 heap,执行 heap 内第一个task_queue的第一个 task。
这部分逻辑可以参考Blink Scheduler(2): Selector,不过这套机制目前应该是在源码中部分重构了,现在使用的是sequence_manager来管理任务调度,而具体的 task_queue 选择方法则在task_queue_selector中。以WorkQueue SelectWorkQueueToService为入口,先获取目前所有可选 task_queue 对应的最高任务队列优先级。
WorkQueue TaskQueueSelector::SelectWorkQueueToService(
SelectTaskOption option) {
DCHECK_CALLED_ON_VALID_THREAD(associated_thread_->thread_checker);
// 获取目前所有可选task_queue对应的最高的 任务队列优先级
auto highest_priority = GetHighestPendingPriority(option);
if (!highest_priority.has_value())
return nullptr;
// Select the priority from which we will select a task. Usually this is
// the highest priority for which we have work, unless we are starving a lower
// priority.
TaskQueue::QueuePriority priority = highest_priority.value();
// For selecting an immediate queue only, the highest priority can be used as
// a starting priority, but it is required to check work at other priorities.
// For the case where a delayed task is at a higher priority than an immediate
// task, HighestActivePriority(...) returns the priority of the delayed task
// but the resulting queue must be the lower one.
// 下文中的chose方法会先找到合适的sets,然后通过 sets和priority找到 task_queue
if (option == SelectTaskOption::kSkipDelayedTask) {
WorkQueue queue =
#if DCHECK_IS_ON()
random_task_selection_
? ChooseImmediateOnlyWithPriority<SetOperationRandom>(priority)
:
#endif
ChooseImmediateOnlyWithPriority<SetOperationOldest>(priority);
return queue;
}
WorkQueue* queue =
#if DCHECK_IS_ON()
random_task_selection_ ? ChooseWithPriority<SetOperationRandom>(priority)
:
#endif
ChooseWithPriority<SetOperationOldest>(priority);
// If we have selected a delayed task while having an immediate task of the
// same priority, increase the starvation count.
if (queue->queue_type() == WorkQueue::QueueType::kDelayed &&
!immediate_work_queue_sets_.IsSetEmpty(priority)) {
immediate_starvation_count_++;
} else {
immediate_starvation_count_ = 0;
}
return queue;
}
}
有了优先级以后,在相关 chose 方法(会考虑饥饿等待等情况)内选择一个 sets,继而通过 sets 和 priority 选择 task_queue。这里只看下其中SetOperationOldest是如何执行的。
struct SetOperationOldest {
static absl::optional<WorkQueueAndTaskOrder> GetWithPriority(
const WorkQueueSets& sets,
TaskQueue::QueuePriority priority) {
// 获取sets内对应priority下,task_queue第一个task等待最久的task_queue,其实就是取其小根堆的第一个元素。
return sets.GetOldestQueueAndTaskOrderInSet(priority);
}
};
至此,task 生成和调度就算是结束了。现在可以回答下前言中出现的一个问题: 任务队列是否为一个先进先出队列,这个问题主要源自于 html 规范中关于task-queue定义中说其不是一个queue的疑问。答案其实是很明确的,任务队列不是常规意义上的队列,而是一个很复杂的数据结构,真要说是什么结构的话,可以说是两个 sets:immediate_work_queue_sets_和delayed_work_queue_sets_。
同时这里也可以回答下引言中的前两个问题: 第一个关于task queue的问题,上文已经大篇幅的讲述了其原理了,同时也能得出时间循环并非一个纯js或者js引擎相关的概念。
html 规范中的事件循环
讲完任务队列是如何生成和调度以后,下一个步骤就是 task 是如何执行的了。其具体步骤在event-loop-processing-model规范中有着详尽的描述,下文主要内容就是逐条解析相应规范,并加入相关代码示例或者浏览器 performace 截图。不过在进入规范解读前先了解几个概念。
微任务 microtask
微任务的出现要涉及到一个很核心的开发需求:监听 dom 变化。
不妨设想下这个需求如何实现,在不考虑原生事件的基础上,基本就是轮训这一个方案。但轮训方案是做不到精确监听的。谁也不知道 dom 什么时候会变化,所以轮训间隔时间就难以选择,长了检测不到变化,短了又是性能浪费,而且如果有高优先级任务插入并且再次修改 dom,哪怕时间间隔为 0,定时器也是检测不到第一次修改的。
于是浏览器引入了 Mutation Event,Mutation Event 采用了观察者的设计模式,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调。
for (let i = 0; i < 100; i++) {
const span = document.createElement("span");
document.body.appendChild(span); // 100次
span.textContent = "hello" + i; // 100次
}
上述代码将要 log 200 次,因为插入有 100 次,修改 textContent 又会有 100 次,而这只是个很常规的简单操作,所以这个 api 的设计是比较失败的。于是我们希望能够有一种方案,在本次 task 操作完成以后,只通知开发人员一次 dom 插入事件,这得是一个异步方案。但是目前的定时器异步方案是行不通的,得有个优先级非常高的任务来实现 dom 监听,然后就有了微任务。
微任务在perform a microtask checkpoint时被执行,而这个checkPoint算法则会在每次任务调用栈被清空时执行。而新式的 dom 监听 api MutationObserver正是一个这样的微任务,除此之外Promise也可以产生微任务,在执行到then时挂载,在执行resolve时进入队列排队。而排列微任务的队列,就是微任务队列。
完全激活文档 fully-active-documents
之所以单独拿出来这个概念讲一下,是因为在后续规范中会多次出现这个概念,可以简单理解成当前 tab 对应的 html document。即当前正在浏览的 tab 对应的页面。如果该页面中有在展示的 iframe,那这个 iframe 也是完全激活的。fully-active-documents 可以响应各种交互,而且其内部的各种延迟任务执行时机(定时器,raf 回调,ridle 回调等)也和未完全激活文档不同。
事件循环内置成员变量
currently running task: 当前正在执行的 task,初始值为null
performing a microtask checkpoint: 一个布尔值,用来标记当前是否在处理微任务,如果值为 true,则不会在处理新的微任务时在此运算消耗较高的perform a microtask checkpoint算法。
last render opportunity time: 上一次渲染时间,一个高精度时间变量,初始值为 0,在idle过期时间计算时会用到
last idle period start time: 上一次 idle 开始时间,一个高精度时间变量,初始值为 0,在idle过期时间计算时会用到
requestAnimationFrame
浏览器定义的 api,可以调用以注册回调,回调函数会在页面渲染前执行,俗称 js 最后操作 dom 的机会。如果在回调内在通过requestAnimationFrame注册回调,其新注册的回调将在下一帧渲染前执行。
requestidlecallback
浏览器定义的 api,可以调用以注册回调,回调函数会在浏览器空闲时执行,如果在回调内在通过requestIdleCallback注册回调,其新注册的回调会在下一次空闲期(Idle Period)内执行。后续有章节会专门测试和讲解空闲期和 idle callbacks 执行时机。
标注式解读事件循环规范
事件循环只要存在,就会不断执行以下步骤
-
清空
oldestTask和taskStartTime。 -
如果任务队列(仅指宏任务队列,也就是上面分析的两个 sets)中有可以执行的任务,那么执行如下逻辑:
- 选中 task_queue。可以理解成上文中的
sets.GetOldestQueueAndTaskOrderInSet(priority); - 设置
taskStartTime为unsafe shared current time,主要是一个高精度的当前时间, - 从
task_queue中出队第一个任务,并将其设置为oldestTask。结合上面的 task 产生,存储,调度过程,这里才是真正取出待执行的 task。 - 将事件循环的
currently running task设置为oldestTask - 执行
oldestTask - 将事件循环的
currently running task设置为null
- 选中 task_queue。可以理解成上文中的
-
Perform a microtask checkpoint,执行微任务检查点算法。这里其实就是去执行并清空微任务队列。微任务队列可能涉及微任务自己产生微任务,不同类型微任务如何排队等多种情况,是一定要掌握的知识点。详细解释可以看上一小节中内置成员变量
performing a microtask checkpoint。 -
设置
hasARenderingOpportunity值为false,这个值主要是控制本轮事件循环是否可以进入渲染逻辑。其具体应用在第 7 步 update-the-rendering 中。 -
设置
now为当前高精度时间。 -
如果当前
oldestTask现在不为null,此时我们认为这是一个长任务,处理脚本执行上下文。并报告这是一个长任务,这里会用到第五步的taskStartTime,now,context(上下文),以及oldestTask。 -
更新渲染,如果当前循环是一个 window 事件循环(worker 中也有事件循环),则进入以下逻辑:
- 整理
docs变量,它需要包含本 event loop 中所有的 document。这里我个人理解多个 doc 是存在至少一个 iframe 的情况。 - Rendering opportunities: Remove from
docsallDocumentobjects whose node navigables do not have a rendering opportunity.这一步是比较重要的。主要用来确定本轮事件循环相应的文档是否需要进入绘制渲染相关阶段。具体逻辑就是判断rendering-opportunity布尔值。该值由屏幕刷新率(常见为 60hz,即每 16.6ms 刷新一次),页面 tab 是否处于激活状态(非激活 tab 可能 1s 只刷新 4 次或者更低),当前页面是否处于堵塞中等多个状态控制。一般在固定时间间隔变为true。如果该 doc 没有渲染机会,就从 docs 数组中删除。 - 如果 docs 不为空,将
hasARenderingOpportunity设置为 true.并将事件循环成员变量last render opportunity time的值设置为本次 task 开始执行时的时间taskStartTime。 - 非必要渲染情况。从 docs 中移除所有没有必要渲染的 document。如果本次更新渲染文档不会产生任何可见效果,并且文档的 requestAnimationFrame 回调列表为空,那本次更新渲染就属于没必要的更新。
- 从 docs 中移除浏览器因其他原因认为没有必要更新的 document。例如,这可能是因为文档处于非活动状态,因此不需要更新它的呈现;或者文档的呈现已经处于最新状态,因此不需要进行额外的更新。此外,浏览器还可能在执行特定的优化策略时选择跳过某些文档的呈现更新
- 对 docs 中的激活文档,执行 autoFocus 相关逻辑。
- 对 docs 中的激活文档,执行 resize 相关逻辑,发出 resize 事件等
- 对 docs 中的激活文档,执行滚动相关逻辑,发出相关事件
- 对 docs 中的激活文档,执行媒体查询相关逻辑,发出相关事件
- 对 docs 中的激活文档,更新动画和发出相关事件
- 对 docs 中的激活文档,处理全屏相关逻辑
- 对 docs 中的激活文档,处理 canvas 相关逻辑
- 对 docs 中的激活文档,执行 requestAnimationFrame 注册的所有回调函数
- 对 docs 中的激活文档:
- Recalculate styles and update layout for
doc.重新计算样式和更新布局 - 设置 depth 为 0,该变量用于记录元素在文档树中的深度。
- 收集深度为 depth 的 doc 的 active resize observations。active resize observations 是指文档中元素大小发生变化时的观察记录。这些记录包括元素的位置、大小和可见性等信息。通过收集和处理这些记录,浏览器可以重新计算布局并更新样式,从而确保元素的正确定位和可见性。
- 收集所有 depth 下对应文档变化的记录。
- Recalculate styles and update layout for
- 处理 focus 相关问题,修复可能出现的聚焦问题。
- 执行 IntersectionObserver 相关步骤,触发器内部注册的回调。
- 计算绘制时间
- 对 docs 中的激活文档,更新渲染。
- 整理
-
在当前 window event loop 没有要执行的
task,也没有要执行的microtask,并且hasARenderingOpportunity值为false的情况下,进入以下逻辑:- 将事件循环的成员变量
last idle period start time设置为当前高精度时间。 - 计算 deadline:
- 将 deadline 设置为
last idle period start time+50。这里加入 50ms 主要是因为如果在idleTask开启的瞬间有用户交互任务进来,最多50ms之后,浏览器就可以响应交互。而人对于页面卡顿的感知在100ms左右,也就是还剩余50ms时间执行紧急插入的高优先级任务。 - 判断当前是否有挂起的定时任务。计算距离定时任务执行的时间
timeoutDeadline,如果deadline大于timeoutDeadline,将timeoutDeadline的值赋给deadline - 如果有渲染任务,例如本帧中有
raf的回调,计算出下次渲染时间nextRenderDeadline,计算方式为使用事件循环成员变量last render opportunity time加上屏幕正常刷新间隔时间,例如 60hz 刷新率的屏幕,就加上 16.6s. - 如果
deadline大于nextRenderDeadline,将nextRenderDeadline的值赋给deadline
- 将 deadline 设置为
- 执行start an idle period algorithm算法,传入 deadline,该算法会执行 requestIdleCallback 注册的回调,如果注册了多个回调,在时间允许的情况下,会把注册的多个回调都执行掉。其内部也是一个
task_queue而且对应的正是最低优先级kBestEffortPriority。
- 将事件循环的成员变量
至此事件循环相关概念都已经阐述完毕,不过规范解读多少有点松散,现在整合一下,步骤可能会和规范中不太一致:
- event loop 从响应 work_sets 中选择一个 task 并执行
- task 执行完毕以后,清空微任务队列。要注意的是 js call stack 为空时,就会去执行微任务。
- 微任务队列清空以后,会尝试去执行渲染更新,不过这个和屏幕刷新率以及浏览器 performance 策略有关,常规来说,每 16.6ms 才会触发一次,触发的时候会执行
raf内注册的回调,并进行布局,绘制,合成,光栅化,渲染等步骤 - 如果此次 loop 不是渲染 loop,并且此时没有要执行的宏任务和微任务,那么浏览器会执行
requestIdleCallback内注册的回调,其 deadline 时间在 50ms,可预测的下次渲染任务时间和可预测的定时器激活时间三者中取最小值。
本篇结语
本文初始其实没有拆分上下篇,但是篇幅过长,而且上下两部分内容相对来说侧重点各不相同,遂决定拆为上下两篇,上篇主要参考资料和源码介绍事件循环,下篇则偏重于渲染帧以及各种任务先后顺序的具体分析和测验。下篇详情请查阅:
你不知道的事件循环和渲染帧(下)
参考
- Jake Archibald: IN THE LOOP - JSConf.Asia 看到的最好的讲解事件循环的视频,首图就出自于此
- Blink Scheduler (1):Task Queue 正文第一章图出自于此,啥都不用说,膜拜吧
- Blink Scheduler(2): Selector 啥都不用说,膜拜吧 2
- 浏览器工作原理与实践 前端不可错过的浏览器课程
- requestidlecallbackrequestidlecallback定义
- requestAnimationFramerequestAnimationFrame定义
- event-loop-processing-modelhtml规范中事件循环执行逻辑定义
- chromium浏览器源码