看完本篇,你还敢说你会前端吗?

前言

前端在固有印象中一般都是js,html,css这些,以及一些React、Vue、Angular前端框架,再加上js是弱类型的语言,虽然后面ts改进了这一行为,但总给人一种前端较弱的感觉,没啥技术含量。但稍深入,会发现前端的复杂度,可能隐隐超越了后端。本篇看下。

详情

先看代码示例:
<script>var n = 10;console.log(n);</script>
这里面只有两行代码,很简单,分别赋值和控制台打印值。
看到这种代码会有这种疑问,计算机不是只认识二进制码?js在浏览器是被怎么执行的呢?
实际上执行这种js,跟Rustc执行Rust代码,JVM执行Java,以及JIT执行.NET毫无区别。在chromium里面,因为V8(js编译和执行的组件)的极致优化,复杂度上可能超越了前面几个。
因为第一次运行,v8不可能生成优化的代码。一般都是解释性执行,它的字节码如下:
0x38f00800078 @    0 : 13 00             LdaConstant [0]0x38f0080007a @    2 : d1                Star10x38f0080007b @    3 : 1b fe f7          Mov <closure>, r20x38f0080007e @    6 : 670 01 f8 02    CallRuntime [DeclareGlobals], r1-r20x38f00800083 @   11 : 0d 0a             LdaSmi [10]0x38f00800085 @   13 : 25 01 00          StaGlobal [1], [0]0x38f00800088 @   16 : 23 02 02          LdaGlobal [2], [2]0x38f0080008b @   19 : d0                Star20x38f0080008c @   20 : 33 f7 03 04       GetNamedProperty r2, [3], [4]0x38f00800090 @   24 : d1                Star10x38f00800091 @   25 : 23 01 06          LdaGlobal [1], [6]0x38f00800094 @   28 : cf                Star30x38f00800095 @   29 : 67 f8 f7 f6 08    CallProperty1 r1, r2, r3, [8]0x38f0080009a @   34 : d2                Star00x38f0080009b @   35 : b7                Return
这跟Java的字节码和.NET的MSIL有异曲同工之妙。下面就是执行这段js字节码了,与.NET不同的是V8第一次执行js字节码是解释性的执行,而JIT是在多次运行这段代码之后才会触发。
解释性的执行只有字节码,而JIT优化后的代码才是汇编代码
0x... ed3  6a14   push 0x14     //var n=100x... ed5  48b9... console map...0x... eea  49b8... JSFunction log...0x... efc  48bb... Builtin_ConsoleLog...0x... f15  call CEntry_Return1_ArgvOnStack_BuiltinExit  //console.log(n)1e  d1fa   sarl rdx,1
我们看n==0x14(十进制20),而不是push 0xA之类的。
这是因为V8 中的 Smi(Small Integer)表示
0xA二进制:1010把它左移1位等于20也即是十六进制的0x14用的时候把它右移1位,还原成0xA,上面代码最后一行sarl rdx,1所以代码中看不到0xA
那么字节码是怎么跟计算机的二进制打交道的呢?
当V8执行字节码的时候,有一个桩入口(stub_entry.Call)会调用需要被执行的函数,比如:
var n=10;console.log(n);
会被视为一个函数,送入到桩入口
stub_entry.Call-》return fn_ptr_(args...)->
桩入口会启动一些事先准备好的环境函数,比如:
Builtins_JSEntryBuiltins_JSEntryTrampolineBuiltins_InterpreterEntryTrampoline
其中的Builtins_InterpreterEntryTrampoline函数里面会进入字节码的入口,然后就是一行字节码接着一行字节码的解释执行了。
比如上面展示的字节码,它的第一个字节码是
0x38f00800078 @    0 : 13 00             LdaConstant [0]
V8规则
Builtin_字节码名称Handler
根据字节码的名称(上面名称是LdaConstant)
构造一个函数
Builtins_LdaConstantHandler
所以进入字节码入口调用的第一个函数即是:
Builtins_LdaConstantHandler。
我们忽略掉Star1,Mov这种字节码,下一个是CallRuntime,它的原型是:
Builtins_CallRuntimeHandler
字节码的函数Builtins_LdaConstantHandler执行完成之后通过指令jmp rcx跳转到了Builtins_CallRuntimeHandler。下一条字节码是LdaSmi,其原型:
Builtins_LdaSmiHandler
Builtins_CallRuntimeHandler通过jmp rcx跳转到
Builtins_LdaSmiHandler执行。
按照此种模式,解释完成整个字节码的执行。

V8极致优化

在看chromium source code时候,遇到了很多的汇编代码,最典型的就是解释执行引擎的几个函数:
Builtins_JSEntryBuiltins_JSEntryTrampolineBuiltins_InterpreterEntryTrampoline
这些函数很有意思,它们直接被以十六进制的形式存储在某个文件中
比如:Builtins_JSEntry函数如下
 "Builtins_JSEntry:\n" ".def Builtins_JSEntry; .scl 2; .type 32; .endef;\n" "  .octa 0x56415541544108ec8348026ae5894855,0x7f0f48f3000000a0ec81485356575741\n" "  .octa 0x2024447f0f4cf310247c7f0f48f32434,0x4cf34024547f0f4cf330244c7f0f4cf3\n" "  .octa 0x7f0f4cf36024647f0f4cf350245c7f0f,0xf4cf30000008024b47f0f4cf370246c\n"//便于观看,后面省略

Builtins_JSEntry没有函数名称,函数参数,以及函数代码,有的只是这些十六进数据。它就是Builtins_JSEntry函数的代码。这种做法,自然是提高了性能和效率。

更关键的是字节码的执行他同样是这种十六进制的形式,比如例子里面第一个被执行的字节码函数是Builtins_LdaConstantHandler,它的原型如下:

"Builtins_LdaConstantHandler:\n"".def Builtins_LdaConstantHandler; .scl 2; .type 32; .endef;\n""  .octa 0x90ba0d74d93b48fffffff91d8d48,0x48266ae5894855cc0000602095ff4100\n""  .octa 0x894cf0e4834828ec8348e2894930ec83,0x4cd0458948e07d894ce865894c202454\n""  .octa 0x4500002470858b4d00000216bad84d89,0x60858b49e1894cc6034d0000e94b808b\n"//便于观看,后面省略

相对于代码:

var n=10;

如果你在汇编代码中找类似于:

mov rax 10mov rcx 10 

这种是找不到的,因为它只有

push 0x14

它变成了Smi格式存储,这种也是一种优化的方式。举个例子,如果要检查一个数值是不是Smi,只需要如下:

; 检查是否为 Smi,只需要一个位操作testb rax, 0x1    ; 测试最低位jz is_smi         ; 如果是0,跳转到Smi处理

而不是:

if(数值==Smi{   do something}else{   do something}

V8的解释执行和V8的JIT执行的差距急剧缩小。

结尾

V8和RustC,JVM,CLR没有本质上的区别
都是对前端的代码(js/rust/java/C#)进行解释或者编译性的执行。前端的后面不简单。

 

分享到: 文章二维码
© 版权声明

暂无评论

您必须登录才能参与评论!
暂无评论...