Dart虛擬機運行原理
一、Dart虛擬機
1.1 引言
Dart VM是一種虛擬機,為高級編程語言Dart提供執行環境,但這并意味著Dart在D虛擬機上執行時,總是采用解釋執行或者JIT編譯。 例如還可以使用Dart虛擬機的AOT管道將Dart代碼編譯為機器代碼,然后運行在Dart虛擬機的精簡版環境,稱之為預編譯運行時(precompiled runtime)環境,該環境不包含任何編譯器組件,且無法動態加載Dart源代碼。
1.2 虛擬機如何運行Dart代碼
Dart VM有多鐘方式來執行代碼:
- 源碼或者Kernel二進制(JIT)
- snapshot
- AOT snapshot
- AppJIT snapshot
區別主要在于什么時機以及如何將Dart代碼轉換為可執行的代碼。
1.3 Isolate組成
先來看看dart虛擬機中isolate的組成:
image
- isolate堆是運該isolate中代碼分配的所有對象的GC管理的內存存儲;
- vm isolate是一個偽isolate,里面包含不可變對象,比如null,true,false;
- isolate堆能引用vm isolate堆中的對象,但vm isolate不能引用isolate堆;
- isolate彼此之間不能相互引用
- 每個isolate都有一個執行dart代碼的Mutator thread,一個處理虛擬機內部任務(比如GC, JIT等)的helper thread;
isolate擁有內存堆和控制線程,虛擬機中可以有很多isolate,但彼此之間不能直接狀態,只能通過dart特有的端口;isolate除了擁有一個mutator控制線程,還有一些其他輔助線程:
- 后臺JIT編譯線程;
- GC清理線程;
- GC并發標記線程;
線程和isolate的關系是什么呢?
- 同一個線程在同一時間只能進入一個isolate,當需要進入另一個isolate則必須先退出當前的isolate;
- 一次只能有一個Mutator線程關聯對應的isolate,Mutator線程是執行Dart代碼并使用虛擬機的公共的C語言API的線程
1.4 ThreadPool組成
虛擬機采用線程池的方式來管理線程,定義在runtime/vm/thread_pool.h
image
ThreadPool的核心成員變量:
- all_workers_:記錄所有的workers;
- idle_workers:_記錄所有空閑的workers;
- count_started_:記錄該線程池的歷史累計啟動workers個數;
- count_stopped_:記錄該線程池的歷史累計關閉workers個數;
- count_running_:記錄該線程池當前正在運行的worker個數;
- count_idle_:記錄該線程池當前處于空閑的worker個數,也就是idle_workers的長度;
ThreadPool核心方法:
- Run(Task*): 執行count_running_加1,并將Task設置到該Worker,
- 當idle_workers_為空,則創建新的Worker并添加到all_workers_隊列頭部,count_started_加1;
- 當idle_workers_不為空,則取走idle_workers_隊列頭部的Worker,count_idle_減1;
- Shutdown(): 將all_workers_和idle_workers_隊列置為NULL,并將count_running_和count_idle_清零,將關閉的all_workers_個數累加到count_stopped_;
- SetIdleLocked(Worker*):將該Worker添加到idle_workers_隊列頭部,count_idle_加1, count_running_減1;
- ReleaseIdleWorker(Worker*):從all_workers_和idle_workers_隊列中移除該Worker,count_idle_減1,count_stopped_加1;
對應關系圖:
count_started_ | count_stopped_ | count_running_ | count_idle_ |
---|---|---|---|
Run() | +1(無空閑worker) | +1 | -1(有空閑worker) |
Shutdown() | +all_workers_個數 | 清零 | 清零 |
SetIdleLocked() | -1 | +1 | |
ReleaseIdleWorker() | +1 | -1 |
可見,count_started_ - count_stopped_ = count_running_ + count_idle_;
二、JIT運行模式
2.1 CFE前端編譯器
看看dart是如何直接理解并執行dart源碼
// gityuan.dart
main() => print('Hello Gityuan!');//dart位于flutter/bin/cache/dart-sdk/bin/dart
$ dart gityuan.dart
Hello, World!
說明:
- Dart虛擬機并不能直接從Dart源碼執行,而是執行dill二進制文件,該二進制文件包括序列化的Kernel AST(抽象語法樹)。
- Dart Kernel是一種從Dart中衍生而來的高級語言,設計之初用于程序分析與轉換(transformations)的中間產物,可用于代碼生成與后端編譯器,該kernel語言有一個內存表示,可以序列化為二進制或文本。
- 將Dart轉換為Kernel AST的是CFE(common front-end)通用前端編譯器。
- 生成的Kernel AST可交由Dart VM、dev_compiler以及dart2js等各種Dart工具直接使用。
image
2.2 kernel service
有一個輔助類isolate叫作kernel service,其核心工作就是CFE,將dart轉為Kernel二進制,然后VM可直接使用Kernel二進制運行在主isolate里面運行。
image
2.3 debug運行
將dart代碼轉換為kernel二進制和執行kernel二進制,這兩個過程也可以分離開來,在兩個不同的機器執行,比如host機器執行編譯,移動設備執行kernel文件。
image
圖解:
- 這個編譯過程并不是flutter tools自身完成,而是交給另一個進程frontend_server來執行,它包括CFE和一些flutter專有的kernel轉換器。
- hot reload:熱重載機制正是依賴這一點,frontend_server重用上一次編譯中的CFE狀態,只重新編譯實際更改的部分。
2.4 RawClass內部結構
虛擬機內部對象的命名約定:使用C++定義的,其名稱在頭文件raw_object.h中以Raw開頭,比如RawClass是描述Dart類的VM對象,RawField是描述Dart類中的Dart字段的VM對象。
1)將內核二進制文件加載到VM后,將對其進行解析以創建表示各種程序實體的對象。這里采用了懶加載模式,一開始只有庫和類的基本信息被加載,內核二進制文件中的每一個實體都會保留指向該二進制文件的指針,以便后續可根據需要加載更多信息。
image
2)僅在以后需要運行時,才完全反序列化有關類的信息。(例如查找類的成員變量,創建類的實例對象等),便會從內核二進制文件中讀取類的成員信息。 但功能完整的主體(FunctionNode)在此階段并不會反序列化,而只是獲取其簽名。
image
到此,已從內核二進制文件加載了足夠的信息以供運行時成功解析和調用的方法。
所有函數的主體都具有占位符code_,而不是實際的可執行代碼:它們指向LazyCompileStub,該Stub只是簡單地要求系統Runtime為當前函數生成可執行代碼,然后對這些新生成的代碼進行尾部調用。
image
2.5 查看Kernel文件格式
gen_kernel.dart利用CFE將Dart源碼編譯為kernel binary文件(也就是dill),可利用dump_kernel.dart能反解kernel binary文件,命令如下所示:
//將hello.dart編譯成hello.dill
$ cd <FLUTTER_ENGINE_ROOT>
$ dart third_party/dart/pkg/vm/bin/gen_kernel.dart \--platform out/android_debug/vm_platform_strong.dill \-o hello.dill \hello.dart//轉儲AST的文本表示形式
$ dart third_party/dart/pkg/vm/bin/dump_kernel.dart hello.dill hello.kernel.txt
gen_kernel.dart文件,需要平臺dill文件,這是一個包括所有核心庫(dart:core, dart:async等)的AST的kernel binary文件。如果Dart SDK已經編譯過,可直接使用out/ReleaseX64/vm_platform_strong.dill,否則需要使用compile_platform.dart來生成平臺dill文件,如下命令:
//根據給定的庫列表,來生成platform和outline文件
$ cd <FLUTTER_ENGINE_ROOT>
$ dart third_party/dart/pkg/front_end/tool/_fasta/compile_platform.dart \dart:core \ third_party/dart/sdk/lib/libraries.json \vm_outline.dill vm_platform.dill vm_outline.dill
2.6 未優化編譯器
首次編譯函數時,這是通過未優化編譯器來完成的。
image
未優化的編譯器分兩步生成機器代碼:
- AST -> CFG: 對函數主體的序列化AST進行遍歷,以生成函數主體的控制流程圖(CFG),CFG是由填充中間語言(IL)指令的基本塊組成。此階段使用的IL指令類似于基于堆棧的虛擬機的指令:它們從堆棧中獲取操作數,執行操作,然后將結果壓入同一堆棧
- IL -> 機器指令:使用一對多的IL指令,將生成的CFG直接編譯為機器代碼:每個IL指令擴展為多條機器指令。
在此階段沒有執行優化,未優化編譯器的主要目標是快速生成可執行代碼。
2.7 內聯緩存
未優化編譯過程,編譯器不會嘗試靜態解析任何未在Kernel二進制文件中解析的調用,因此(MethodInvocation或PropertyGet AST節點)的調用被編譯為完全動態的。虛擬機當前不使用任何形式的基于虛擬表(virtual table)或接口表(interface table)的調度,而是使用內聯緩存實現動態調用。
虛擬機的內聯緩存的核心思想是緩存方法解析后的站點結果信息,對于內聯緩存最初是為了解決函數的本地代碼:
- 站點調用的特定緩存(RawICData對象)將接受者的類映射到方法,緩存中記錄著一些輔助信息,比如方法和基本塊的調用頻次計數器,該計數器記錄著被跟蹤類的調用頻次;
- 共享的查找存根,用于實現方法調用的快速路徑。該存根在給定的高速緩存中進行搜索,以查看其是否包含與接收者的類別匹配的條目。 如果找到該條目,則存根將增加頻率計數器和尾部調用緩存的方法。否則,存根將調用系統Runtime來解析方法實現的邏輯,如果方法解析成功,則將更新緩存,并且隨后的調用無需進入系統Runtime。
image
2.8 編譯優化
未優化編譯器產生的代碼執行比較慢,需要自適應優化,通過profile配置文件來驅動優化策略。內聯優化,當與某個功能關聯的執行計數器達到某個閾值時,該功能將提交給后臺優化編譯器進行優化。
優化編譯的方式與未優化編譯的方式相同:通過序列化內核AST來構建未優化的IL。但是,優化編譯器不是直接將IL編譯為機器碼,而是將未優化的IL轉換為基于靜態單分配(SSA)形式的優化的IL。
對基于SSA的IL通過基于收集到的類型反饋,內聯,范圍分析,類型傳播,表示選擇,存儲到加載,加載到加載轉發,全局值編號,分配接收等一系列經典和Dart特定的優化來進行專業化推測。最后,使用線性掃描寄存器分配器和一個簡單的一對多的IL指令。優化編譯完成后,后臺編譯器會請求mutator線程輸入安全點,并將優化的代碼附加到該函數。下次調用該函數時,它將使用優化的代碼。
image
另外,有些函數包含很長的運行循環,因此在函數仍在運行時將執行從未優化的代碼切換到優化的代碼是有意義的,此過程之所以稱為“堆棧替換”(OSR)。
VM還具有可用于控制JIT并使其轉儲IL以及用于JIT正在編譯的功能的機器代碼的標志
$ dart --print-flow-graph-optimized \--disassemble-optimized \--print-flow-graph-filter=myFunc \--no-background-compilation \hel.dart
2.9 反優化
優化是基于統計的,可能出現違反優化的情況
void printAnimal(obj) {print('Animal {');print(' ${obj.toString()}');print('}');
}// 大量調用的情況下,會推測printAnimal假設總是Cat的情況下來優化代碼
for (var i = 0; i < 50000; i++)printAnimal(Cat());// 此處出現的是Dog,優化版本失效,則觸發反優化
printAnimal(Dog());
每當只要優化版本遇到無法解決的情況,它就會將執行轉移到未優化功能的匹配點,然后繼續執行,這個恢復過程稱為去優化:未優化的功能版本不做任何假設,可以處理所有可能的輸入。
虛擬機通常會在執行一次反優化后,放棄該功能的優化版本,然后在以后使用優化的類型反饋再次對其進行重新優化。虛擬機保護編譯器進行推測性假設的方式有兩種:
- 內聯檢查(例如CheckSmi,CheckClass IL指令),以驗證假設是否在編譯器做出此假設的使用場所成立。例如,將動態調用轉換為直接調用時,編譯器會在直接調用之前添加這些檢查。 在此類檢查中發生的取消優化稱為“急切優化”,因為它在達到檢查時就急于發生。
- 運行時在更改優化代碼所依賴的內容時,將會丟棄優化代碼。例如,優化編譯器可能會發現某些類從未擴展過,并且在類型傳播過程中使用了此信息。 但是,隨后的動態代碼加載或類最終確定可能會引入C的子類,導致假設無效。此時,運行時需要查找并丟棄所有在C沒有子類的假設下編譯的優化代碼。 運行時可能會在執行堆棧上找到一些現在無效的優化代碼,在這種情況下,受影響的幀將被標記為不優化,并且當執行返回時將進行不優化。 這種取消優化稱為延遲取消優化,因為它會延遲到控制權返回到優化代碼為止。
三、Snapshots運行模式
3.1 通過Snapshots運行
1)虛擬機有能力將isolate的堆(駐留在堆上的對象圖)序列化成二進制的快照,啟動虛擬機isolate的時候可以從快照中重新創建相同的狀態。
image
Snapshot的格式是低級的,并且針對快速啟動進行了優化,本質上是要創建的對象列表以及如何將它們連接在一起的說明。那是快照背后的原始思想:代替解析Dart源碼并逐步創建虛擬機內部的數據結構,這樣虛擬機通過快照中的所有必要數據結構來快速啟動isolate。
2)最初,快照不包括機器代碼,但是后來在開發AOT編譯器時添加了此功能。開發AOT編譯器和帶代碼快照的動機是為了允許虛擬機在由于平臺級別限制而無法進行JIT的平臺上使用。
帶代碼的快照的工作方式幾乎與普通快照相同,只是有一點點不同:它們包括一個代碼部分,該部分與快照的其余部分不同,不需要反序列化。該代碼節的放置方式使其可以在映射到內存后直接成為堆的一部分
image
3.2 通過AppJIT Snapshots運行
引入AppJIT快照可減少大型Dart應用程序(如dartanalyzer或dart2js)的JIT預熱時間。當這些工具用于小型項目時,它們花費的實際時間與VM花費的JIT編譯這些應用程序的時間一樣多。
AppJIT快照可以解決此問題:可以使用一些模擬訓練數據在VM上運行應用程序,然后將所有生成的代碼和VM內部數據結構序列化為AppJIT快照。然后可以分發此快照,而不是以源(或內核二進制)形式分發應用程序。如果出現實際數據上的執行配置文件與培訓期間觀察到的執行配置文件不匹配,快照開始的VM仍可以采用JIT模式執行。
3.3 通過AppAOT Snapshots運行
AOT快照最初是為無法進行JIT編譯的平臺引入的,對于無法進行JIT意味著:
- AOT快照必須包含應用程序執行期間可能調用的每個功能的可執行代碼;
- 可執行代碼不得依賴于執行期間可能違反的任何推測性假設
為了滿足這些要求,AOT編譯過程會進行全局靜態分析(類型流分析, TFA),以確定從已知入口點集中可訪問應用程序的哪些部分,分配了哪些類的實例以及類型如何在程序中流動。 所有這些分析都是保守的:這意味著它們會在正確性方面出錯,與可以在性能方面出錯的JIT形成鮮明對比,因為它始終可以取消優化為未優化的代碼以實現正確的行為。
然后,所有可能達到的功能都將編譯為本地代碼,而無需進行任何推測性優化。但是,類型流信息仍用于專門化代碼(例如,取消虛擬化調用),編譯完所有函數后,即可獲取堆的快照。
最終的快照snapshot可以運行在預編譯Runtime,該Runtime是Dart VM的特殊變體,其中不包括諸如JIT和動態代碼加載工具之類的組件。
image
AOT編譯工具沒有包含進Dart SDK。
//需要構建正常的dart可執行文件和運行AOT代碼的runtime
$ tool/build.py -m release -a x64 runtime dart_precompiled_runtime// 使用AOT編譯器來編譯APP
$ pkg/vm/tool/precompiler2 hello.dart hello.aot//執行AOT快照
$ out/ReleaseX64/dart_precompiled_runtime hello.aot
Hello, World!
3.3.1 Switchable Calls
1)即使進行了全局和局部分析,AOT編譯的代碼仍可能包含無法靜態的去虛擬化的調用站點。為了補償此AOT編譯代碼和運行時,采用JIT中使用的內聯緩存技術的擴展。此擴展版本稱為可切換呼叫 (Switchable Calls)。
JIT部分已經描述過,與調用站點關聯的每個內聯緩存均由兩部分組成:一個緩存對象(由RawICData實例表示)和一個要調用的本機代碼塊(例如InlineCacheStub)。在JIT模式下,運行時只會更新緩存本身。但在AOT運行時中,可以根據內聯緩存的狀態選擇同時替換緩存和要調用的本機代碼。
image
最初,所有動態呼叫均以未鏈接狀態開始。首次調用此類呼叫站點時,將調用UnlinkedCallStub,它只是調用運行時幫助程序DRT_UnlinkedCall來鏈接此呼叫站點。
2)如果可能,DRT_UnlinkedCall嘗試將呼叫站點轉換為單態狀態。在這種狀態下,呼叫站點變成直接呼叫,該呼叫通過特殊的單態入口點進入方法,該入口點驗證接收方是否具有預期的類。
image
在上面的示例中,假設第一次執行obj.method()時,obj是C的實例,而obj.method則解析為C.method。
下次執行相同的調用站點時,它將直接調用C.method,從而繞過任何類型的方法查找過程。但是,它將通過特殊的入口點(已驗證obj仍然是C的實例)進入C.method。如果不是這種情況,將調用DRT_MonomorphicMiss并將嘗試選擇下一個調用站點狀態。
3)C.method可能仍然是調用的有效目標,例如obj是C的擴展類但不覆蓋C.method的D類的實例。在這種情況下,檢查呼叫站點是否可以轉換為由SingleTargetCallStub實現的單個目標狀態(見RawSingleTargetCache)。
image
此存根基于以下事實:對于AOT編譯,大多數類都使用繼承層次結構的深度優先遍歷來分配整數ID。如果C是具有D0,…,Dn子類的基類,并且沒有一個覆蓋C.method,則C.:cid <= classId(obj)<= max(D0.:cid,…,Dn .:cid)表示obj.method解析為C.method。在這種情況下,我們可以將類ID范圍檢查(單個目標狀態)用于C的所有子類,而不是與單個類(單態)進行比較
否則,呼叫站點將切換為使用線性搜索內聯緩存,類似于在JIT模式下使用的緩存。
image
最后,如果線性數組中的檢查數量超過閾值,則呼叫站點將切換為使用類似字典的結構