DarVM笔记
原文: https://mrale.ph/dartvm/
Dart VM用于本机执行Dart代码的组件集合,可以把它比如成一个虚拟机, 主要包括以下内容:
运行时系统
- 对象模型
- 垃圾回收
- 编译的快照文件
核心库本机方法
辅助开发组件 - Debugging 断点调试
- Profiling 性能评测
- Hot-reload 热重载
两种不同的编译模式的pipie
- Just-in-Time (JIT) 用于开发阶段翻译dart代码并执行
- Ahead-of-Time (AOT) 用于release发布,将dart代码打包成机器码,减少体积和中间代码的转转,提高运行效率
解释器(Interpreter)
ARM架构的模拟器(ARM simulators)
DartVM的名字历史
它为高级语言dart提供了一套虚拟的运行环境,它并不总是解释执行dart代码和通过JIT的模式编译dart,在release阶段,它会通过通过另外一条piplie以aot的模式直接编译成机器码(这个模式将不会包含dart解释器相关组件,也就不能动态加载dart了),所以我们法版本的app就很不能通过服务器下发的dart文件通过手机动态执行了。
Dart VM运行代码
从Dart源代码或者通过JIT执行内核二进制文件
从编译好的Dart快照中执行
- AOTT快照
- AppJITT快照
主要的区别还是在于DartVM如何转换dart代码到可执行文件
虚拟机器中的任何dart代码都是运行在某个isolate(隔离空间中),每个ioslate都有自己独立访问的内存空间(heap),通常也有自己独立的线程控制和管理ioslate,当多个isolates并发执行dart代码时,不同的isolate之间由于内存独立,通过每个isolate暴露的端口互相通信,来控制内存的安全访问,
操作系统线程和isolate之间的关系有点模糊,并且高度依赖于VM如何嵌入到应用程序中,但是下面两点是确定的
- 同一时刻,只能有一个操作系统线程访问isolate,如果需要进入下一个isolate那么它需要离开当前的isolate
- 同一时间只能有一个异变线程和isolate关联,异变线程是一个使用VM公共的C API执行dart代码的线程,这个线程也称为控制线程,通过C API控制dart代码
异变线程它是一个处理虚拟机内部任务(比如GC, JIT等)的helper thread,isolate除了拥有一个mutator控制线程,还有一些其他辅助线程
- 后台JIT编译线程
- GC清理/并发标记线程。
- 并发GC标记线程。
但是,同一个OS线程可以先输入一个隔离,执行Dart代码,然后离开这个隔离,再输入另一个隔离。或者,许多不同的操作系统线程可以在其中输入隔离并执行Dart代码,只是不能同时执行。
PS: 从main函数开始会自动启动一个mainIsolate,如果我们不去手动创建isolate那么我们的业务代码都会在mainIsolate中执行
Dart VM的线程管理
Dart VM使用一个线程池dart::ThreadPool
来管理OS Thread
,对dart代码的操作是围绕dart::ThreadPool::Task
,而不是直接使用系统线程来操作.
举个栗子: 比如后台垃圾清理操作,dart vm会先创建一个dart::ConcurrentSweeperTask
(并行扫描任务),提交到全局的dart线程池中,最终由dart pool来分配线程执行.
通过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执行dart代码的方式,flutter将编译和执放在不同的设备上,编译于执行和内核分开,在host主机(一般为windows和mac电脑)进行编译,编译文件运行在模拟器和手机设备上.
Fluter tool
Flutter tool并不会自己解析dart源文件,它会创建一个frontend_server
,frontend_server
是对CFE
和一些Flutter特殊的内核到内核
的转换器的封装,编写的dart文件被frontend_server
编译成Kernel files
,flutter_tool将这些kernel文件发送到设备,这样frontend_server
可以接收开发者的hot reload
命令,frontend_server
可以重用先前的CFE
的状态,只编译dart文件变更的部分.
一旦内核二进制文件被加载到VM中,它就会被解析为创建表示各种程序实体的对象。然而,这是懒惰地完成的:首先只加载有关库和类的基本信息。源于内核二进制文件的每个实体都保留一个指向该二进制文件的指针,以便以后可以根据需要加载更多信息,如下图。
只有当运行时以后需要类时(例如查找类成员、分配实例等),才会完全反序列化有关该类的信息。在这个阶段,类成员从内核二进制文件中读取,如下图。但是,在这个阶段不反序列化全功能实体,只反序列化它们的签名,(方法实现部分则不会加载)
Keral ATS探索
需要提前下载两个库
https://github.com/dart-lang/sdk/blob/2d064faf748d6c7700f08d223fb76c84c4335c5f/pkg/kernel/lib/ast.dart)
https://github.com/dart-lang/sdk/blob/2d064faf748d6c7700f08d223fb76c84c4335c5f/pkg/front_end/lib
//方式一
# 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
//方式二
$ dart pkg/front_end/tool/_fasta/compile_platform.dart \
dart:core \
sdk/lib/libraries.json \
vm_outline.dill vm_platform.dill vm_outline.dill
函数解析
Dart函数的主体都有一个占位符,而不是一个实际可执行的代码:它们指向LazyCompileStub
,它只要求运行时系统为当前函数生成可执行代码,然后调用这个新生成的代码。
//找到函数实现的指向`LazyCompileStub`
Function(implemetation code ) -> LazyCompileStub
//运行时系统生成可执行代码
LazyCompileStub
realCode = ComplieFunction();
return realCode(..);
第一次编译函数时,这是由未优化的编译器完成的。
分为2部分
-
IR: 解析函数体的AST(Abstract Syntax Tree),对应上图生成的CFG(control flow grah),本质上就是将代码用一标准的格式文件转移出来,包括变量,方法,各种类型的名字等,也叫做中间语言指令(IL, Intermediate instructions),在此阶段使用的IL指令类似于基于堆栈的虚拟机的指令, 它们从堆栈中获取操作数,执行操作,然后将结果推送到同一堆栈中。
-
machine code: 生成的CFG直接编译成机器代码,使用一对多的IL指令:每条IL指令扩展成多条机器语言指令。
在此阶段没有执行任何优化。非优化编译器的主要目标是快速生成可执行代,这也意味着未优化编译器不会尝试静态解析任何未在内核二进制文件中解析的调用,因此调用(MethodInvocation或PropertyGet AST节点)被编译为完全动态的。VM目前不使用任何形式的基于虚拟表或接口表的分派,而是使用内联缓存实现动态调用。
inline cache: 内联缓存背后的核心思想是将方法解析的结果缓存在特定于调用站点的缓存中。VM使用的内联缓存机制包括:
- 一种特定于调用站点的缓存(dart::UntaggedICData),它将接收方的类映射到一个方法,如果接收方属于匹配的类,则应该调用该方法。缓存还存储一些辅助信息,例如调用频率计数器,
- 共享查找存根,实现方法调用快速路径。这个存根在给定的缓存中搜索,看它是否包含与接收者的类匹配的条目。如果找到了条目,那么stub将增加频率计数器和tail call cached方法。否则,存根将调用实现方法解析逻辑的运行时系统助手。若方法解析成功,那个么缓存将被更新,随后的调用将不需要进入运行时系统。
不优化编译器本身就足以执行任何可能的Dart代码。然而,它生成的代码相当慢,这就是为什么VM还实现了自适应优化编译管道。自适应优化背后的思想是使用正在运行的程序的执行概要来驱动优化决策。
在运行未优化的代码时,它会收集以下信息:
- 如上所述,内联缓存收集关于在调用站点观察到的接收器类型的信息;
- 与函数和函数中的基本块相关联的执行计数器跟踪代码的热点区域。
当与函数相关联的执行计数器达到某个阈值时,该函数将提交给后台优化编译器进行优化。
优化编译的启动方式与未优化编译的启动方式相同:通过遍历序列化内核AST为正在优化的函数构建未优化的IL。然而,优化编译器并没有直接将IL转换成机器代码,而是将未优化的IL转换成基于静态单赋值(SSA)形式的优化IL。然后,基于SSA的IL根据收集的类型反馈进行推测专门化,并通过一系列经典的和特定于Dart的优化:例如内联、范围分析、类型传播、表示选择、存储到加载和加载到加载转发、全局值编号、分配下沉,最后利用线性扫描寄存器分配器和简单的一对多IL指令降阶,将优化后的IL降阶为机器码。
Running from Snapshots
VM能够将隔离的堆或更准确地说驻留在堆中的对象图序列化为二进制快照。然后可以使用快照在启动VM时重新创建相同的状态。
Snapshot的格式是低级别的,并针对快速启动进行了优化—它本质上是一个要创建的对象列表以及如何将它们连接在一起的说明。这就是快照背后的最初想法:VM不必解析Dart源代码并逐步创建内部VM数据结构,只需将所有必需的数据结构快速地从数据库中解包出来,就可以实现隔离快照
最初快照不包括机器代码,但后来在开发AOT编译器时添加了此功能。开发带有代码的AOT编译器和快照的动机是允许VM在由于平台级限制而无法进行JITing的平台上使用。
带有代码的快照与普通快照的工作方式几乎相同,只是有一点不同:它们包含一个代码部分,与快照的其余部分不同,它不需要反序列化。此代码段的布局方式允许它在映射到内存后直接成为堆的一部分
Running from AppJIT snapshots
AppJIT快照的引入是为了减少大型Dart应用程序(如dartanalyzer或dart2js)的JIT预热时间。当这些工具用于小项目时,他们花在实际工作上的时间和VM花在JIT编译这些应用程序上的时间一样多。
AppJIT快照允许解决这个问题:可以使用一些模拟训练数据在VM上运行应用程序,然后将所有生成的代码和VM内部数据结构序列化为AppJIT快照。然后可以分发此快照,而不是以源(或内核二进制)形式分发应用程序。从这个快照开始的VM仍然可以使用JIT—如果实际数据上的执行配置文件与训练期间观察到的执行配置文件不匹配。
参考文章
https://mrale.ph/dartvm/