Dart VM是如何运行你的代码的

Dart VM有多种方式去运行Dart代码,比如:

  • JIT模式运行源码或者Kernal binary
  • 通过snapshot方式:AOT snapshot 和 AppJIT shanpshot

两者的主要区别在于VMDart源码转换成可执行代码的时机和方式。

VM中的任何Dart代码都是运行在隔离的isolate当中,isolate具有自己的内存(堆)和线程控制的隔离运行环境。VM可以同时具有多个isolate执行Dart的代码,但不同的isolate之间不能直接共享任何的状态,只能通过消息端口来进行通信。 我们所说的线程和isolate之间的关系其实有点模糊,而且isolate也比较依赖VM是怎样嵌入到应用程序当中的。线程和isolate有下面这样的一些限制:

  • 一个线程一次只能进入一个isolate,如果线程要进入另一个isolate就要先退出当前的
  • 一次只能有一个mutator线程和isolate相关联,mutator是用来执行Dart代码和调用VM API的线程

所以一个线程只能进入一个isolate执行Dart代码,退出之后才能进入另一个isolate。 不同的线程也能进入同一个isolate,但不能同时。

当然除了拥有一个mutator线程之外,isolate还可以有多个helper线程,比如:

  • 后台JIT编译线程
  • GC线程
  • 并发的GC标记线程

VM内部使用了线程池(dart::ThreadPool)来管理系统的线程,而且内部是基于 dart::ThreadPool::Task 的概念去构建的,而不是直接使用的系统线程。例如,GC的过程就是生成一个 dart::ConcurrentSweeperTask 丢给VM的线程池去处理,而不是使用一个专门的线程来做垃圾回收,线程池可以选择一个空闲的线程或者在没有空闲线程的时候新建一个线程来处理这个任务。类似的,消息循环的处理也并没有使用一个专门的event loop线程,而是在有新消息的时候产生一个 dart::MessageHandlerTask 给线程池。

执行源码

你可以在命令行下直接给Dart的源码去执行,例如:

// hello.dart
main() => print('Hello, World!');
$ dart hello.dart
Hello, World!

事实上Dart 2 VM之后就不再支持直接运行Dart源码了,VM使用了一种Kernel binaries(也就是 dill 文件)包含了序列化的 Kernel ASTs。所以源代码要先经过通用前端  common front-end (CFE) 处理成Kernel AST,而CFE是用Dart写的,可以给VM/dart2js/Dart Dev Compiler这些不同的Dart工具使用。

前端编译

那么为了保持直接执行Dart源码的便捷性,所以有一个叫做kernel serviceisolate,负责将Dart源码处理成KernelVM再将Kernel binary拿去运行。

但是CFE和用户的Dart代码是可以在不同的设备上执行,例如在Flutter当中,就是将Dart代码编译成Kernel,和执行Kernel的过程个隔离开来,编译Dart源码的步骤放在了用户的开发机上,执行Kernel放在了移动设备上,Flutter tools负责从开发机上将Kernel binary发送到移动设备上。

flutter tool并不能自己解析Dart源码,它使用了一个叫frontend_server的处理,frontend_server实际上就是CFE的封装和Flutter上特定的Kernel-to-Kernel的转换。frontend_server编译Dart源码到Kernel文件,flutter tools将它同步到执行设备上。Flutterhot reload也正是依赖frontend_server的,frontend_serverhot reload的过程中能够重用之前编译中的CFE状态,只重编已经更改了的部分。

Kernel binary装载

只有Kernel binary能够被VM加载,并解析创建各种对象。不过这个过程是懒加载的,只有被使用到的库和类的信息才会被装载。每一个程序的实体都会保留指向对应Kernel binary的指针,在需要的时候可以去加载更多的信息。

类的信息只有在被使用的过程中(例如:查找类的成员,或新建对象)才会被完全反序列化出来,从Kernel binary读取类的成员信息,但是函数只会反序列化出函数签名信息,函数体只有在被调用运行的时候才会进一步反序列化出来。

Kernel binary加载了足够多的信息供运行时成功解析和调用方法之后,就会去解析和调用到main函数了。

函数编译

程序运行的最初所有的函数主体都不是实际可执行的代码,而是一个占位符,指向LazyCompileStub,它只是简单的要求运行时系统为当前的函数生成可执行的代码,然后尾部调用新生成的代码。

首次编译函数时,是通过未优化编译器来完成的。

未优化编译器通过两个步骤来生成机器码:

  1. 对函数主体的序列化AST进行遍历,以生成函数主体的控制流程图CFGCFG由填充了中间语言IL指令的基本块组成。这里使用的IL指令类似于基于堆栈的虚拟机的指令:从堆栈中获取操作数,执行操作,然后将结果压入同一堆栈。
  2. CFG使用一对多的低级IL指令直接生成机器码:每条IL指令扩展为多条机器指令

这个过程中还没有执行优化,未优化编译器的目标是快速的生成可执行指令。这也意味着不会尝试静态解析任何未从Kernel binary文件中加载的调用,所以调用的编译是动态完成的。VM在这个时候还不能使用任何基于vitual table或者interface table的调度,而是使用inline caching实现动态调用的。

inline caching的核心是在调用的时候缓存对应方法解析的结果,VM使用的inline caching机制包括:

  • 一个调用的特殊缓存,将接收的类映射到方法,如果接收者具有匹配的类型则调用方法,缓存还会有一些辅助信息,比如:调用频次计数器,跟踪特定类型出现的频次。
  • 一个共享的stub,实现方法调用的快速路径,stub在给定的缓存中查找是否有和接收者匹配的类型,如果找到了增加相应的频次计数器,并且尾部调用缓存的方法;否则,stub调用系统的查找解析逻辑,如果解析成功就更新缓存,并且后续的调用使用对应缓存的方法。

下图说明了inline cacheanimal.toFace()调用时的关系和状态,使用Dog实例调用两次,Cat实例调用一次:

未优化的编译器足以执行所有的Dart代码,只是它的执行速度会慢一些,所以呢VM还需要实现自适应的优化编译路径,自适应的优化是采用程序运行时的信息去驱动优化策略。未优化的代码在运行时会收集以下信息:

  • Inline caches过程中每一个方法调用接受的类型信息
  • 执行计数器收集的热点代码区

当某个函数的执行计数器达到某个阈值,这个函数就会提交给后台优化编译器进行优化。

优化编译

优化编译的方式和未优化编译有点类似,通过遍历序列化的Kernel AST为正在优化的函数构建未优化的IL,不同的是与其直接将IL转换为机器码,优化编译器会将未优化的IL转换成基于static single assignment (SSA)的优化IL。基于SSAIL根据收集到的类型信息,经典的优化手段和Dart的特殊优化:比如,inlining, range analysis, type propagation, representation selection, store-to-load and load-to-load forwarding, global value numbering, allocation sinking, etc。最后,使用线性扫描寄存器分配器和简单的一对多的IL指令,将优化的IL降低为机器码。

编译完成之后后端编译器请求mutator线程进入一个安全点(safepoint)并且将优化的代码attaches到对应的调用函数上,下次调用该函数的时候就能直接使用优化的代码。

需要注意的是,由优化编译器生成的代码是基于运行时收集到的特定信息完成的,例如一个接受动态类型的函数调用,只接收到某个特定的类型,就会被转换成直接的调用,然后检查接收到的类型是否一致。但是在程序的执行过程中,有可能接收到的类型是其他的。

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());

反优化

优化代码是基于运行时信息对输入做了一些假设而产生的,如果在后续的运行过程中输入和假设不匹配,它就要防止违反这些假设,并且能够在违反的情况能够恢复正常运行。这个过程就叫着反优化:只要优化版本遇到无法处理的情况,它就会将执行转移到未优化函数的匹配点并继续运行。未优化的版本不做任何假设,可以处理所有可能的输入。

VM通常会在反优化后放弃优化的版本,然后在以后使用更新的类型反馈再次对其进行优化。VM防止违反优化假设一般有两种方式:

  • Inline checks (e.g. CheckSmi, CheckClass IL instructions)验证输入是否符合优化。例如,将动态调用转换为直接调用时,编译器会在直接调用之前添加这些检查。在此类检查中发生的反优化称为eager deoptimization,因为它很容易在 check 的时候被检测出来。
  • 全局保护程序,指令运行时在更改优化代码所依赖的内容时丢弃优化代码。例如,优化编译器可能发现某些类C从未扩展过,并在类型传播过程中使用了此信息。但是,随后的动态代码加载或类最终确定可能会引入C的子类-使得假设无效。这个时候,运行时需要查找并丢弃所有在C没有子类的假设下编译的优化代码。运行时可能会在执行堆栈上找到一些现在无效的优化代码,在这种情况下,受影响的frames将被标记,并且在执行返回时将对其进行反优化。这种反优化也称为延迟反优化:因为它会延迟到控制权返回到优化代码为止。

运行 Snapshots

VM有能力序列化isolate堆上的对象为二进制的snapshot文件,并且可以使用snapshot重新创建相同状态的isolate.

snapshot针对启动速度做了相应的优化,本质上是要创建的对象的列表和他们之间关系。相对于解析Dart源码并逐步创建VM内部的数据结构,VM可以将isolate所必须的数据结构全部打包在snapshot中。

但最初snapshot是不包括机器码的,在后来开发AOT编译的时候就加上去了,开发AOT编译和带机器码的snapshot是为了允许VM在一些无法JIT的平台上运行。带代码的snapshot几乎和普通的snapshot的工作方式是一样的,只是它带有一个代码块,这部分是不需要反序列化的,代码块可以直接map进堆内存。

运行 AppJIT snapshots

AppJIT snapshot可以减少大型Dart应用(比如:dartanalyzer 或者 dart2js)的JIT预热时间,在小型应用和VM使用JIT编译的时间差不多。

AppJIT snapshots其实是VM使用一些模拟的数据来训练程序,然后将生成的代码和VM内部的数据结构序列化而生成的,然后分发这个snapshot而不是源码或者Kernel binaryVM使用这个snapshot仍然可以在实际运行的过程中发现数据不匹配训练时而启用JIT

运行 AppAOT snapshots

AOT snapshot最初是为了无法进行JIT编译的平台而引入的,但也可以用来优化启动速度。无法进行JIT就意味着:

  1. AOT snapshot必须包含在应用程序执行期间可以调用的每个功能的可执行代码
  2. 可执行代码不能基于运行时的数据进行任何的假设

为了满足这些要求,AOT编译过程中会进行全局静态分析(type flow analysis or TFA),以从已知的入口点确定应用程序的哪些部分是被使用的,分配了哪些类以及类型是如何在程序中传递的。所有这些分析都是保守的,因为必须要保证正确性,有可能会牺牲一点性能,这跟JIT不太一样,JIT生成的代码还可以通过反优化来回到未优化的代码上运行。然后所有可达的代码块都将被编译成机器码,不会再进行任何的类型推测的优化。编译完所有的代码块之后,就可以获得堆的快照了。

然后,可以使用预编译的运行时来运行生成的snapshot,该运行时是Dart VM的特殊变体,其中不包括诸如JIT和动态代码加载工具之类的组件。

Switchable Calls

即使进行了全局和局部分析,AOT编译的代码仍可能包含无法静态虚拟化的调用操作。为了弥补这种情况,运行时使用了类似JIT过程中的inline cache,在这里叫着switchable callsJIT部分上面讲过了,inline cache主要包括两部分,一个缓存对象(通常是 dart::UntaggedICData )和一个VM的调用(例如:InlineCacheStub),在JIT模式下运行时只会更新 cache 的缓存,但是在AOT中,运行时可以根据inline cache的状态选择替换缓存和要调用的VM函数路径。

所有的动态调用最初都是unlinked状态,首次调用时会触发UnlinkedCallStub的调用,它又会调用DRT_UnlinkedCall去 link 当前的调用点。 如果DRT_UnlinkedCall尝试将调用点的状态切换为monomorphic,在这个状态下调用就会被替换成直接调用,它通过一个特殊的入口进入方法,并且在入口处验证类型。

在上图的例子中,当 obj.method() 首次执行时,obj 是 C 的实例,那么 obj.method 就会被解析成 C.method,下一次出现同样的调用就会直接调用到 C.method,跳过方法查找的过程。但是进入 C.method 仍然是通过一个特殊的入口进入的,验证 obj 是 C 的实例;如果不是的话,DRT_MonomorphicMiss 就会被调用尝试去进入下一个状态。C.method 有可能仍然是调用的目标函数,例如,obj 是类D的实例,D继承C并且没有overrideC.method。在这种情况下,我们检查是否可以进入single target状态,由 SingleTargetCallStub 实现(也可查看 dart::UntaggedSingleTargetCache)。

AOT编译过程中,大部分类会在继承结构的深度优先遍历过程分配一个 ID,如果类C具有D0..Dn这些子类,而且都没有override C.method,那么C.:cid <= classId(obj) <= max(D0.:cid, ..., Dn.:cid)表示 obj.method 会被解析成 C.method。在这种情况下,与其进行单态类(monomorphic状态)的比较,我们可以使用类的 ID 范围去检查C的所有子类。

换言之,调用的时候会使用线行扫描inline cache, 类似JIT模式(查看ICCallThroughCodeStubdart::UntaggedICData 以及 dart::PatchableCallHandler::DoMegamorphicMiss)

当然,如果线性数组中的检查数量超过阈值,将切换为使用类似字典的数据结构。(查看 MegamorphicCallStubdart::UntaggedMegamorphicCache 以及 dart::PatchableCallHandler::DoMegamorphicMiss)

参考链接


发布者

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注