浏览器中的页面循环系统

浏览器中的页面循环系统

 

目录

  • 浏览器中的消息队列

  • 浏览器中的延迟队列

  • V8 引擎中的微任务队列

  • 总结

 

浏览器中的消息队列

我们先从最简单的场景来分析,然后一步步了解浏览器页面主线程是如何运作的,从中体会浏览器为什么要这样设计。

 

使用单线程处理安排好的任务

我们先从最简单的场景讲起,比如有如下一系列的任务:

  • 任务 1:1+2

  • 任务 2:20/5

  • 任务 3:7*8

  • 任务 4:打印出任务 1、任务 2、任务 3 的运算结果

现在要在一个线程中去执行这些任务,通常我们会这样编写代码(由于官方源码是用C++语言,所以下面代码都是C++进行示范):

void MainThread(){

     int num1 = 1+2; //任务1

     int num2 = 20/5; //任务2

     int num3 = 7*8; //任务3

     print("最终计算的值为:%d,%d,%d",num,num2,num3); //任务4

  }

在上面的执行代码中,我们把所有任务代码按照顺序写进主线程里,等线程执行时,这些任务会按照顺序在线程中依次被执行;等所有任务执行完成之后,线程会自动退出。可以参考下图来直观地理解下其执行过程:

 

在线程运行过程中处理新任务

但并不是所有的任务都是在执行之前统一安排好的,大部分情况下,新的任务是在线程运行过程中产生的。比如一个计算器的交互,在线程执行过程中,又接收到用户想要执行的一个新的任务要求计算“10+2”,那上面那种方式就无法处理这种情况了。要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制。我们可以通过一个 for 循环语句来监听是否有新的任务,如下面的示例代码:

//GetInput
//等待用户从键盘输入一个数字,并返回该输入的数字
int GetInput(){ 
        int input_number = 0; 
        cout<<"请输入一个数:"; 
        cin>>input_number; 
        return input_number;
}
//主线程(Main Thread)
void MainThread(){ 
        for(;;){ 
                int first_num = GetInput(); 
                int second_num = GetInput(); 
                result_num = first_num + second_num; print("最终计算的值为:%d",result_num); 
        }
}

相较于第一版的线程,这一版的线程做了两点改进。第一点引入了循环机制,具体实现方式是在线程语句添加了一个 for 循环语句,线程会一直循环执行。第二点是引入了事件,可以在线程运行过程中,等待用户输入的数字,等待过程中线程处于暂停状态,一旦接收到用户输入的信息,那么线程会被激活,然后执行对应的运算,最后输出结果。通过引入事件循环机制,就可以让该线程“活”起来了,我们每次输入两个数字,都会打印出两数字执行的结果

 

 

处理其他线程发送过来的任务

上面我们改进了线程的执行方式,引入了事件循环机制,可以让其在执行过程中接受新的任务。不过在第二版的线程模型中,所有的任务都是来自于线程内部的,如果另外一个线程想让主线程执行一个任务,利用第二版的线程模型是无法做到的。那下面我们就来看看其他线程是如何发送消息给渲染主线程的,如下图所示:

从上图可以看出,渲染主线程会频繁接收到来自于 IO 线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理,比如接收到资源加载完成的消息后,渲染进程就要着手进行 DOM 解析了;接收到鼠标点击的消息后,渲染主线程就要开始执行相应的 JavaScript 脚本来处理该点击事件。那么如何设计好一个线程模型,能让其能够接收其他线程发送的消息呢?一个通用模式是使用消息队列。在解释如何实现之前,我们先说说什么是消息队列,可以参考下图:

从图中可以看出,消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。有了队列之后,我们就可以继续改造线程模型了,改造方案如下图所示:

从上图可以看出,我们的改造可以分为下面三个步骤:添加一个消息队列;IO 线程中产生的新任务添加进消息队列尾部;渲染主线程会循环地从消息队列头部中读取任务,执行任务。有了这些步骤之后,那么接下来我们就可以按步骤使用代码来实现第三版的线程模型。首先,构造一个队列。

class TaskQueue{
        public: Task takeTask(); //取出队列头部的一个任务 
        void pushTask(Task task); //添加一个任务到队列尾部
};

接下来,改造主线程,让主线程从队列中读取任务:

TaskQueue task_queue;
void ProcessTask();
void MainThread(){
         for(;;){
                 Task task = task_queue.takeTask(); 
                 ProcessTask(task); 
        }
}

在上面的代码中,我们添加了一个消息队列的对象,然后在主线程的 for 循环代码块中,从消息队列中读取一个任务,然后执行该任务,主线程就这样一直循环往下执行,因此只要消息队列中有任务,主线程就会去执行。主线程的代码就这样改造完成了。这样改造后,主线程执行的任务都全部从消息队列中获取。所以如果有其他线程想要发送任务让主线程去执行,只需要将任务添加到该消息队列中就可以了,添加任务的代码如下:

Task clickTask;
task_queue.pushTask(clickTask)

 

处理其他进程发送过来的任务

通过使用消息队列,我们实现了线程之间的消息通信。在 Chrome 中,跨进程之间的任务也是频繁发生的,那么如何处理其他进程发送过来的任务?

从图中可以看出,渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程,后续的步骤就和前面讲解的“处理其他线程发送的任务”一样了

 

浏览器中的延迟队列

通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,所以不能将定时器的回调函数直接添加到消息队列中。

那么该怎么设计才能让定时器设置的回调事件在规定时间内被执行呢?

在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。

DelayedIncomingQueue delayed_incoming_queue;

当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数 showName、当前发起时间、延迟执行时间,其模拟代码如下所示:

struct DelayTask{
        int64 id; 
        CallBackFunction cbf; 
        int start_time; 
        int delay_time;
};
DelayTask timerTask;
timerTask.cbf = showName;
timerTask.start_time = getCurrentTime(); //获取当前时间
timerTask.delay_time = 200;//设置延迟执行时间

创建好回调任务之后,再将该任务添加到延迟执行队列中,代码如下所示:

delayed_incoming_queue.push(timerTask);

现在通过定时器发起的任务就被保存到延迟队列中了,那接下来我们再来看看消息循环系统是怎么触发延迟队列的。我们可以来完善消息循环的代码,在其中加入执行延迟队列的代码,如下所示:

void ProcessTimerTask(){        
        //从delayed_incoming_queue中取出已经到期的定时器任务
        //依次执行这些任务
}
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
        for(;;){
                //执行消息队列中的任务 
                Task task = task_queue.takeTask(); 
                ProcessTask(task);
        
                //执行延迟队列中的任务 
                ProcessDelayTask() 
                if(!keep_running) //如果设置了退出标志,那么直接退出线程循环 
                        break; 
        }
}

从上面代码可以看出来,我们添加了一个 ProcessDelayTask 函数,该函数是专门用来处理延迟执行任务的。这里我们要重点关注它的执行时机,在上段代码中,处理完消息队列中的一个任务之后,就开始执行 ProcessDelayTask 函数。ProcessDelayTask 函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。通过这样的方式,一个完整的定时器就实现了。

设置一个定时器,JavaScript 引擎会返回一个定时器的 ID。那通常情况下,当一个定时器的任务还没有被执行的时候,也是可以取消的,具体方法是调用 clearTimeout 函数,并传入需要取消的定时器的 ID。如下面代码所示:

clearTimeout(timer_id)

其实浏览器内部实现取消定时器的操作也是非常简单的,就是直接从 delayed_incoming_queue 延迟队列中,通过 ID 查找到对应的任务,然后再将其从队列中删除掉就可以了。

 

V8 引擎中的微任务队列

随着浏览器的应用领域越来越广泛,消息队列中这种粗时间颗粒度的任务已经不能胜任部分领域的需求,所以又出现了一种新的技术——微任务。微任务可以在实时性和效率之间做一个有效的权衡。

 

微任务已经被广泛地应用,基于微任务的技术有 MutationObserver、Promise 以及以 Promise 为基础开发出来的很多其他的技术。所以微任务的重要性也与日俱增,了解其底层的工作原理对于你读懂别人的代码,以及写出更高效、更具现代的代码有着决定性的作用。

有微任务,也就有宏任务,那这二者到底有什么区别?

 

宏任务

宏任务前面我们已经介绍过了,页面中的大部分任务都是在主线程上执行的,这些任务包括了:渲染事件(如解析 DOM、计算布局、绘制);用户交互事件(如鼠标点击、滚动页面、放大缩小等);JavaScript 脚本执行事件;网络请求完成、文件读写完成事件。为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务。

 

宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以胜任了,下面我们就来分析下为什么宏任务难以满足对时间精度要求较高的任务。

 

页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。意思就是,消息队列中就有可能被插入很多系统级的任务。所以如果中间被插入的任务执行时间过久的话,那么就会影响到后面任务的执行了。

 

所以说宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如后面要介绍的监听 DOM 变化的需求。

 

微任务

在理解了宏任务之后,下面我们就可以来看看什么是微任务了,我们先了解一下以下两种异步回调的方式:

 

第一种是把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数。这种比较好理解,比如setTimeout 和 XMLHttpRequest 的回调函数都是通过这种方式来实现的。

 

第二种方式的执行时机是在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的。那这里说的微任务到底是什么呢?微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。不过要搞清楚微任务系统是怎么运转起来的,就得站在 V8 引擎的层面来分析下。

 

我们知道当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。顾名思义,这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给 V8 引擎内部使用的,所以你是无法通过 JavaScript 直接访问的。也就是说每个宏任务都关联了一个微任务队列。

 

那么接下来,我们就需要分析两个重要的时间点——微任务产生的时机和执行微任务队列的时机。我们先来看看微任务是怎么产生的?在现代浏览器里面,产生微任务有两种方式。

 

第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。

 

第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

 

通过 DOM 节点变化产生的微任务或者使用 Promise 产生的微任务都会被 JavaScript 引擎按照顺序保存到微任务队列中。现在微任务队列中有了微任务了,那接下来就要看看微任务队列是何时被执行的。通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。我们把执行微任务的时间点称为检查点。如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

微任务添加和执行流程示意图:

 

该示意图是在执行一个 ParseHTML 的宏任务,在执行过程中,遇到了 JavaScript 脚本,那么就暂停解析流程,进入到 JavaScript 的执行环境。从图中可以看到,全局上下文中包含了微任务列表。在 JavaScript 脚本的后续执行过程中,分别通过 Promise 和 removeChild 创建了两个微任务,并被添加到微任务列表中。接着 JavaScript 执行结束,准备退出全局执行上下文,这时候就到了检查点了,JavaScript 引擎会检查微任务列表,发现微任务列表中有微任务,那么接下来,依次执行这两个微任务。等微任务队列清空之后,就退出全局执行上下文。

 

 

监听 DOM 变化方法演变

MutationObserver 是用来监听 DOM 变化的一套方法。虽然监听 DOM 的需求是如此重要,不过早期页面并没有提供对监听的支持,所以那时要观察 DOM 是否变化,唯一能做的就是轮询检测,比如使用 setTimeout 或者 setInterval 来定时检测 DOM 是否有改变。这种方式简单粗暴,但是会遇到两个问题:如果时间间隔设置过长,DOM 变化响应不够及时;反过来如果时间间隔设置过短,又会浪费很多无用的工作量去检查 DOM,会让页面变得低效。

 

直到 2000 年的时候引入了 Mutation Event,Mutation Event 采用了观察者的设计模式,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调。采用 Mutation Event 解决了实时性的问题,因为 DOM 一旦发生变化,就会立即调用 JavaScript 接口。但也正是这种实时性造成了严重的性能问题,因为每次 DOM 变动,渲染引擎都会去调用 JavaScript,这样会产生较大的性能开销。比如利用 JavaScript 动态创建或动态修改 50 个节点内容,就会触发 50 次回调,而且每个回调函数都需要一定的执行时间,这里我们假设每次回调的执行时间是 4 毫秒,那么 50 次回调的执行时间就是 200 毫秒,若此时浏览器正在执行一个动画效果,由于 Mutation Event 触发回调事件,就会导致动画的卡顿。

 

也正是因为使用 Mutation Event 会导致页面性能问题,所以 Mutation Event 被反对使用,并逐步从 Web 标准事件中删除了。为了解决了 Mutation Event 由于同步调用 JavaScript 而造成的性能问题,从 DOM4 开始,推荐使用 MutationObserver 来代替 Mutation Event。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。那么相比较 Mutation Event,MutationObserver 到底做了哪些改进呢?

 

首先,MutationObserver 将响应函数改成异步调用,可以不用在每次 DOM 变化都触发异步调用,而是等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。这样即使频繁地操纵 DOM,也不会对性能造成太大的影响。我们通过异步调用和减少触发次数来缓解了性能问题,那么如何保持消息通知的及时性呢?如果采用 setTimeout 创建宏任务来触发回调的话,那么实时性就会大打折扣,因为上面我们分析过,在两个任务之间,可能会被渲染进程插入其他的事件,从而影响到响应的实时性。这时候,微任务就可以上场了,在每次 DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8 引擎就会按照顺序执行微任务了。

 

 

总结

  • 如果有一些确定好的任务,可以使用一个单线程来按照顺序处理这些任务,这是第一版线程模型。

  • 要在线程执行过程中接收并处理新的任务,就需要引入循环语句和事件系统,这是第二版线程模型。

  • 如果要接收其他线程发送过来的任务,就需要引入消息队列,这是第三版线程模型。

  • 如果其他进程想要发送任务给页面主线程,那么先通过 IPC 把任务发送给渲染进程的 IO 线程,IO 线程再把任务发送给页面主线程。

  • 为了支持定时器的实现,浏览器增加了延时队列。但由于消息队列排队和一些系统级别任务的限制,通过 setTimeout 设置的回调任务并非总是可以实时地被执行。

  • 消息队列机制并不是太灵活,为了适应效率和实时性,引入了时间粒度更小的微任务。

 

页面循环系统官方例子

https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/?utm_source=html5weekly

 

Chromium 官方源码参考

https://cs.chromium.org/chromium/src/third_party/blink/public/platform/web_url_loader_mock_factory.h?g=0

 

参考文献及图片出处:

https://time.geekbang.org/column/article/132931

 

 

 

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
 Toucher触摸屏浏览器软件界面美观大方,触摸查询导航方便,程序对外接口丰富,提供了在触摸屏一体机上使用的丰富实用功能:网站浏览、退出密码保护、背景音乐播放、自动关机、自动返回首页、访问网站限制、隐藏滚动条、屏幕软键盘感应启动、导航条多种风格、循环网页展示、隐藏浏览器导航条等。   Toucher触摸屏浏览器是专门针对触摸屏一体机应用而开发的,用于在触摸屏一体机上浏览网页和网站。该浏览器基于IE内核,提供访问本地及远程网页功能,具备触摸屏应用所需要的各种实用特性,提供了丰富的应用程序接口,方便与第三方程序协同工作,并且提供了很多用户定义功能,满足用户的各种实际需求。主要功能有:   · 触摸屏浏览导航。前进、后退、刷新、停止、首页、页面滚动等导航按钮使用方便快捷。   · 触摸屏软键盘。快速的输入英文,用户可根据自己喜好选择系统任意输入法进行输入。   · 触摸屏上方便快捷退出软件。提供隐藏的在触摸屏上退出软件的方式,并支持密码保护,防止误操作。   · 多种导航条风格。自带三种不同的导航条风格,尽最大可能与所浏览的网站风格匹配。   · 自动返回首页,定时关机,周末不开机,自动改变屏幕分辨率,禁止图像工具栏,禁止屏幕保护,延时启动软件。
您的浏览器很重要。 用Vivaldi掌控一切。 我们的生活离不开浏览器。选择一款有您所需的功能,适合您的风格且有您认同的价值观的浏览器。 今天小编要给大家介绍一款全新好用的浏览器软件——Vivaldi浏览器。Vivaldi浏览器由Opera前CEO基于Chromium/Blink内核开发,支持Windows、Mac和Linux等多种操作系统,软件支持鼠标手势浏览和用户们熟悉的“快速拨号”(Speed Dial)界面,可以在新页面上显示用户最喜爱的页面,还可以将多个页面整合成一个让你更好的浏览器整个网站,并且你还可以直接在页面上做笔记下次再浏览到这个网页时可以看到上次做的笔记,使用非常方便。是用户实现网页浏览功能的好帮手。 vivaldi浏览器特色介绍 定制的一切 我们都是独特的,我们要的是不同的东西。维瓦尔第适应你,而不是周围的其他方法。你喜欢的标签放置在底部或侧面的窗口?也许你喜欢不同的 地址栏的位置吗?与维瓦尔第你可以自定义所有的,以及更多,比如你的键盘快捷键,鼠标手势等。 自适应接口 维瓦尔第的颜色方案将使你浏览网页,就像变色龙适应环境。一个干净的和现代的外观和内容。facebook将浏览器的蓝色,边缘会红-如果你希望如此。 标签栈 太多的散乱的标签会很快变得有点乱,很难找到你要找的标签。组织你的标签使用标签栈!他们让你组一个选项卡下的多标签。只需拖动标签到另一个简单的分组。 空间导航 不需要TAB循环通过网页内容了当你可以用箭头键移动各轴。只是你的键盘快速获取网页上的任何地方。 页面平铺 有时你想比较两个或两个以上的页面呢?你可以选择显示或堆叠标签并排或网格布局。有一个高分辨率的显示器?尝试页面平铺,很方便。 Web技术 一个独特的东西,使得Vivaldi是建立在现代网络技术。我们使用JavaScript创建与Node.js的帮助和一长串的NPM模块用户界面反应。维瓦尔第是网络与网络的建立。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值