浏览器层面优化前端性能:Chrom组件与进程/线程模型分析

Posted zhoulujun

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浏览器层面优化前端性能:Chrom组件与进程/线程模型分析相关的知识,希望对你有一定的参考价值。

Chrome支持以下几种进程模型:Process-per-site-instance Process-per-site Process-per-tab Single Process。Browser只有一个,主控整个系统的运行,管理Chrome大部分的日常事务;而Renderer则可以有多个,主要负责页面的渲染和显示。

现阶段的浏览器运行在一个单用户,多合作,多任务的操作系统中。一个糟糕的网页同样可以让一个现代的浏览器崩溃。其原因可能是一个插件出现bug,最终的结果是整个浏览器以及其他正在运行的标签被销毁。

现代操作系统已经非常健壮了,它让应用程序在各自的进程中运行和不会影响到其他程序。一个进程崩溃不会损害到其他进程以及操作系统。同时系统会严格的限制一个用户访问另外一个用户空间的数据。

关于进程、线程、多线程等相关知识回顾,参看《同步与异步:并发/并行/进程/线程/多cpu/多核/超线程/管程

浏览器属于一个应用程序,而应用程序的一次执行,可以理解为计算机启动了一个进程,进程启动后,CPU会给该进程分配相应的内存空间,当我们的进程得到了内存之后,就可以使用线程进行资源调度,进而完成我们应用程序的功能。

而在应用程序中,为了满足功能的需要,启动的进程会创建另外的新的进程来处理其他任务,这些创建出来的新的进程拥有全新的独立的内存空间,不能与原来的进程内向内存,如果这些进程之间需要通信,可以通过IPC机制(Inter Process Communication)来进行。

假如我们去开发一个浏览器,它的架构可以是一个单进程多线程的应用程序,也可以是一个使用IPC通信的多进程应用程序。

以chrome为例,使用IPC通信的多进程应用程序

chrome浏览器与其他浏览器不同,chrome使用多个渲染引擎实例,每个Tab页一个,即每个Tab都是一个独立进程。

浏览器组件

浏览器大体上由以下几个组件组成,各个浏览器可能有一点不同。

  • 界面控件 – 包括地址栏,前进后退,书签菜单等窗口上除了网页显示区域以外的部分

  • 浏览器引擎 – 查询与操作渲染引擎的接口

  • 渲染引擎 – 负责显示请求的内容。比如请求到HTML, 它会负责解析HTML、CSS并将结果显示到窗口中

  • 网络 – 用于网络请求, 如HTTP请求。它包括平台无关的接口和各平台独立的实现

  • UI后端 – 绘制基础元件,如组合框与窗口。它提供平台无关的接口,内部使用操作系统的相应实现

  • JS解释器 - 用于解析执行JavaScript代码

  • 数据存储持久层 - 浏览器需要把所有数据存到硬盘上,如cookies。新的HTML5规范规定了一个完整(虽然轻量级)的浏览器中的数据库web database

Chrome的并发模型

chrome的进程,chrome没有采用一般应用程序的单进程多线程的模型,而是采用了多进程的模型,按照他的文字说明,主界面框架下的一个TAB就对应这个一个进程。但实际上,一个进程不仅仅包含一个页面,实际上同类的页面在共用一个进程。

Google在宣传的时候一直都说,Chrome是one tab one process的模式。实际上,Chrome支持的进程模型远比宣传丰富,简单的说,Chrome支持以下几种进程模型:

  • Process-per-site-instance:就是你打开一个网站,然后从这个网站链开的一系列网站都属于一个进程。这是Chrome的默认模式。

  • Process-per-site:同域名范畴的网站放在一个进程,比如www.google.com和www.google.com/bookmarks就属于一个域名内(google有自己的判定机制),不论有没有互相打开的关系,都算作是一个进程中。用命令行–process-per-site开启。

  • Process-per-tab:这个简单,一个tab一个process,不论各个tab的站点有无联系,就和宣传的那样。用–process-per-tab开启。

  • Single Process:这个很熟悉了吧,即传统浏览器的模式:没有多进程只有多线程,用–single-process开启。

多进程有好处:

把渲染放到另外个进程防止崩溃了影响主进程。webkit最初时候很多内存泄露。多进程能很大程度避免。一个进程关了,所有内存就回收了。其次,多进程安全性更好。如果blink被发现什么提权漏洞,例如写一段js就能控制整个chromium进程做任何事情,显然多进程可以把损失限制在渲染线程。渲染线程拿不到主进程的各种私密信息,例如别的域名下的密码。

多线程模型

chrome进程模型下有

  • Browser进程只有一个,主控整个系统的运行,管理Chrome大部分的日常事务;

    负责浏览器页面的显示,各个页面的管理,所有其他类型进程的祖先,负责他们的创建和销毁。

    • UI thread:

      • 负责浏览器界面显示,与用户交互。如前进,后退等

      • 将Renderer进程得到的内存中的Bitmap,绘制到用户界面上

    • network thread:网络资源的管理,下载等(这个和网络进程间???)

    • storage thread: 控制文件等的访问;

  • Renderer 浏览器渲染进程(浏览器内核),主要负责页面的渲染和显示:页面渲染,脚本执行,事件处理等

    默认每个Tab页面一个Renderer进程(Renderer进程,内部是多线程的)

  • Utility Network:网络进程,负责页面网络资源的加载

  • GPU进程:最多只有一个,GPU硬件加速打开时才会被创建,用于3D绘制等。

  • NPAPI或PPAPI插件进程,每种类型的插件对应一个进程,仅当使用该插件时才创建。

    1995 年 Netscape 发明了NPAPI (Netscape plugin API)这个种架构,来帮助浏览器渲染一些HTML没有的东西。比如 PDF, 比如 视频, 以及等等。NPAPI不限制插件自由访问系统所有的API,而且和浏览器是平级运行的。现在已被禁用。 PPAPI是谷歌提出的架构。

  • Pepper插件进程

  • 其他类型的进程,比如Linux的Zygote进程;Sandbox进程。

Browser作为主进程最先启动,Browser包含一个主线程mainthread,在mainthread中对整个系统进行初始化,并启动为另外几个线程,看下面的代码:

void CreateChildThreads(BrowserProcessImpl* process)

  process->db_thread(); //负责数据库处理

  process->file_thread(); // 负责文件管理

  process->process_launcher_thread();

  process->cache_thread(); //负责管理缓存

  process->io_thread(); //负责管理进程间通信和所有I/O行为。

io_thread不仅负责Browser进程的I/O,而且其他Renderer的I/O请求也会通过进程间通信发送到这个线程,由该线程进行处理,最后把结果在返回给各个Renderer进程。各个线程的功能不一样,但设计模式是一样的

 

对于Renderer进程,它们通常有两个线程:一个是Main thread,负责与主线程联系。另一个是Render thread,它们负责页面的渲染和交互

当我们是要浏览一个网页,我们会在浏览器的地址栏里输入URL,这个时候Browser Process会向这个URL发送请求,获取这个URL的HTML内容,然后将HTML交给Renderer Process,Renderer Process解析HTML内容,解析遇到需要请求网络的资源又返回来交给Browser Process进行加载,同时通知Browser Process,需要Plugin Process加载插件资源,执行插件代码。解析完成后,Renderer Process计算得到图像帧,并将这些图像帧交给GPU Process,GPU Process将其转化为图像显示屏幕。

 

Chrome的线程模型

Chrome的线程模型极力规避锁的存在,将锁限制了极小的范围内(仅仅在将Task放入消息队列的时候才存在…),并且使得上层完全不需要关心锁的问题(当然,前提是遵循它的编程模型,将函数用Task封装并发送到合适的线程去执行…),大大简化了开发的逻辑。它用到了消息循环的手段。每一个Chrome的线程,入口函数都差不多,都是启动一个消息循环(参见MessagePump类),等待并执行任务。

根据线程处理事务类别的不同,所起的消息循环有所不同。比如

  • 处理进程间通信的线程(注意,在Chrome中,这类线程都叫做IO线程)

  • 启用的是MessagePumpForIO类,处理UI的线程用的是MessagePumpForUI类,

  • 一般的线程用到的是MessagePumpDefault类(只讨论windows)。

不同的消息循环类,主要差异有两个,一是消息循环中需要处理什么样的消息和任务,第二个是循环流程(比如是死循环还是阻塞在某信号量上…)。

浏览器通常由以下常驻线程组成:

  • GUI 渲染线程
    GUI渲染线程负责渲染浏览器界面HTML元素,当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。在Javascript引擎运行脚本期间,GUI渲染线程都是处于挂起状态的,也就是说被冻结了.

    • 一个主线程(main thread)

    • 多个工作线程(work thread)

    • 一个合成器线程(compositor thread)

    • 多个光栅化线程(raster thread)

  • JavaScript引擎线程
    JS为处理页面中用户的交互,以及操作DOM树、CSS样式树来给用户呈现一份动态而丰富的交互体验和服务器逻辑的交互处理。如果JS是多线程的方式来操作这些UI DOM,则可能出现UI操作的冲突;如果JS是多线程的话,在多线程的交互下,处于UI中的DOM节点就可能成为一个临界资源,假设存在两个线程同时操作一个DOM,一个负责修改一个负责删除,那么这个时候就需要浏览器来裁决如何生效哪个线程的执行结果,当然我们可以通过锁来解决上面的问题。但为了避免因为引入了锁而带来更大的复杂性(多线程的话会使浏览器的效率降低。多线程必然会引入的锁,信号量的一类操作,大大增加了复杂性),JS在最初就选择了单线程执行
    GUI渲染线程与JS引擎线程互斥的,是由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JavaScript线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致。当JavaScript引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到引擎线程空闲时立即被执行。
    由于GUI渲染线程与JS执行线程是互斥的关系,当浏览器在执行JS程序的时候,GUI渲染线程会被保存在一个队列中,直到JS程序执行完成,才会接着执行。因此如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

  • 定时触发器线程
    浏览器定时计数器并不是由JS引擎计数的, 因为JS引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确, 因此通过单独线程来计时并触发定时是更为合理的方案。

  • 事件触发线程
    当一个事件被触发时该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理。这些事件可以是当前执行的代码块如定时任务、也可来自浏览器内核的其他线程如鼠标点击、AJAX异步请求等,但由于JS的单线程关系所有这些事件都得排队等待JS引擎处理。

  • 异步http请求线程
    在XMLHttpRequest在连接后是通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到JS引擎的处理队列中等待处理。

对于普通的前端操作来说,最终要的是什么呢?答案是渲染进程

可以这样理解,页面的渲染,JS的执行,事件的循环,都在这个进程内进行。

Browser Process进程:

  • tab以外的大部分工作由浏览器进程Browser Process负责,Browser Process 划分出不同的工作线程

  • UI thread:控制浏览器上的按钮及输入框;

  • network thread:处理网络请求,从网上获取数据(Chrome72以后,已将network thread单独摘成network service process,当然也可以通过 chrome://flags/#network-service-in-process修改配置,将其其作为线程运行在Browser Process中,感谢 @Popeye-Wz 的提出);

  • storage thread: 控制文件等的访问;

网页加载过程-导航过程

  • UI thread:控制浏览器上的按钮及输入框;

  • network thread:处理网络请求,从网上获取数据(Chrome72以后,已将network thread单独摘成network service process,当然也可以通过 chrome://flags/#network-service-in-process修改配置,将其其作为线程运行在Browser Process中

  • storage thread: 控制文件等的访问;

处理过程解析

处理输入

当我们在浏览器的地址栏输入内容按下回车时,UI thread会判断输入的内容是搜索关键词(search query)还是URL,如果是搜索关键词,跳转至默认搜索引擎对应都搜索URL,如果输入的内容是URL,则开始请求URL。

开始导航

回车按下后,UI thread将关键词搜索对应的URL或输入的URL交给网络线程Network thread,此时UI线程使Tab前的图标展示为加载中状态,然后网络进程进行一系列诸如DNS寻址,建立TLS连接等操作进行资源请求,如果收到服务器的301重定向响应,它就会告知UI线程进行重定向然后它会再次发起一个新的网络请求。

读取响应

network thread接收到服务器的响应后,开始解析HTTP响应报文,然后根据响应头中的Content-Type字段来确定响应主体的媒体类型(MIME Type),如果媒体类型是一个HTML文件,则将响应数据交给渲染进程(renderer process)来进行下一步的工作,如果是 zip 文件或者其它文件,会把相关数据传输给下载管理器。

与此同时,浏览器会进行 Safe Browsing 安全检查,如果域名或者请求内容匹配到已知的恶意站点,network thread 会展示一个警告页。除此之外,网络线程还会做 CORB(Cross Origin Read Blocking)检查来确定那些敏感的跨站数据不会被发送至渲染进程。

查找渲染进程

各种检查完毕以后,network thread 确信浏览器可以导航到请求网页,network thread 会通知 UI thread 数据已经准备好,UI thread 会查找到一个 renderer process 进行网页的渲染。

浏览器为了对查找渲染进程这一步骤进行优化,考虑到网络请求获取响应需要时间,所以在第二步开始,浏览器已经预先查找和启动了一个渲染进程,如果中间步骤一切顺利,当 network thread 接收到数据时,渲染进程已经准备好了,但是如果遇到重定向,这个准备好的渲染进程也许就不可用了,这个时候会重新启动一个渲染进程。

提交导航

到了这一步,数据和渲染进程都准备好了,Browser Process 会向 Renderer Process 发送IPC消息来确认导航,此时,浏览器进程将准备好的数据发送给渲染进程,渲染进程接收到数据之后,又发送IPC消息给浏览器进程,告诉浏览器进程导航已经提交了,页面开始加载。

这个时候导航栏会更新,安全指示符更新(地址前面的小锁),访问历史列表(history tab)更新,即可以通过前进后退来切换该页面。

初始化加载完成

当导航提交完成后,渲染进程开始加载资源及渲染页面(详细内容下文介绍),当页面渲染完成后(页面及内部的iframe都触发了onload事件),会向浏览器进程发送IPC消息,告知浏览器进程,这个时候UI thread会停止展示tab中的加载中图标。

渲染进程

    1. GUI渲染线程

      • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。

      • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行

      • 注意,GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。

        • 由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JS线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。因此为了防止渲染出现不可预期的结果,浏览器设置GUI渲染线程与JS引擎为互斥的关系,当JS引擎执行时GUI线程会被挂起,GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。

      • 注意composite概念,浏览器渲染的图层一般包含两大类:普通图层以及复合图层。

        • 普通文档流内可以理解为一个复合图层(这里称为默认复合层,里面不管添加多少元素,其实都是在同一个复合图层中,哪怕是absolute布局(fixed也一样),即使脱离普通文档流,但它仍然属于默认复合层)

          • absolute虽然可以脱离普通文档流,但是无法脱离默认复合层。就算absolute中信息改变时不会改变普通文档流中render树,但是,浏览器最终绘制时,是整个复合层绘制的,所以absolute中信息的改变,仍然会影响整个复合层的绘制。

        • 可以通过硬件加速的方式—GPU线程,声明一个新的复合图层(最常用的方式:translate3d、translateZ),它会单独分配资源,会脱离普通文档流,不管这个复合图层中怎么变化,也不会影响默认复合层里的回流重绘。

          • GPU中,各个复合图层是单独绘制的,所以互不影响,这也是为什么某些场景硬件加速效果一级棒

          • 如果a是一个复合图层,而且b在a上面,那么b也会被隐式转为一个复合图层,这点需要特别注意

      • css加载是否会阻塞dom 渲染进程

        • css加载不会阻塞DOM树解析(异步加载时DOM照常构建——css是由单独的下载线程异步下载的)

        • 但会阻塞render树渲染(渲染时需等css加载完毕,因为render树需要css信息——这可能也是浏览器的一种优化机制)

因为加载css的时候,可能会修改下面DOM节点的样式,如果css加载不阻塞render树渲染的话,那么当css加载完之后,render树可能又得重新重绘或者回流了,这就造成了一些没有必要的损耗。所以干脆就先把DOM树的结构先解析完,把可以做的工作做完,然后等你css加载完之后,在根据最终的样式来渲染render树,这种做法性能方面确实会比较好一点。

  1. JS引擎线程

    • 也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)

    • JS引擎线程负责解析Javascript脚本,运行代码。

    • JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序

    • 同样注意,GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。

    • 要尽量避免JS执行时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。Web Worker  异步优化下

      • 创建Worker时,JS引擎向浏览器申请开一个子线程(子线程是浏览器开的,完全受主线程控制,而且不能操作DOM)

      • JS引擎线程与worker线程间通过特定的方式通信(postMessage API,需要通过序列化对象来与线程交互特定的数据)

        JS引擎是单线程的,这一点的本质仍然未改变,Worker可以理解是浏览器给JS引擎开的外挂,专门用来解决那些大量计算问题。

    • SharedWorker是浏览器所有页面共享的,不能采用与Worker同样的方式实现,因为它不隶属于某个Render进程,可以为多个Render进程共享使用。所以Chrome浏览器为SharedWorker单独创建一个进程来运行JavaScript程序,在浏览器中每个相同的JavaScript只存在一个SharedWorker进程,不管它被创建多少次。

      • 页面A发送数据给worker:window.worker.port.postMessage(\'get\'),然后打开页面B,调用window.worker.port.postMessage(\'get\'),即可收到页面A发送给worker的数据。

  2. 事件触发线程

    • 归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)

    • 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中.

    • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理

    • 注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)

      • 为什么有时候setTimeout推入的事件不能准时执行?因为可能在它推入到事件列表时,主线程还不空闲,正在执行其它代码,

  3. 定时触发器线程

    • 传说中的setIntervalsetTimeout所在线程

    • 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)

    • 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)

    • 注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。

  4. 异步http请求线程

    • 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求

    • 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。

事件循环机制进与线程关系

之前也写过《弄懂javascript的执行机制:事件轮询|微任务和宏任务》,但是还是没有从本质去阐述。

JavaScript事件队列等原因还是JavaScript线程与 定时触发器线程、事件触发线程、异步http请求线程等IO通信问题。《》

  • 主线程运行时会产生执行栈

  • 栈中的代码调用某些api时,它们会在事件队列中添加各种事件(当满足触发条件后,如ajax请求完毕)

  • 而栈中的代码执行完毕,就会读取事件队列中的事件,去执行那些回调

如此循环,如下图

注意,总是要等待栈中的代码执行完毕后才会去读取事件队列中的事件

有执行栈与任务队列,引发,宏任务-macrotask与微任务-microtask等相关概念

在ECMAScript中,microtask称为jobs,macrotask可称为task

  • macrotask(又称之为宏任务),macrotask中的事件都是放在一个事件队列中的,而这个队列由事件触发线程维护 

    可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行),(`task->渲染->task->...`)

     

    • 每一个task会从头到尾将这个任务执行完毕,不会执行其它

    • 浏览器为了能够使得JS内部task与DOM任务能够有序的执行,会在一个task执行结束后,在下一个 task 执行开始前,对页面进行重新渲染

  • microtask(又称为微任务),microtask中的所有微任务都是添加到微任务队列(Job Queues)中,等待当前macrotask执行完毕后执行,而这个队列由JS引擎线程维护

    可以理解是在当前 task 执行结束后立即执行的任务

    • 也就是说,在当前task任务后,下一个task之前,在渲染之前

    • 所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染

    • 也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)

分别很么样的场景会形成macrotask和microtask呢?

  • macrotask:主代码块,setTimeout、postMessage、setImmediat、MessageChannel(优先级高于setTimeout)等(可以看到,事件队列中的每一个事件都是一个macrotask)、requestAnimationFrame 、I/O、UI Rendering

  • microtask:Promise,MutationObserver(优先级小于Promise—监听一个DOM变动),process.nextTick(process.nextTick的优先级高于Promise) 、Object.observe(废弃)等

    注意promise的polyfill与官方版本的区别:

    • 官方版本中,是标准的microtask形式

    • polyfill,一般都是通过setTimeout模拟的,所以是macrotask形式

关于vue的$nextTick 2.5+由 MutationObserver 改为MessageChannel,这方面的内容,具体参看《web messaging与Woker分类:漫谈postMessage跨线程跨页面通信

定时器

上述事件循环机制的核心是:JS引擎线程和事件触发线程

但事件上,里面还有一些隐藏细节,譬如调用setTimeout后,是如何等待特定时间后才添加到事件队列中的?

是JS引擎检测的么?当然不是了。它是由定时器线程控制(因为JS引擎自己都忙不过来,根本无暇分身)

为什么要单独的定时器线程?因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确,因此很有必要单独开一个线程用来计时。

什么时候会用到定时器线程?当使用setTimeout或setInterval时,它需要定时器线程计时,计时完成后就会将特定的事件推入事件队列中

setTimeout与setInterval

  • setTimeout计时到到后触发事件触发器,插入一个任务到 事件队列

    延缓事件为:setTimeout触发是设置的等待事件+等待到任务执行时间)

  • setInterval则是每次都精确的隔一段时间推入一个事件

而且setInterval有一些比较致命的问题就是:累计效应

如果setInterval代码在(setInterval)再次添加到队列之前还没有完成执行,就会导致定时器代码连续运行好几次,而之间没有间隔。

JS引擎会对setInterval进行优化,如果当前事件队列中有setInterval的回调,不会重复添加。但是,有错过了延迟的事件。

一般认为的最佳方案是:用setTimeout模拟setInterval,或者特殊场合直接用requestAnimationFrame

 

Node.js事件循环与线程

Node.js也是单线程的Event Loop,但是它的运行机制不同于浏览器(和浏览器中的是完全不相同的东西,关键还是线程架构不同)

Node.js 采用 V8 作为 js 的解析引擎,而 I/O 处理方面使用了自己设计的 libuv,libuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API,事件循环机制也是它里面的实现

根据上图,Node.js的运行机制如下

  • V8引擎解析JavaScript脚本

  • 解析后的代码,调用Node API

  • libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎

  • V8引擎再将结果返回给用户

Node.js 的运行机制

  • V8 引擎解析 JavaScript 脚本。

  • 解析后的代码,调用 Node API。

  • libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。

  • V8 引擎再将结果返回给用户。

libuv 引擎中的事件循环6个阶段

libuv 引擎中的事件循环分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

从上图中,大致看出 node 中的事件循环的顺序:

 

外部输入数据–>轮询阶段(poll)–>检查阶段(check)–>关闭事件回调阶段(close callback)–>定时器检测阶段(timer)–>I/O 事件回调阶段(I/O callbacks)–>闲置阶段(idle, prepare)–>轮询阶段(按照该顺序反复运行)…

    • timers 阶段:这个阶段执行 timer(setTimeout、setInterval)的回调

      • timers 阶段会执行 setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的。

      • 同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。

    • I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调

    • idle, prepare 阶段:仅 node 内部使用

    • poll 阶段:获取新的 I/O 事件, 适当的条件下 node 将阻塞在这里

      poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情

      • 回到 timer 阶段执行回调

      • 执行 I/O 回调

并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情

    • 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制

    • 如果 poll 队列为空时,会有两件事发生

      • 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调

      • 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去

  • check 阶段:执行 setImmediate() 的回调

    setImmediate()的回调会被加入 check 队列中,从 event loop 的阶段图可以知道,check 阶段的执行顺序在 poll 阶段之后。

    • setImmediate 设计在 poll 阶段完成时执行,即 check 阶段;

    • setTimeout 设计在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行

  • close callbacks 阶段:执行 socket 的 close 事件回调

注意:上面六个阶段都不包括 process.nextTick()

process.nextTick 这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

浏览器环境下,microtask 的任务队列是每个 macrotask 执行完之后执行。而在 Node.js 中,microtask 会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行 microtask 队列的任务。

每个阶段都有一个先进先出的回调函数队列。只有一个阶段的回调函数队列清空了,该执行的回调函数都执行了,事件循环才会进入下一个阶段。

nodejs 写的少,没有过多深入

function f () 
  console.log(\'start\')
  setTimeout(() => 
    console.log(\'timer1\')
    Promise.resolve().then(function() 
      console.log(\'promise1\')
    )
  , 0)
  setTimeout(() => 
    console.log(\'timer2\')
    Promise.resolve().then(function() 
      console.log(\'promise2\')
    )
  , 0)
  Promise.resolve().then(function() 
    console.log(\'promise3\')
  )

  console.log(\'end\')


f()

现在,浏览器和node12,输出顺序是一样的。推荐阅读软老师的《Node 定时器详解

 

从文章的 浏览器通常由以下常驻线程组成 里面的 渲染进程  已知,GUI渲染线程与JS引擎线程是互斥的,他们会阻塞页面渲染。所以我们从浏览器的去分析下,怎么优化前端的性能呢?

下篇《浏览器层面优化前端性能(2):Reader引擎线程与模块分析优化点

 

参考文章:

前端都该懂的浏览器工作原理,你懂了吗? https://segmentfault.com/a/1190000022633988

从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理 https://www.cnblogs.com/cangqinglang/p/8963557.html

Chrome源码剖析、上--多线程模型、进程通信、进程模型https://www.cnblogs.com/v-July-v/archive/2011/04/02/2036008.html

Chrome源代码分析之进程和线程模型(三) https://blog.csdn.net/namelcx/article/details/6582730

http://dev.chromium.org/developers/design-documents/multi-process-architecture

chrome渲染机制浅析 https://www.jianshu.com/p/99e450fc04a5

浅析浏览器渲染原理 https://segmentfault.com/a/1190000012960187

javascript宏任务和微任务 https://www.cnblogs.com/fangdongdemao/p/10262209.html

浏览器与Node的事件循环(Event Loop)有何区别? https://blog.csdn.net/Fundebug/article/details/86487117

 


转载本站文章《浏览器层面优化前端性能(1):Chrom组件与进程/线程模型分析》,
请注明出处:https://www.zhoulujun.cn/html/webfront/browser/webkit/2020_0610_8455.html

前端优化带来的思考,浅谈前端工程化转

重复优化的思考

这段时间对项目做了一次整体的优化,全站有了20%左右的提升(本来载入速度已经1.2S左右了,优化度很低),算一算已经做了四轮的全站性能优化了,回顾几次的优化手段,基本上几个字就能说清楚:

传输层面:减少请求数,降低请求量
执行层面:减少重绘&回流

传输层面的从来都是优化的核心点,而这个层面的优化要对浏览器有一个基本的认识,比如:

① 网页自上而下的解析渲染,边解析边渲染,页面内CSS文件会阻塞渲染,异步CSS文件会导致回流

② 浏览器在document下载结束会检测静态资源,新开线程下载(有并发上限),在带宽限制的条件下,无序并发会导致主资源速度下降,从而影响首屏渲染

③ 浏览器缓存可用时会使用缓存资源,这个时候可以避免请求体的传输,对性能有极大提高

衡量性能的重要指标为首屏载入速度(指页面可以看见,不一定可交互),影响首屏的最大因素为请求,所以请求是页面真正的杀手,一般来说我们会做这些优化:

减少请求数

① 合并样式、脚本文件

② 合并背景图片

③ CSS3图标、Icon Font

降低请求量

① 开启GZip

② 优化静态资源,jQuery->Zepto、阉割IScroll、去除冗余代码

③ 图片无损压缩

④ 图片延迟加载

⑤ 减少Cookie携带

很多时候,我们也会采用类似“时间换空间、空间换时间”的做法,比如:

① 缓存为王,对更新较缓慢的资源&接口做缓存(浏览器缓存、localsorage、application cache这个坑多)

② 按需加载,先加载主要资源,其余资源延迟加载,对非首屏资源滚动加载

③ fake页技术,将页面最初需要显示Html&Css内联,在页面所需资源加载结束前至少可看,理想情况是index.html下载结束即展示(2G 5S内)

④ CDN

......

从工程的角度来看,上述优化点半数以上是重复的,一般在发布时候就直接使用项目构建工具做掉了,还有一些只是简单的服务器配置,开发时不需要关注。

可以看到,我们所做的优化都是在减少请求数,降低请求量,减小传输时的耗时,或者通过一个策略,优先加载首屏渲染所需资源,而后再加载交互所需资源(比如点击时候再加载UI组件),Hybrid APP这方面应该尽可能多的将公共静态资源放在native中,比如第三方库,框架,UI甚至城市列表这种常用业务数据。

拦路虎

有一些网站初期比较快,但是随着量的积累,BUG越来越多,速度也越来越慢,一些前端会使用上述优化手段做优化,但是收效甚微,一个比较典型的例子就是代码冗余:

① 之前的CSS全部放在了一个文件中,新一轮的UI样式优化,新老CSS难以拆分,CSS体量会增加,如果有业务团队使用了公共样式,情况更不容乐观;

② UI组件更新,但是如果有业务团队脱离接口操作了组件DOM,将导致新组件DOM更新受限,最差的情况下,用户会加载两个组件的代码;

③ 胡乱使用第三方库、组件,导致页面加载大量无用代码;

......

以上问题会不同程度的增加资源下载体量,如果听之任之会产生一系列工程问题:

① 页面关系错综复杂,需求迭代容易出BUG;

② 框架每次升级都会导致额外的请求量,常加载一些业务不需要的代码;

③ 第三方库泛滥,且难以维护,有BUG也改不了;

④ 业务代码加载大量异步模块资源,页面请求数增多;

......

为求快速占领市场,业务开发时间往往紧迫,使用框架级的HTML&CSS、绕过CSS Sprite使用背景图片、引入第三方工具库或者UI,会经常发生。当遇到性能瓶颈时,如果不从根源解决问题,用传统的优化手段做页面级别的优化,会出现很快页面又被玩坏的情况,几次优化结束后我也在思考一个问题:

前端每次性能优化的手段皆大同小异;代码的可维护性也基本是在细分职责;
既然每次优化的目的是相同的,每次实现的过程是相似的,而每次重新开发项目又基本是要重蹈覆辙的,那么工程化、自动化可能是这一切问题的最终答案

工程问题在项目积累到一定量后可能会发生,一般来说会有几个现象预示着工程问题出现了:

① 代码编写&调试困难

② 业务代码不好维护

③ 网站性能普遍不好

④ 性能问题重复出现,并且有不可修复之势

像上面所描述情况,就是一个典型的工程问题;定位问题、发现问题、解决问题是我们处理问题的手段;而如何防止同一类型的问题重复发生,便是工程化需要做的事情,简单说来,优化是解决问题,工程化是避免问题,今天我们就站在工程化的角度来解决一些前端优化问题,防止其死灰复燃。

文中是我个人的一些开发经验,希望对各位有用,也希望各位多多支持讨论,指出文中不足以及提出您的一些建议

消灭冗余

我们这里做的第一个事情便是消除优化路上第一个拦路虎:代码冗余(做代码精简),单从一个页面的加载来说,他需要以下资源:

① 框架MVC骨架模块&框架级别CSS

② UI组件(header组件、日历、弹出层、消息框......)

③ 业务HTML骨架

④ 业务CSS

⑤ 业务Javascript代码

⑥ 服务接口服务

因为产品&视觉会经常折腾全站样式加之UI的灵活性,UI最容易产生冗余的模块。

UI组件

UI组件本身包括完整的HTML&CSS&Javascript,一个复杂的组件下载量可以达到10K以上,就UI部分来说容易导致两个工程化问题:

① 升级产生代码冗余

② 对外接口变化导致业务升级需要额外开发

UI升级

最理想的升级是保持对外的接口不变甚至保持DOM结构不变,但多数情况的UI升级其实是UI重做,最坏的情况是不做老接口兼容,这个时候业务同事便需要修改代码。为了防止业务抱怨,UI制作者往往会保留两个组件(UI+UI1),如果原来那个UI是核心依赖组件(比如是UIHeader组件),便会直接打包至核心框架包中,这时便出现了新老组件共存的局面,这种情况是必须避免的,UI升级需要遵守两个原则:

① 核心依赖组件必须保持单一,相同功能的核心组件只能有一个

② 组件升级必须做接口兼容,新的特性可以做加法,绝不允许对接口做减法

UI组成

项目之初,分层较好的团队会有一个公共的CSS文件(main.css),一个业务CSS文件,main.css包含公共的CSS,并且会包含所有的UI的样式:

技术分享

半年后业务频道增,UI组件需求一多便容易膨胀,弊端马上便暴露出来了,最初main.css可能只有10K,但是不出半年就会膨胀至100K,而每个业务频道一开始便需要加载这100K的样式文件页面,但是其中多数的UI样式是首屏加载用不到的。

所以比较好的做法是,main.css只包含最核心的样式,理想情况是什么业务样式功能皆不要提供,各个UI组件的样式打包至UI中按需加载:

技术分享

如此UI拆分后,main.css总是处于最基础的样式部分,而UI使用时按需加载,就算出现两个相同组件也不会导致多下载资源。

拆分页面

一个PC业务页面,其模块是很复杂的,这个时候可以将之分为多个模块:

技术分享

一经拆分后,页面便是由业务组件组成,只需要关注各个业务组件的开发,然后在主控制器中组装业务组件,这样主控制器对页面的控制力度会增加。

业务组件一般重用性较低,会产生模块间的业务耦合,还会对业务数据产生依赖,但是主体仍然是HTML&CSS&Javascript,这部分代码也是经常导致冗余的,如果能按模块拆分,可以很好的控制这一问题发生:

技术分享

按照上述的做法现在的加载规则是:

① 公共样式文件

② 框架文件,业务入口文件

③ 入口文件,异步加载业务模块,模块内再异步加载其它资源

这样下来业务开发时便不需要引用样式文件,可以最大限度的提升首屏载入速度;需要关注的一点是,当异步拉取模块时,内部的CSS加载需要一个规则避免对其它模块的影响,因为模块都带有样式属性,页面回流、页面闪烁问题需要关注。

一个实际的例子是,这里点击出发后的城市列表便是一个完整的业务组件,城市选择的资源是在点击后才会发生请求,而业务组件内部又会细分小模块,再细分的资源控制由实际业务情况决定,过于细分也会导致理解和代码编写难度上升:

技术分享

技术分享

demo演示地址代码地址

如果哪天需求方需要用新的城市选择组件,便可以直接重新开发,让业务之间使用最新的城市列表即可,因为是独立的资源,所以老的也是可以使用的。

只要能做到UI级别的拆分与页面业务组件的拆分,便能很好的应付样式升级的需求,这方面冗余只要能避过,其它冗余问题便不是问题了,有两个规范最好遵守:

1 避免使用全局的业务类样式,就算他建议你使用
2 避免不通过接口直接操作DOM

冗余是首屏载入速度最大的拦路虎,是历史形成的包袱,只要能消除冗余,便能在后面的路走的更顺畅,这种组件化编程的方法也能让网站后续的维护更加简单。

资源加载

解决冗余便抛开了历史的包袱,是前端优化的第一步也是比较难的一步,但模块拆分也将全站分成了很多小的模块,载入的资源分散会增加请求数;如果全部合并,会导致首屏加载不需要的资源,也会导致下一个页面不能使用缓存,如何做出合理的入口资源加载规则,如何合理的善用缓存,是前端优化的第二步。

经过几次性能优化对比,得出了一个较优的首屏资源加载方案:

① 核心框架层:mvc骨架、异步模块加载器(require&seajs)、工具库(zepto、underscore、延迟加载)、数据请求模块、核心依赖UI(header组件消息类组件)

② 业务公共模块:入口文件(require配置,初始化工作、业务公共模块)

③ 独立的page.js资源(包含template、css),会按需加载独立UI资源

④ 全局css资源

技术分享

这里如果追求极致,libs.js、main.css与main.js可以选择合并,划分结束后便可以决定静态资源缓存策略了。

资源缓存

资源缓存是为二次请求加速,比较常用的缓存技术有:

① 浏览器缓存

② localstorage缓存

③ application缓存

application缓存更新一块不好把握容易出问题,所以更多的是依赖浏览器以及localstorage,首先说下浏览器级别的缓存。

时间戳更新

只要服务器配置,浏览器本身便具有缓存机制,如果要使用浏览器机制作缓存,势必关心一个何时更新资源问题,我们一般是这样做的:

原文地址: http://www.cnblogs.com/yexiaochai/p/4901341.html 

 

以上是关于浏览器层面优化前端性能:Chrom组件与进程/线程模型分析的主要内容,如果未能解决你的问题,请参考以下文章

前端性能优化——概述

前端性能优化成神之路-总结

前端性能优化总结

Vue 项目性能优化

重学浏览器-多进程多线程的浏览器

前端优化带来的思考,浅谈前端工程化转