deno深刻揭祕及將來展望

deno

node.js之父Ryan Dahl在一個月前發起了名爲deno的項目,項目的初衷是打造一個基於v8引擎的安全的TypeScript運行時,同時實現HTML5的基礎API。所謂的安全運行時,是將TS代碼運行在一個沙盒裏,訪問受限的文件系統、網絡功能,這比較相似於web裏的iframe sandbox。前端

現階段,deno的變化可謂翻天覆地。Ryan的項目一個月前提供了golang版本的deno簡易源碼,而現在不只僅重構了項目,底層語言都切換爲c++,接口也作了很大的更新,這源自於社區內熱情的討論,有太多太多的開發者、協做人員提出了太多的優化以及改進意見,這也就致使接下來將來幾個月deno仍然會出現大改變,這在後文會說起。如今,我就帶領你們進入最初的deno微觀世界探索deno最初的設計。node

架構

q 本文講解deno的golang版本,當前最新的deno因爲性能問題放棄了golang的實現,但這不影響咱們分析deno的原理。將來在七月deno估計會釋放出基於Rust的底層特權級實現,性能更優。c++

q 因爲deno涉及之處是爲了直接運行TS,所以下文會用TS來代指JS(現階段TS沒有本身的運行時,還是基於編譯爲JS在運行在v8)golang

deno的設計初期來看比較簡單,宏觀上看包括三部分:deno的go運行時、v8引擎以及鏈接go運行時和v8的v8worker2庫。
deno簡易架構
go運行時是deno的特權級,它負責deno對系統資源的申請、使用、釋放;v8引擎此處不只僅執行JS代碼,同時也負責TypeScript的編譯;而v8worker2負責go與v8的全雙工通訊,經過ArrayBuffer傳輸數據,傳輸的協議規範爲protobuf。web

深刻到go運行時裏,目前deno對TS層提供了幾種能力:Console、fetch、fs、module、timer、stack trace,雖然有些功能沒有提供用戶端API,不過golang的接口已完成,擴展很容易。
deno詳細算法

go運行時

deno在特權級代碼執行了3端邏輯:typescript

  1. 初始化go運行時環境
  2. 初始化TS運行時環境
  3. 啓動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和項目,固然也有多是咱們天天忙於業務需求中沒法自拔。願國內開發者且行且珍惜,不能被國外的同行甩開太多。

相關文章
相關標籤/搜索