大白话讲透 Chromium 源码(宏观篇)

379次阅读  |  发布于2年以前

自从我 对C语言有过编译原理上的实践经验 && 对js和浏览器的表层(非chromium源码级我都统称表层) 有了更深的理解后,对于JS及其相关的很多事物愈发的产生了疑惑,比如"执行上下文"、"渲染流水线"、"浏览器多进程到底怎么玩的(ipc等)"等等,网上现有的理论(包括ES官方),已经不能解决我产生的各种疑惑了。我需要的是chromium源码级的解释!!

我想先解决"执行上下文"等疑惑,又奈何,截止2021.11.1(我得严谨一点嘛,世事难料,我们永远不知道下一刻会发生什么,更何况过去了一个月呢)我竟然没有搜到一篇关于"执行上下文"的源码级解释(我需要的不是八股文也不是ES官方规范呐)。准确说是关于Chromium源码解释的资源本身就太少太少太少了,

为什么要读源码呢?大家要先明白,ES规范的官方标准,它只是一个理论标准。它也是在某个技术实现后,才出的标准。所以最终实现上是怎样的婀娜多姿,只有相应开发者知道。其他人只能通过阅读源码窥得一二。而且源码中充斥着大量的最佳实践,以及为了极限性能而各种"骚操作"(导致难以阅读),可以瞻仰瞻仰。

没辙了,由于自身强烈的强迫症,我只好硬着头皮自己上了(虽然只有大一的C基础,也没学过C++,并且我也只是个仍在实习的小FEer。但是!这并不能阻挡我探索chromium的决心,Fighting!Fighting!)。 经过一个多月(每天完成公司任务后,自己加的2~3hour)对chromium的折腾(虽然本地都准备好了,但未能在本地调试。于是我在 chromium 【需要科学上网】,干巴巴的读。文末对chromium的阅读经验中会解释为何未能调试)。

打个不恰当的比方:如果其他矿工无暇分享来自chromium这座矿山的"资源",那我愿意学着如何去当一名矿工。戴上小钢帽儿,背上行囊,提起小铁铲儿,把挖到的、探索到的"资源"分享给大家。

Hi~ 我是本文向导

(: 后来想了想,不太推荐纯小白观看噢,纯小白慎入⚠️。最好是熟悉js&懂一点编译和c++。编译和C++不了解也没关系,了解js就行,其他的我来讲

首先关于 chromium 和 chrome 的关系大家可自行搜索哈。

“无忌,我教你的还记得多少?”“回太师傅,我只记得一大半” “ 那,现在呢?”“已经剩下一小半了” “那,现在呢?”“我已经把所有的全忘记了!” “好,你可以上了…”

先忘掉你之前学过的js上下文和作用域等知识,包括ES官方说的,也忘掉!我只能说,官方描述的"样子"源码里有对应的体现,但是具体称呼、约定、实现,官方无法强约束。再次强调,是chrome(或各大浏览器厂商)先有的实现,然后ES组织后来才出的官方标准。所以希望你最好和我一起,我们重新探索,重新学习。

本篇文章主要是从chromium源码中的v8(JavaScript引擎,主要内容都是由C++实现,还涉及Chrome自研的Torque语言.tq),去梳理js上下文&作用域、对象及数组等内容。还会涉及一些编译原理的知识。为了尽量同步最新源码&风格统一,所有截图均为 chromium 里最新实时截图,就不看我本地代码了。

行文至此,俺懵了,my mind goes completely blank.。因为这是写给大家看的,"你要我怎么做怎么说,你才能爱我~"

把所有本文涉及的东西都截图出来--不太可能,那样这篇文章体积就太大了。但我会尽量的截图源码。

为了帮助兄弟姐妹萌过渡,我会先宏观介绍一些知识,再结合微观探索细节,最好不要跳着看噢

v8源码宏观铺垫与分析

我会先为大家讲解一下各种铺垫知识,来慢慢揭开v8这神秘魔法石的一部分 -- js Context&Scope&others

注意下文中我画的图、翻译的话、我打的注释希望XDJMM可以一个字一个字的扫一遍,很重要的,我长时间的摸索才得来的经验。不读一下的话,我担心后文你们直接懵了‍‍

Handle--大智若愚的"憨豆"先生

首先:Handle用于管理v8对象的内存地址,它的本质就是一个指向指针的指针。

重要的前菜--指针介绍

C/C++之所以那么汹涌澎湃、大海无量,我个人认为指针(针哥)占了50%的功劳。

我们的程序、变量等,最终一定是存到物理硬件中的。运行程序一般都是在内存中,分配的空间也在内存中。你要找到某个东西,就一定需要地址,C/C++是偏底层的语言,因为它们能通过指针直接操作内存单元!

指针就是地址,地址就是指针。地址就是内存单元的编号。指针变量就是就是一个存放指针/地址的变量。申请内存空间后的返回的地址都是内存单元的起始地址,然后通过偏移量即可访问具体数据。

指针的几点好处:可以访问硬件、快速的传递数据(通过指针可以直接找到一个复杂的数据结构的起始地址)..

那么为何需要"憨豆"先生呢?

首先Handle应取其句柄含义,起源于Handle-C。我觉得你可以直接理解为柄,联想到什么了吗?手提袋的柄?打魂斗罗的游戏机手柄?书包上面的柄?无所谓,差不多就那个意思,啥意思?有点拿捏了&&四两拨千斤的韵味。

v8中重新实现了一个Handle类,然后我们先来看源码中的一段注释,看看为啥需要"憨豆"先生:

从 v8 返回的所有对象都必须由垃圾收集器跟踪,为了知道它们是否还活着。又因为垃圾收集器可能会移动对象,直接指向一个对象是不安全的。相反,所有对象都存储在垃圾收集器所知道的句柄中,并在对象移动时更新句柄。Handles应该总是按值传递(地址也是值,一般用16进制)(除了像out-parameters这样的情况--我并不知道这句个out-parameters是指什么,不过对本文应该没影响),并且它们不应该在堆上分配。

这有两种类型的句柄,local 和 persistent 类型的句柄。Local类型的句柄是轻量且瞬态的,通常用于local(我理解应翻译为:局部)操作。它们都由HandleScope管理。这意味着当Handles们被创建在有效的HandleScope内部时,一定是位于栈上。要将local句柄传递给外部 HandleScope,必须使用 EscapableHandleScope 及其 Escape() 方法。

本文涉及的源码涉及的代码里都是 Local 类型句柄,先不用管 Persistent。

当存储对象跨越多个独立操作时,可以使用持久句柄,并且在不再使用时必须明确释放。通过取消引用句柄来提取存储在句柄中的对象是安全的(例如,从Local中提取Object*),该值仍将由幕后的句柄控制,并且相同的规则适用于这些值的句柄。

Type* temp; 这代表的是一个叫temp的变量, 持有一个指向Type类型的指针;temp就是那个指针变量!通过 (*temp) 就可以拿到那个指针/地址所在的内存单元上存储的数据!但是这么做比较危险!因为GC可能移动对象从而导致产生新地址,原地址将会存着啥,谁都不知道。就是说,如果temp指向一个对象,GC换了地址存储这个对象,那开发者拿到的原地址上是啥,谁都不知道了。难道我们开发者不要面子的吗??!!所以得蹦出个Handle,来帮你管理这个对象的地址,你拿着Handle就行。你可以理解为Handle是一个指向指针的指针;一个智能的指针。

我没讲明白是吧?行,给宝宝我愁的啊,上图!

我讲清楚了吗,兄弟姐妹萌? 再不理解,俺也没辙啦,就先跳过吧

所以你Get到“憨豆”先生的大智若愚了吧

憨豆先生的几部作品中的片段我仍历历在目,始终觉得他是一位有大智慧的人。

回归正题。难道你没发现:你给js对象添加属性,js数组push元素之后,再去做恒等比较时,结果仍为true。

你没想过为啥吗?你总不会认为它不是分配新的内存空间,而是在原有内存后面加内存吧??????虽然对象和数组一开始都会分配固定默认容量的内存,但是你超过这个容量后,就得重新分配内存了。你不会想问为啥得重新分配而不是追加内存或者覆盖其后地址上的数据吧? 兄弟姐妹萌。。你们怎么比我还天真呢。稚嫩呐。。真好。

首先,追加是不可能的。因为物理内存一个挨着一个,你凭什么无中生有呢?你顶多是把x号单元后连着的单元,覆盖上你想要的数据。但是这也不行,为啥?你根本没权利这么干,就算有,你也绝对不能这么干!!

打个不恰当的比方:地铁上,这排5个座位,就剩中间一个了,你女朋友坐了上去。到了下一站,你也上来了,想坐在你女朋友身边。你总不能让它旁边某个人起开,然后给你腾座位吧?凭啥?人家先来的,人家凭啥让给你,而且这是公共场所。至少有一个人会"遭殃"。

但反映到内存上,它可不是这么简单的让开就行,你能知道这块“需要让座”的内存单元正在存储着什么吗?你能知道它是为了什么而存储吗?你有权利动人家吗?你一冲动,可能后面n个内存单元都错了,那就不是一个单元的事儿了!而且这些内存所服务的事情也都会出错,可怕吗?我就问你怕不怕!为了一点点内存,至于这么大动干戈吗。当然不至于!所以才要重新分配内存空间!!也就是你和你女朋友去找一个2连座,然后再享受欢乐时光。

拿数组举例:当你push后,达到扩容条件时,v8就得这么干:

这次我讲明白了吧,小哥哥小姐姐?Handle哥帮你做了那么多事,任劳任怨,还得保证你不会出错,你说人聪不聪明?大智若愚~

本杰明·巴顿奇事--编译原理科普

实现一门编程语言,实际上就是在实现一个翻译程序--编译器,因为CPU只认识0和1,汇编可以认识指令。

这里以编译型语言为例,我简单描述下编译程序的流程:

不断的退化,回归原始至臻,是不是有点本杰明的韵味呢?

虽然JS是解释型语言,但也有"编译"的部分,采用了JIT技术。大家可自行在掘金搜索介绍v8对js如何解释执行的相关文章。

v8 部分类关系图概览

可以结合上文和下文内容后再读下图。兄弟姐妹萌!每个方块都是一个类。其实绿色的(我们这次不care)你们不理解也没事,其他颜色的没看懂,也没事;毕竟这个图只是为了大家有个宏观的认识而已,而且后面我会拉扯出非绿色的其中几个模块 -- 就是我们这次要聊的js上下文&作用域。

精美包装--js上下文Context概览

上述类图中对js Context我也写了一点描述,是js代码执行的小型沙箱,因此我美其名曰:精美包装。

请 XDJMM 先仔细阅读我的翻译,因为有些话不在原英文中(宝贝们也可读它个20遍,若不理解,没关系,后续我会慢慢"拉扯出来"),然后可以自己去翻译一下。大致是说:

JSFunction 是成对的(上下文、函数代码),有时也称为闭包(注意:这里不要狭隘了,JSFunction和这个closures不是单纯指大家理解的js函数和闭包,这个JSFunction还包含了很多字节码、优化代码的相关操作等)。Context 对象常常用于表示函数上下文和动态推送“with”上下文(或 ECMA-262 中的“scope”)。

在运行时,上下文们会构建一个与执行栈并行的栈!(注意注意:这就有提示了,上下文的栈最初不是诞生在执行栈里!!)栈顶的上下文是当前上下文。所有上下文有以下插槽(理解为字段/属性吧):

对于 native 上下文,它包含全局对象。(其实只是Browser把全局对象的指针暴露给了v8,v8又暴露给了native上下文;native上下文你们可以类似理解为你们以前所学的"全局执行上下文")对于 module 上下文,它包含模块对象。

对于 await 上下文,它包含生成器对象。对于 var block(varblock这东西太难找了,后面再说)上下文,它可能包含一个“extension object”。

对于 with 上下文,它包含一个“extension object”。“extension object”用于动态扩展一个带有附加变量的上下文,也就是在'with'结构和'eval'结构的实现中。例如, Context::Lookup 也搜索扩展对象的属性。存储扩展对象是这个上下文槽的原始目的,因此得名。此外,带有草率的 eval 的函数上下文可能会静态地分配上下文槽,来存储要从内部函数(通过静态上下文地址)或通过 'eval'(动态上下文查找)访问的局部变量/函数。

native上下文包含用于快速访问native属性的附加插槽。最后,在和谐的scope氛围下(我淦,我真不知道怎么翻译这个Harmony了),JSFunction 表示顶级脚本将具有 ScriptContext而不是 FunctionContext。所有来自顶级scripts的ScriptContext都被收集在脚本上下文表:ScriptContextTable 中。

所以说,每个Context一定至少有三个共同的槽位:scope_info, previous, extension

那么你猜,主要信息都在哪儿呢?Bingo!当然一定以及肯定的在scope_info中了啊!!!

所以Context本身是没有大家以前认为的所谓的那些什么 变量环境、词法环境、this、outer;都没有!!这些东西倒是在Scope中都有涉及!但是在Scope中分得非常细致,所以,请不要叫词法环境,你可以称呼为词法标识。也不要叫变量环境,这些概念都太大,源码中没有对应实现。因为在源码实现中拆分了各种细致的东西。如果你不拆分为各种标识,那么绝对无法应对千奇百怪的各种语法和语义!拥有足够多细腻的信息,你就可以"金刚不坏"了。

现在我们通过判断上下文类型的方法,反向看看都有哪些类型的Context:

但是!其实最终还是从native_context的扩展对象里的拿的global_object啊,如下:

出现了个全局代理,它和全局对象啥关系呢?global_proxy_object.prototype指向global_object,如下:

v8期望只有只有全局对象能作为全局代理的原型,所以v8不希望我们改变全局代理对象的原型的指向,否则可能发生意向不到的事,因为很可能破坏虚拟机!!

ps:global_object 就是 ES规定的全局对象,也是Browser暴露给v8的window

一人之下万人之上--NativeContext

上述类图中,我已经画了,NativeContext继承自Context,然后他重写了获取global_object方法,并为它又重载了一个方法。但其实还是调用的是Context里的gobal_object(); 自己拿自己的东西还得绕一圈,可还行?行,估计是为了某种安全,chrome这么注重性能的团队,不会做不必要的事情。

716~721在说,722行的方法忽略了是否加载完毕的tag。可以在并发(并发噢,没毛病)情况下安全的读取全局对象,因为在它初始化后就不可变了(指的是指针不可变,即地址不会变了)。722行的方法不能用于heap-refs.cc以外的地方。估计是其他地方如果你瞎调用,可能还没load完,你去操作全局对象就要出事。所以723行来了个重载函数,有标识的时候,就调723的的方法就行了。

并且NativeContext中还有微任务队列的指针:

你们想知道微任务具体是在什么情况下、什么时候运行的吗?欸,我就是不说,就是玩儿,就是皮。

为啥说 "一人之下万人之上",请继续往下看↓↓

跨脚本共享的关键--ScriptContext及其Table

首先ScriptContext和ScriptContextTable都可以通过NativeContext拿到。

再来看下面一段注释:

就是说ScriptContextTable存放着所有加载完毕的顶级脚本下的顶级词法声明变量(let/const)。在源码中,我看它首先是通过JSFunction(它不是单纯我们说的js的function,它承载着v8对我们整个js的掌控,包括优化信息、字节码等编译后端的底层知识,我目前没继续深入了解JSFunction),拿到native_context,然后native_context 访问ScriptContextTable,遍历上面说的顶级词法变量,表中没有的就存进去,有的就共享。但是注意!!其实script_context仍然会为这些变量开辟一个槽位占位,但是他们共享访问table中对应变量修改的也是table中对应的变量。这就是为什么你在html中引入了多个script标签后,可以跨script去使用顶级let/const的变量的原因。注意,这只是你可以跨脚本调用,本质上这些顶级词法变量是没有被安装到global_object中的!!比如你跨脚本使用顶级词法变量temp:直接用temp, 但你不能用window.temp!!因为它本质上就不在global_object中!!

那为什么没提到var声明的变量呢?首先在源码中,顶级脚本是需要被安装到native_context中的,然后JSFunction会拿到native上下文,继而拿到native上下文扩展对象里的 global_object,之后通过一系列函数,最后遍历脚本中的顶级var声明存储进global_object。所以你既可以跨脚本直接使用var变量,也可以利用window.xxx使用。

关于函数有些复杂,还得看你最初怎么定义的,然后就会有不同的体现,总之思路要点都是一致的:通过JSFunction拿到native_context进而拿到script_context_table或者global_object去操作或者不操作。

还是再贴一点点源码吧,在 NewScriptContext 方法里,框起来的都是经过一堆操作后的入口,每个入口后面又会调N个函数,我贴的其他源码图也是这样,毕竟v8这么大,chromium那么那么大。。:

现在是不是觉得native_context几乎权势滔天了?只有JSFunction比他官儿大。当然最大的是v8,v8上面还有Chrome Browser,

颤栗吧!不可思议的再次声明let/const!

但是你发现没有,你在控制台的话:

为什么第一和第三种模式居然可以通过,第二种和第四种就是错的?其实控制台算是一种调试模式!是Devtools照顾大家的。会在一定情况下触发repl模式作用域,然后你每敲一次回车之后,相当要新建立一个ScriptContext,然后在源码中:

就看我框起来的3个地方小方框,就是说,(let和let || const和const) && 此种特殊情况下,就可以重复声明,否则会报相应错误。

, 我为什么要举这么个破玩意儿例子呢?不是来刷存在感的,只是想证明,N多情况都不是我们未经源码考证时可以解释的。其实还有N多小细节,我只是随便举了一个例子而已。希望大家对一切都抱有敬畏之心!

其他 Context 就不讲了,主体和精髓都是各种 Scope,下篇 scopes 里面讲讲就行了。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8