deno
node.js之父Ryan Dahl在一個月前發起了名為deno的項目,項目的初衷是打造一個基於v8引擎的安全的TypeScript運行時,同時實現HTML5的基礎API。所謂的安全運行時,是將TS代碼運行在一個沙盒里,訪問受限的文件系統、網絡功能,這比較類似於web里的iframe sandbox。
現階段,deno的變化可謂翻天覆地。Ryan的項目一個月前提供了golang版本的deno簡易源碼,而如今不僅僅重構了項目,底層語言都切換為c++,接口也做了很大的更新,這源自於社區內熱情的討論,有太多太多的開發者、協作人員提出了太多的優化以及改進意見,這也就導致接下來未來幾個月deno仍然會出現大改變,這在后文會提及。現在,我就帶領大家進入最初的deno微觀世界探索deno最初的設計。
架構
q 本文講解deno的golang版本,當前最新的deno由於性能問題放棄了golang的實現,但這不影響我們分析deno的原理。未來在七月deno估計會釋放出基於Rust的底層特權級實現,性能更優。
q 由於deno涉及之處是為了直接運行TS,因此下文會用TS來代指JS(現階段TS沒有自己的運行時,仍是基於編譯為JS在運行在v8)
deno的設計初期來看比較簡單,宏觀上看包括三部分:deno的go運行時、v8引擎以及連接go運行時和v8的v8worker2庫。
go運行時是deno的特權級,它負責deno對系統資源的申請、使用、釋放;v8引擎此處不僅僅執行JS代碼,同時也負責TypeScript的編譯;而v8worker2負責go與v8的全雙工通信,通過ArrayBuffer傳輸數據,傳輸的協議規范為protobuf。
深入到go運行時里,目前deno對TS層提供了幾種能力:Console、fetch、fs、module、timer、stack trace,雖然有些功能沒有提供用戶端API,不過golang的接口已完成,擴展很容易。
go運行時
deno在特權級代碼執行了3端邏輯:
- 初始化go運行時環境
- 初始化TS運行時環境
- 啟動go這一側的事件循環(該事件循環不同於node的基於libuv的event loop,下文會提到)
初始化go運行時環境
// HOME目錄下創建 cache和src目錄
createDirs()
// 利用 afero 庫創建虛擬fs對象;同時訂閱 v8端的 os事件,在go端實現 文件抓取、獲取緩存、磁盤I/O,同時返回 proto序列化數據 給v8
InitOS()
// 心跳
InitEcho()
// 接受v8消息,進行 timeout、interval和clear
InitTimers()
// 訂閱 fetch 事件,代理服務器。當代理請求結束時,返回兩個消息:第一個為狀態碼;第二個為body體
InitFetch()
// recv為 v8->go 的回調函數,處理v8的消息
worker = v8worker2.New(recv)
// 初始化ts的相關環境,和go端對應
main_js = stringAsset("main.js")
err := worker.Load("/main.js", main_js)
exitOnError(err)
依次執行以下任務:
- 創建緩存目錄,存儲TS文件編譯后的JS文件
- 訂閱 os 事件,處理來自v8層的操作,如fs等
- 訂閱 timer 事件,處理來自v8的定時器操作
- 訂閱 fetch 事件,處理來自v8的http request
- 初始化v8worker2實例,實現go與v8的綁定
- 加載js入口文件main.js,該文件定義了js的全局接口、初始化邏輯和與go運行時通信的方法,等待下一階段的執行。
初始化js運行時環境
// v8端執行 denoMain函數,在main.ts中定義
deno.Eval("deno_main.js", "denoMain()")
上一步v8已經加載並執行了main.js文件,現在該執行denoMain方法了。denoMain是在main.js中定義的初始化方法,它定義了deno在js層的API以及v8worker實例,也是開發者密切相關的一層。
關於ts層的邏輯留在下文講述。
啟動事件循環
var resChan = make(chan *BaseMsg, 10)
var doneChan = make(chan bool)
var wg sync.WaitGroup
wg.Add(1)
first := true
// In a goroutine, we wait on for all goroutines to complete (for example
// timers). We use this to signal to the main thread to exit.
// wg.Add(1) basically translates to uv_ref, if this was Node.
// wg.Done() basically translates to uv_unref
go func() {
wg.Wait()
doneChan <- true
}()
for {
select {
case msg := <-resChan:
out, err := proto.Marshal(msg)
check(err)
err = worker.SendBytes(out)
stats.v8workerSend++
stats.v8workerBytesSent += len(out)
exitOnError(err)
wg.Done() // Corresponds to the wg.Add(1) in Pub().
case <-doneChan:
// All goroutines have completed. Now we can exit main().
checkChanEmpty()
return
}
// We don't want to exit until we've received at least one message.
// This is so the program doesn't exit after sending the "start"
// message.
if first {
wg.Done()
}
first = false
}
熟悉go語言的人會發現這是協程goroutine的典型用法:
main協程開啟循環,監聽來自resChan channel的消息,當接受到resChan的消息時意味着此刻go運行時需要向v8返回相關數據,如定時器執行結果、網絡請求結果,執行對應的select case,通過v8worker2寫入經過protobuf處理后的數據,進入下一次循環;直到go運行時此刻處理完所有的ts請求,會執行協程中的邏輯doneChan <- true
,最終觸發main協程的case case <-doneChan
,結束事件循環退出程序。
因此,deno的golang版本的事件循環與node基於libuv的事件循環並不是一回事,因此不能一概而論。
TS運行時與v8worker2
TS運行時對應於v8的實例isolate,在isolate上定義了handscope、context以及在handscope范圍內的一系列句柄對象。TS運行時的初始化配置是在v8worker2中定義的,在v8worker2中,借助cgo模塊實現go與c的通信:go可以調用c庫,同時也可到處go函數給c程序使用。在本文中,這不是要講述的重點,有興趣的同學可以等下一篇文章的介紹。
總之,TS運行時的初始化是由go的v8worker2模塊執行,它向v8暴露了global全局變量,同時提供了global變量下提供V8Worker2對象,用於v8與golang的通信。
TS運行時初始化完畢后,看是准備deno在TS層的執行環境,包括:
- 初始化定時器事件,監聽go運行時返回的timer事件,該事件對象里有TS調用定時器的返回結果
- 初始化 fetch 事件,該事件對象里有TS請求net、fs的返回值
- 訂閱 start 事件,等待執行deno程序
在 start事件處理函數中,deno做了兩件事:
- 編譯TS源文件
- 執行JS文件
deno使用typescript模塊提供的LanguageServiceHost功能,采用硬編碼的編譯規則
ts.CompilerOptions = {
allowJs: true,
module: ts.ModuleKind.AMD,
outDir: "$deno$",
inlineSourceMap: true,
lib: ["es2017"],
inlineSources: true,
target: ts.ScriptTarget.ES2017
};
默認使用es2017規范,模塊規范使用AMD規范。
ts模塊的加載
目前ts模塊加載支持fs和nfs,也就是“相對路徑加載和網絡加載”,如
import { printHello } from "./subdir/print_hello.ts";
import { printHelloNfs } from "http://localhost:4545/testdata/subdir/print_hello.ts";
printHello();
printHelloNfs();
TS模塊如何轉換為AMD規范並且如何確定加載順序,下面舉例說明:
有兩個ts文件: a.ts和say.ts
a.ts:
import say from './say';
say('hello world');
--------------------
say.ts:
export function say(msg){
console.log(msg)
}
執行命令 deno a.ts
,返回“hello world”。
經過ts運行時的編譯后,a.ts的編譯后的代碼為:
define(["require", "exports", "./say.ts"], function (require, exports, say) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
say(msg);
});
其中,回調函數的require參數簡單的require實現,exports為a.ts模塊的導出對象,say模塊則為say.ts的導出對象。
對於“./say.ts”文件,是由ts運行時通過v8worker2傳遞消息由go運行時獲取對應源文件(此處通過fs或者net),通過ArrayBuffer傳遞給ts運行時,並進行編譯、運行,傳遞給引用模塊a.ts。最后,當所有依賴模塊加載完畢之后,a.ts的回調函數執行,實現模塊間時序的調度。
q 關於模塊加載問題,社區內有提出異議,即增加絕對路徑的引用方式: import "/abc/test.ts". 不過Ryan認為這種絕對路徑方式會與系統的根目錄進行沖突,而且不符合deno所提出的“安全的TS運行時”,這樣會暴露系統的路徑或文件信息。不過社區也提出了解決方案,即在deno運行時提供命令行參數 --baseDir,標識當前deno進程的根目錄,防止訪問系統的文件系統。
頗具爭議的v8worker2與protobuf
其實deno的golang實現被詬病最多的也正是v8worker2與protobuf。這兩個模塊非常有名,但是不太適用於deno的場景。
首先說道protobuf,這是google提出的一種跨平台跨語言的結構化數據存儲格式,它是有類型聲明的,通過protobuf的命令行工具可以生成不同語言的代碼,操作對應的數據結構。但是protobuf的性能瓶頸在於序列化與反序列化,這也正是protobuf作者在deno項目下質疑Ryan的原因,他推薦使用 Cap'n Proto來進行數據傳遞。 Cap'n Proto比較有意思,它使用ArrayBuffer進行傳遞,並且不需要序列化為對應語言的相關變量,直接提供一套方法讀取二進制數據(類似於訪問數組使用的偏移量),更快。
對於v8worker2模塊,筆者通讀了這個binding實現,其實Ryan對於v8worker2已經盡可能優化了,不過並沒有開啟v8的snapshot特性,對於重復引入的模塊會有些性能損失。但是最重要的瓶頸其實在於v8worker2依賴的cgo模塊。cgo對於c庫以及編譯器的支持非常的不錯,但是在數據類型的轉換耗費性能比較多。
下圖為社區針對golang版本的deno做出的go運行時的性能分析:
可以看出v8worker2的SendBytes和Load執行占比已超過70%。而這兩個函數主要邏輯是使用cgo完成數據傳遞以及TS執行。
社區也有相關cgo性能瓶頸的介紹,即go中的協程goroutien不同於OS的線程,在具體實現上取決於GOMAXPROCS設置以及調度策略。一旦通過cgo在c語言進行系統調用,那么會導致當前go routine所在的線程睡眠,直到調用返回。那么其他跑在當前線程的go routine都會被阻塞導致性能下降。因此,Ryan下個版本也會放棄使用go的v8worker2模塊。
deno的golang版本生命終結
終於到了這個話題,golang實現的deno現在已經被放棄了,這是由於性能問題導致的:
- 與c/c++綁定性能差,這是由cgo模塊導致的,也直接導致deno的golang實現tps小,rt比較大
- golang的GC機制導致的性能的不確定性。目前v8采用的是標記清楚+整理的GC算法,而golang運行時也運行類似的GC算法,這樣在多線程中存在兩個並行的GC線程會對程序運行造成非常大的不確定性
- 社區內Rust力量壯大,Rust的服務器性能越發強大,而且沒有GC機制,與c通信性能高過golang,因此也算是個推進因素
不過,雖然golang版本的deno走到了終點,我們通過Ryan的實現仍然很容易把握住deno的脈絡,因此對於相關的開發者仍有借鑒和參考意義。
deno的未來及感慨
就目前社區內部的討論以及Ryan的決定來看,deno在七月份仍有重大改變:底層的代碼會切換為Rust,會使用libdeno作為Rust和C的binding。deno社區目前還非常活躍,各種想法和思潮互相碰撞,比如關於模塊管理與加載、API的設計、v8編譯TS的優化等,在這個時代我們必須要跟上浪潮,學習這些弄潮人的思想及設計理念。
筆者之前非常專注於node的攝入挖掘與應用,不過自從deno出來之后帶給筆者的震撼遠非語言之能形容。因此學習golang、閱讀v8文檔通讀deno,盡量走出自己的舒適區感受牆外的先進思想,碰撞中學習,求同中存異,收貨頗豐。最后感慨下,是不是國內相對封閉的互聯網環境導致國內前端或全棧領域的思維有些僵化,無法產生並主導這種非常有意思的idea和項目,當然也有可能是我們每天忙於業務需求中無法自拔。願國內開發者且行且珍惜,不能被國外的同行甩開太多。