Dart VM 介绍 译
前言
Dart VM 是一个执行 Dart 语言的组件集合,包括但不限于以下组件:
-
运行系统
- 对象模型
- 垃圾收集
- 快照
native 代码核心库
-
开发体验优化组件
- debug调试
- 性能分析
- 热重载
JIT(Just-in-Time)和 AOT (Ahead of time) 编译管道。
解释器
ARM指令模拟器
"Dart VM" 的命名是有历史原因的。从某种程度上来说,Dart VM 作为虚拟机为高级的编程语言提供了执行环境,但这并不表示 Dart VM 总是在解释或 JIT 的方式执行 Dart 。举个例子,Dart 的代码能够被 Dart VM 的 AOT 管道(AOT pipeline) 编译成机器码,然后通过一个叫做 precompiled runtime 的精简版本的 Dart VM 执行,这个版本不包括任何的编译组件,也不能动态加载 Dart 源码。
1 Dart VM 怎么执行你的代码?
Dart VM 有很多种方式来执行你的代码,比如说:
- 从源码执行或者通过 JIT 执行内核二进制文件(Kernel binary)。
- 从快照中恢复:
- 从 AOT 快照;
- 从 AppJIT 快照。
任何的 Dart 在 VM 上执行的 Dart 代码,都依赖 isolate。isolate 可以理解为 Dart 独占的一部分堆内存,并且通常拥有自己的控制线程(mutator thread)。在并发执行 Dart 代码的时候,通常会有很多的 isolate,但是他们之间是无法直接共享内存,共享状态的,仅能通过消息传递端口(不要和网络端口混淆了!)。
系统线程和 isolates 的概念有一点模糊,这高度依赖应用是怎么集成的 VM。只有如下的事情可以保证:
- 同一时刻一个线程只能进入一个 isolate 。如果想要进入其他的 isolate ,就必须先离开当前的 isolate;
- 同一时刻,只能有一个突变线程(mutator thread)关联到一个 isolate 上面。突变线程的主要功能是执行 Dart 代码,并使用 VM 的公开C接口。
然而相同的系统线程可以首先进入一个 isolate,执行 Dart 代码,然后退出这个 isolate 在进入另外一个 isolate。同样,也可以有很多的系统线程进入一个 isolate 中,在里面执行 Dart 代码,只不过不同时而已。
isolate 除了会关联一个突变线程,同时也会被关联到许多辅助线程,比如:
- 一个后台的 JIT 编译线程;
- 多个垃圾清理线程;
- 多个并发的垃圾标记线程。
VM 内部使用一个线程池(ThreadPool)来处理系统线程,整个代码逻辑围绕着任务(ThreadPool::Task)而不是系统线程的观念来展开的。举个例子,我们并不会专门为了一个后台垃圾回收的事情开一个单独的线程,而是发送一个叫做 SweeperTask 的任务到全局 VM 线程池当中,而线程池会要么选择一个空闲的线程或者当没有可用线程时等待创建一个新的线程来执行这个任务。类似的,isolate 的事件循环处理的实现,也没有一个专门叫做事件循环的线程,而是在接收了一个新消息之后,发送一个 MessageHandlerTask 任务到线程池来处理的。
源码导读:类 Isolate 表示一个 isolate,类 Heap - 表示 isolate的堆。类 Thread 描述了关联到一个 isoloate 的线程状态。注意 Thread 这个名字可能会产生一些困惑,因为所有作为 mutator 关联到一个 isolate 上系统线程都会被相同的 Thread 实例复用。通过查看 Dart_RunLoop 和 MessageHandler 来了解 isolate 默认的消息处理实现原理。
1.1 通过 JIT 机制从源码运行
这一节解释一下当你从命令行执行 Dart 的过程:
// hello.dart
main() => print('Hello, World!');
$ dart hello.dart
Hello, World!
自从 Dart 2 版本之后,VM 已经没有了直接从源代码执行 Dart 的功能,取而代之的是,VM 只能执行那些由内核抽象语法树(Kernel ASTs)序列化成的内核二进制文件(Kernel binaries)(又被称作 dill files)。而将 Dart 源码翻译成内核抽象语法树的任务则交给了由 Dart 编写的通用前端(common front-end(CFE)),这个工具被不同的 Dart 模块所使用(举个例子:虚拟机(VM),dart2js,Dart Dev Compiler)。
为了保留直接从独立源码直接执行 Dart 的便利性,专门还提供了一个辅助 isolate ,叫做 kernel service ,专门用来处理 Dart 源码编译成内核可执行文件的过程。之后 VM 就能直接执行生成的内核二进制文件了。
然而这并不是 VM 和 CFE 唯一运行 Dart 代码的方式。举个例子,Flutter 完全分离了编译和执行的过程,编译过程发生在开发过程中,而用户则获取这些编译好的文件直接在装有 flutter 工具的移动设备上运行。
注意 flutter 工具不会直接去解析 Dart ,而是生成林一个持久的前端服务(frontend_server),这个前端服务实际上就是对 CFE 和一些 Flutter 特殊的内核到内核的转换器的一个封装。frontend_server 将 Dart 源码编译成内核文件,然后通过 flutter 工具发送给设备。当开发者请求热重载的时候,这个持久化的前端服务就开始发挥作用,在这种情况下,前端服务可以重用之前一次的编译状态,只编译那些变化的文件。
一旦内核二进制被加载到了 VM 里面,他会被解析,然后创建多个代表了不同程序实体的对象。然而这一切都是懒加载的:首先只有库和类的基础信息会被加载。每一个程序实体都会保留一个指向内核二进制文件的指针,所以在需要更多信息的时候,可以按需加载。
那些类的信息只有当运行时完全需要的时候,才会被完全的反序列化(举个例子,当查找一个类的成员时,当申请一个实例时等等)。在这个阶段,会从内核二进制中读取出来类的所有成员。然而完整的函数体还没有被反序列化,只有他们的函数签名被序列化出来。
此时,已经从内核二进制中加载了足够多的信息来解析和执行方法了。举个例子,已经可以开始解析和执行一个库的 main 函数了。
源码导读:package:kernel/ast.dart 定义了描述内核抽象语法树的类。package:front_end处理解析 Dart 源码和创建内核抽象语法树的过程。kernel::KernelLoader::LoadEntireProgram
是一个为了反序列化内核抽象语法树到对应的 VM 对象的入口点。pkg/vm/bin/kernel_service.dart
实现了内核服务 isolate,runtime/vm/kernel_isolate.cc
将 Dart 实现和VM的其他部分粘合在一起。package:vm
包含了大部分 VM 的基础方法。比如多种 内核到内核的变换。然而由于历史原因,还是有一些 VM 相关的变换在 package:kernel
中。一个优秀的编译变换的例子是:package:kernel/transformations/continuation.dart
,这个类将 async,async* ,sync* 这些语法糖进行了解糖操作。
尝试:如果你对于内核格式和他的 VM 的特定的语法感兴趣,你可以使用 pkg/vm/bin/gen_kernel.dart
来从 Dart 源码生成 内核二进制文件。生成的二进制文件可以被pkg/vm/bin/dump_kernel.dart
解析。
# Take hello.dart and compile it to hello.dill Kernel binary using CFE.
$ dart pkg/vm/bin/gen_kernel.dart \
--platform out/ReleaseX64/vm_platform_strong.dill \
-o hello.dill \
hello.dart
# Dump textual representation of Kernel AST.
$ dart pkg/vm/bin/dump_kernel.dart hello.dill hello.kernel.txt
当你尝试使用 gen_kernel.dart 的时候,你会注意到她需要一个叫做 平台(platform)的东西,这个东西是一个包含了所有核心库的AST文件的内核二进制文件。如果你已经配置了 Dart SDK,你只需要在 out 目录使用平台文件,比如: out/ReleaseX64/vm_platform_strong.dill。另一个可以选择的方案是使用 pkg/front_end/tool/_fasta/compile_platform.dart
来生成平台。
# Produce outline and platform files using the given libraries list.
$ dart pkg/front_end/tool/_fasta/compile_platform.dart \
dart:core \
sdk/lib/libraries.json \
vm_outline.dill vm_platform.dill vm_outline.dill
初始化方法的时候,每个方法都有一个占位符,而不是直接去找到他们真正的方法执行体:他们指向了一个 LazyCompileStub ,这个东西的功能很简单,就是向运行时系统请求生成当前方法的可执行体,然后尾调回新生成的代码中。
函数是通过 非优化编译器(unoptimizing compiler) 进行第一次编译的。
非优化编译器在两个过程中生成机器码:
- 序列化函数体的抽象语法树的方法是为函数体遍历生成一个控制流图(control flow graph (CFG))。CFG 由一系列的包含中间语言(intermediate language (IL))指令的基础块组成。这个阶段使用的 IL 指令类似于基于栈的虚拟机指令:他们从堆栈中获取操作数,执行操作,然后将结果推送到相同的堆栈中。
- 由此产生的 CFG 直接编译成机器码,使用了一对多的 IL 指令:每个 IL 指令扩展成多个机器语言指令。
在这个阶段,没有任何性能上的优化。非优化编译器的主要目标就是尽快的生成可执行代码。
这同时意味着非优化编译器不会尝试静态分析任何未在内核二进制文件中的调用。所以调用(MethodInvocation 或者 PropertyGet AST 节点)都是完全动态的进行编译的。VM 目前不会使用任何的基于虚拟表和接口表的分发方式,而是直接使用内联缓存(inline caching)实现动态调用。
内联缓存背后的核心思想是将方法解析的结果缓存到对应调用点特定的缓存中。VM 使用的内联缓存机制包括:
- 一个调用栈的特定缓存(RawICData object)会将接受者的类映射到一个方法,如果接收者有匹配到这个类,就应该调用这个方法。这个缓存应该存储一些辅助信息,比如调用频率计数器,他跟踪给定类在这个调用站点上出现的频率。
- 一个共享查找存根(stub,感觉很难翻译),实现了方法的快速查找。这个存根通过查找给定缓存,来确定他是否包含接收者的类匹配的条目。如果这个条目被找到了,存根就会调用频率计数器+1,然后尾调回缓存方法。否则存根将会调用运行时系统中实现了方法调用逻辑的辅助器。如果方法解析成功,那么缓存将会被更新,后面的调用就会命中缓存而不是进入运行时系统。
下面的图片解释了 animal.toFace() 调用点关联的内联缓存的结构和状态,这个调用点被 Dog 实例执行了两次,被 Cat 实例执行了一次。
未优化编译器本身就已经可以执行任何 Dart 代码了。然后他生成的代码执行速度非常慢,这就是为什么 VM 同时还实现了自适应优化编译管道(adaptive optimizing compilation pipeline)。他背后的思想是使用正在运行的执行性能数据来驱动优化策略。
当未优化的代码运行的时候,他会收集以下数据:
- 与每个动态调用点相关联的内联缓存回收集想让的观察到的接收者类型;
- 与函数和函数基本块关联的执行计数器跟踪记录着代码的调用频繁程度。
当与某个函数相关联的执行计数器的计数达到了设定的阈值,这个函数会被提交给后台优化编译器(background optimizing compiler )进行优化。
优化编译器与非优化编译器的开始方式相同:都是通过遍历序列化的内核抽象语法树来为准备优化的方法创建非优化的 IL。但是接下来就不一样了,他不是直接将 IL 翻译成机器码,而是继续将未优化的 IL 翻译成静态单个任务(static single assignment (SSA))。基于 IL 的 SSA,根据收集到的类型反馈,进行推测优化,并通过一系列的常规的优化:例如内联,范围分析,类型船舶,表示选择,存储到加载和加载到加载转发,全局值编号,分配下沉等等手段。最后通过线性扫描寄存器分配器和简单的一对多降低 IL 指令,将优化的 IL 生成为机器码。
编译完成之后,后台编译器会请求 mutator 线程进入安全点,并将优化代码附加到函数上。下一次调用该函数时,就会使用优化的代码了。
源码阅读:编译源码在 runtime/vm/compiler
目录。编译管道入口点在 CompileParsedFunctionHelper::Compile
。 IL 定义在 runtime/vm/compiler/backend/il.h
。 内核到 IL的转换入口在 kernel::StreamingFlowGraphBuilder::BuildGraph
,这个方法同时也处理了多种人为构建的方法。StubCode::GenerateNArgsCheckInlineCacheStub
为 内联缓存存根生成机器码,InlineCacheMissHandler
来处理 内联缓存没有命中的情况。runtime/vm/compiler/compiler_pass.cc
定义了优化编译器及其顺序。JitCallSpecializer
做了大多数的类型反馈的工作。
尝试 VM 提供了可以控制 JIT 的标志位,可以通过这个反编译这些被 JIT 编译出来的 IL 和 机器码。
Flag | Description |
---|---|
--print-flow-graph[-optimized] | Print IL for all (or only optimized) compilations |
--disassemble[-optimized] | Disassemble all (or only optimized) compiled functions |
--print-flow-graph-filter=xyz,abc,... | Restrict output triggered by previous flags only to the functions which contain one of the comma separated substrings in their names |
--compiler-passes=... | Fine control over compiler passes: force IL to be printed before/after a certain pass. Disable passes by name. Pass help for more information |
--no-background-compilation | Disable background compilation, and compile all hot functions on the main thread. Useful for experimentation, otherwise short running programs might finish before background compiler compiles hot function |
For example
# Run test.dart and dump optimized IL and machine code for
# function(s) that contain(s) "myFunction" in its name.
# Disable background compilation for determinism.
$ dart --print-flow-graph-optimized \
--disassemble-optimized \
--print-flow-graph-filter=myFunction \
--no-background-compilation \
test.dart
要强调的一点是,被优化编译器生成的代码是基于应用运行时的推测和假设的。例如,一个动态调用点,只能观察到一个单一类 C 作为接收者的实例,然后这个调用点会被转换为一个直接调用,检查接收者是否真的是类C。然而这样的假设可能会在程序运行期间被证实是错误的:
void printAnimal(obj) {
print('Animal {');
print(' ${obj.toString()}');
print('}');
}
// Call printAnimal(...) a lot of times with an intance of Cat.
// As a result printAnimal(...) will be optimized under the
// assumption that obj is always a Cat.
for (var i = 0; i < 50000; i++)
printAnimal(Cat());
// Now call printAnimal(...) with a Dog - optimized version
// can not handle such an object, because it was
// compiled under assumption that obj is always a Cat.
// This leads to deoptimization.
printAnimal(Dog());
每当优化的代码作出一些无法从静态不可变信息中产生的假设时,他都需要验证是否有违背此假设的情况,并在这种违背的情况发生时,回退到未优化的代码状态,能够正确执行。
这一流程被称作去优化(deoptimization): 每当优化的代码遇到一个他不能处理的情况时,他就将执行点转移到未优化的函数的匹配点,并在那里开始执行。未优化的版本没有任何假设,可以处理任何的输入。
VM 通常都会在发生反优化之后,丢弃掉他的优化版本,并重新使用新的反馈数据进行再次的优化。
防止 VM 作出推测假设的方法有两种:
- 内联检查(比如 CheckSmi,CheckClass IL 指令),验证使用点假设的正确性。例如,当将动态调用转换为直接调用的时候,编译器回在直接调用前做这个检查。发生在这些检查上的去优化,被称为是急切去优化(eager deoptimization),因为他在检查发生时,就要做去优化的工作。
- 全局保护,他指示运行时丢弃掉优化版本。比如优化编译器可能发现一些类 C 在类型传递过程中从来不会被继承。然后随后的代码可能会引入一个 C 的子类,那么这个推测就失效了。此时就需要运行时找到并丢弃掉所有在假设 C 没有子类的情况下做的代码优化。运行时可能会发现一些优化的代码已经无效了,在这种情况下,受影响的帧会被标记为去优化,并在执行返回时去优化。这种去优化被称作延迟去优化:因为只有当控制流反馈到优化的代码时才会进行。
源码阅读:去优化的代码位于 runtime/vm/deopt_instructions.cc
。 他本质上是一个小型的解释器,可以将优化的代码重新转换为未优化的代码状态。在优化代码中每个潜在可能去优化的位置处,都会通过 CompilerDeoptInfo::CreateDeoptInfo
中生成的去优化的指令。
尝试: 标志 --trace-deoptimization 会让 VM 打印出每个去优化发生位置的具体信息。 --trace-deoptimization-verbose 会让 VM 在去优化期间打印每一行去优化的指令。
1.2 从快照中运行
VM 能够将 isolate 中的堆,或者更多具体的对象图序列化成二进制快照。当 VM 再次启动时,可以利用快照恢复到之前相同的状态。
快照是为了启动速度而优化的底层格式 —— 他实质上是一个创建对象的列表,以及如何将对象关联起来的指令。快照背后的根本思想是:不需要解析 Dart 源码然后逐渐创建出 VM 的数据结构,而是直接将所有必要的数据从快照中解压缩出来,然后快速的生成一个 isolate 。
最初的快照没有包含机器码,但随着 AOT 编译器的加入,也被引入了进来。开发 AOT 的动机是为了让 VM 能够在那些限制使用 JIT 的平台上也能使用快照。
含有代码的快照的运行方式几乎与普通的快照一样,只有一点不同:他们包含了一个代码区,和快照其他部分不同的是,他们不需要反序列化。代码区在映射到内存后会直接称为堆的一部分。
源码阅读: runtime/vm/clustered_snapshot.cc
处理快照的序列化和反序列化。Dart_createXyzSnapshot [ AsAssembly ]是一系列 API 函数,负责写出堆的快照(比如 Dart_CreateAppJITSnapshotAsBlobs
和Dart_CreateAppAOTSnapshotAsAssembly
)。另一方面,Dart_CreateIsolate
可以选择启动时使用哪个快照。
1.3 从 AppJIT 的快照启动
AppJIT 快照的引入是为了减少类似 dartanalyzer 或 dart2js 这种大型 Dart 应用的 JIT 预热时间。在小型项目上时,代码运行的时间可能和使用用 JIT 运行的时间可能差不多。
而 AppJIT 就是来解决这个问题的:一个应用在启动 VM 的时候可以使用一些预设的训练好的数据和所有生成的代码以及 VM 的内部数据结构都会被序列化成一个 AppJIT 快照。然后可以通过下发快照而不是通过下发二进制文件来发布应用。通过这个快照启动的 VM 仍然可以进行 JIT —— 就是当实际上的执行数据和预设的训练数据不匹配的时候,就会运行。
尝试 在你运行应用的时候,如果传递了 --snapshot-kind=app-jit --snapshot=path-to-snapshot 两个参数,就会生成 AppJIT 的快照。下面是一个 dart2js 生成和使用 AppJIT 快照的例子。
# Run from source in JIT mode.
$ dart pkg/compiler/lib/src/dart2js.dart -o hello.js hello.dart
Compiled 7,359,592 characters Dart to 10,620 characters JavaScript in 2.07 seconds
Dart file (hello.dart) compiled to JavaScript: hello.js
# Training run to generate app-jit snapshot
$ dart --snapshot-kind=app-jit --snapshot=dart2js.snapshot \
pkg/compiler/lib/src/dart2js.dart -o hello.js hello.dart
Compiled 7,359,592 characters Dart to 10,620 characters JavaScript in 2.05 seconds
Dart file (hello.dart) compiled to JavaScript: hello.js
# Run from app-jit snapshot.
$ dart dart2js.snapshot -o hello.js hello.dart
Compiled 7,359,592 characters Dart to 10,620 characters JavaScript in 0.73 seconds
Dart file (hello.dart) compiled to JavaScript: hello.js
1.4 从 AppAOT 快照中运行
AOT 最终被引入的原因是为了支持那些不支持 JIT 的平台。但是他们也可以用于快速启动和避免潜在的性能损失。
没有 JIT 的能力意味着:
- AOT 快照必须应用运行期间所需的每一个方法的可执行代码。
- 可执行代码不能依赖任何可能在运行期会被推翻的推测假设。
为了满足这些需求,AOT 必须进行全局的静态分析(type flow analysis or TFA),从而决定应用的哪部分代码可以到达,那部分代码会被执行,哪些实例会被分配,以及他们之间的类型流动过程。所有这些分析都是保守的: 这意味着他们在正确性方面犯了错误——这与 JIT 在性能方面犯了错误形成了鲜明的对比,因为它总是在未优化的代码中去优化,以实现正确的行为。
然后将所有潜在可能会被触发的代码都编译为本地代码,这其中不会包含任何的推测优化。然而,类型信息还是会被使用,用于专门化代码(比如 devirtualize 调用)。
编译完所有的函数之后,就可以获取堆的快照了。
生成的快照可以使用与编译的运行时运行,这是一个去掉了 JIT 可动态代码加载工具等组件的 Dart VM 的一个特殊变体。
源码阅读: package:vm/transformations/type_flow/transformer.dart
是基于 TFA 结果的类型流分析和转换的切入点。 Precompiler::DoCompileAll
是 VM 中 AOT 编译的循环点。
尝试 AOT 编译管道目前还没有打包到 Dart SDK 中。如果有项目需要依赖他(比如 flutter)就必须用 SDK 提供方式手动构建他。 pkg/vm/tool/precompiler2
中的脚本,对于管道是如何构建的,以及必须构建哪些二进制构建才能使用它,有很高的参考价值。
# Need to build normal dart executable and runtime for running AOT code.
$ tool/build.py -m release -a x64 runtime dart_precompiled_runtime
# Now compile an application using AOT compiler
$ pkg/vm/tool/precompiler2 hello.dart hello.aot
# Execute AOT snapshot using runtime for AOT code
$ out/ReleaseX64/dart_precompiled_runtime hello.aot
Hello, World!
注意如果希望检查生成的 AOT 代码,可以将 --print-flow-graph-optimizedand
--disassemble-optimized 传递给 precompiler2 脚本。
1.4.1 可切换调用(Switchable Calls)
即使使用了全局和局部的 AOT 静态编译代码,也可能仍然会有一些调用,无法完全的被静态化。为了补偿 AOT 的 缺憾,在运行时会用到一个叫做内联缓存技术的扩展。这个扩展的版本名称是可切换调用。
JIT 部分已经描述了与调用点相关的每个内联缓存由哪两部分组成:一个缓存的对象(即一个 RawICData)和一大块本机代码去调用(比如 InlineCacheStub)。在 JIT 模式下运行时只会更新自己的缓存。然而 AOT 的运行时可以根据内联缓存的状态,去选择替换缓存和调用本机代码。
最初,所有的动态调用都以非链接状态开始。当这些调用首次到达了 UnlinkedCallStub,他只需要简单的调用运行时助手 DRT_UnlinkedCall 来链接这次调用。
DRT_UnlinkedCall 会尽可能的将这次调用转换为单态状态。在这个状态下,调用点会直接被转换为一次直接调用,就是说是通过一种特殊的调用点,直接访问已经被验证过的类来实现调用。
[站外图片上传中...(image-2503e5-1592291234144)]
在上面这个简单的例子中,我们假设当 obj.method() 首次被执行之后,obj 是一个 C 的实例,然后 obj.method 会被解析为 C.method.
下一次当我们执行到相同的调用点的时候,将会绕过一些列查找方法的过程,直接去调用 C.method。然而他会通过一个特殊的调用点来调用 C.method,这个过程将会验证 obj 是否仍然是一个 C 的实例。如果不是 DRT_MonomorphicMiss
的情况,将会重新进入查找过程,并找到下一个调用状态。
如果 obj 是一个 D 的实例,而 D 继承了 C,并且没有重写 C.method 方法时,C.method 仍然是 obj 的一个合法调用。在这种情况下,我们会检查这个调用点是否可以被转换到单目标状态,也就是 SingleTargetCallStub
(也可以参看 RawSingleTargetCache
)。
对于 AOT 编译,大多数类都使用继承层次结构,会通过深度优先遍历来分配整数 id。如果 C 是 D0,....,的基类,并且这些子类都没有重写 C.method 方法。然后 C.:cid <= classId(obj) < max(D0.:cid,...,Dn.:cid),这表示 obj.method 总是会被解析为 C.method 方法。在这种情况下,我们不会通过比较单态,而是通过 class id 的范围检查去让所有 C 的子类,完成这次调用。
其他情况下,调用点会通过线性查找的方式去查找内联缓存,非常像 JIT 模式下用到的方法。(参看ICCallThroughCodeStub
, RawICData
和 DRT_MegamorphicCacheMissHandler
)。
最后,如果线性数组中的检查次数增长超过了阈值,则会切换为类似字典的结构。(参看 MegamorphicCallStub
, RawMegamorphicCache
和 DRT_MegamorphicCacheMissHandler
)。