從 JS 引擎到 JS 運行時(上)

V8 和 Node.js 的關係,是許多前端同窗們所津津樂道的——瀏覽器裏的語言,又兼容了瀏覽器外的環境,兩份快樂重疊在一塊兒。而這兩份快樂,又帶來了更多的快樂……但你有沒有想過,這兩份快樂究竟是如何重疊在一塊兒的呢?下面咱們將以嵌入式 JS 引擎 QuickJS 爲例,介紹一個 JS 引擎是如何被逐步定製爲一個新的 JS 運行時的。前端

本文將分上下兩篇,逐一覆蓋(或者說,用盡量簡單的代碼實現)這些內容:程序員

  • 集成嵌入式 JS 引擎
  • 爲 JS 引擎擴展原生能力
  • 移植默認 Event Loop
  • 支持 libuv Event Loop
  • 支持宏任務與微任務

上篇主要涉及前三節,主要介紹 QuickJS 這一嵌入式 JS 引擎自身的基本使用,並移植其自帶的 Event Loop 示例。而下篇所對應的後兩節中,咱們將引入 libuv,講解如何基於 libuv 實現擴展性更好的 Event Loop,並支持宏任務與微任務。數組

閒話少說,進入白學現場吧 :)瀏覽器

集成嵌入式 JS 引擎

在個人理解裏,JS 引擎的「嵌入式」能夠從兩種層面來理解,一種意味着它面向低端的嵌入式設備,另外一種則說明它很易於嵌入到原生項目中。而 JS 運行時 (Runtime) 其實也是一種原生項目,它將 JS 引擎做爲專用的解釋器,爲其提供操做系統的網絡、進程、文件系統等平臺能力。所以,要想本身實現一個 JS 運行時,首先應該考慮的天然是「如何將 JS 引擎嵌入到原生項目中」了。bash

本節內容是面向我這樣前端背景(沒有正經作過 C / C++ 項目)的同窗的,熟悉的小夥伴能夠跳過。網絡

怎樣纔算將 JS 引擎嵌入了呢?咱們知道,最簡單的 C 程序就是個 main 函數。若是咱們能在 main 函數裏調用引擎執行一段 JS 代碼,那不就成功「嵌入」了嗎——就好像只要在地球兩頭各放一片面包,就能把地球作成三明治同樣。多線程

因此,又該怎樣在本身寫的 C 代碼中調用引擎呢?從 C 開發者的視角看,JS 引擎也能夠被看成一個第三方庫來使用,它的集成方式和普通的第三方庫並無什麼不一樣,簡單說包括這幾步:異步

  1. 將引擎源碼編譯爲庫文件,這既能夠是 .a 格式的靜態庫,也能夠是 .so.dll 格式的動態庫。
  2. 在本身的 C 源碼中 include 引擎的頭文件,調用它提供的 API。
  3. 編譯本身的 C 源碼,並連接上引擎的庫文件,生成最終的可執行文件。

對 QuickJS 來講,只要一行 make && sudo make install 就能完成編譯和安裝(再囉嗦一句,原生軟件包的所謂安裝,其實就是把頭文件與編譯出來的庫文件、可執行文件,分別複製到符合 Unix 標準的目錄下而已),而後就能夠在咱們的 C 源碼裏使用它了。函數

完成 QuickJS 的編譯安裝後,咱們甚至不用親自動手寫 C,能夠偷懶讓 QuickJS 幫你生成,由於它支持把 JS 編譯到 C 噢。像這樣的一行 JS:工具

console.log("Hello World");
複製代碼

就能夠用 qjsc -e 命令編譯成這樣的 C 源碼:

#include <quickjs/quickjs-libc.h>

const uint32_t qjsc_hello_size = 87;

const uint8_t qjsc_hello[87] = {
 0x02, 0x04, 0x0e, 0x63, 0x6f, 0x6e, 0x73, 0x6f,
 0x6c, 0x65, 0x06, 0x6c, 0x6f, 0x67, 0x16, 0x48,
 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72,
 0x6c, 0x64, 0x22, 0x65, 0x78, 0x61, 0x6d, 0x70,
 0x6c, 0x65, 0x73, 0x2f, 0x68, 0x65, 0x6c, 0x6c,
 0x6f, 0x2e, 0x6a, 0x73, 0x0e, 0x00, 0x06, 0x00,
 0x9e, 0x01, 0x00, 0x01, 0x00, 0x03, 0x00, 0x00,
 0x14, 0x01, 0xa0, 0x01, 0x00, 0x00, 0x00, 0x39,
 0xf1, 0x00, 0x00, 0x00, 0x43, 0xf2, 0x00, 0x00,
 0x00, 0x04, 0xf3, 0x00, 0x00, 0x00, 0x24, 0x01,
 0x00, 0xd1, 0x28, 0xe8, 0x03, 0x01, 0x00,
};

int main(int argc, char **argv) {
  JSRuntime *rt;
  JSContext *ctx;
  rt = JS_NewRuntime();
  ctx = JS_NewContextRaw(rt);
  JS_AddIntrinsicBaseObjects(ctx);
  js_std_add_helpers(ctx, argc, argv);
  js_std_eval_binary(ctx, qjsc_hello, qjsc_hello_size, 0);
  js_std_loop(ctx);
  JS_FreeContext(ctx);
  JS_FreeRuntime(rt);
  return 0;
}
複製代碼

這不就是咱們要的 main 函數示例嗎?這個 Hello World 已經變成了數組裏的字節碼,嵌入到最簡單的 C 項目中了。

注意這其實只是把 JS 編譯成字節碼,再附上個 main 膠水代碼入口而已,不是真的把 JS 編譯成 C 啦。

固然,這份 C 源碼還要再用 C 編譯器編譯一次才行。就像使用 Babel 和 Webpack 時的配置那樣,原生工程也須要構建配置。對於構建工具,這裏選擇了現代工程中幾乎標配的 CMake。和這份 C 源碼相配套的 CMakeLists.txt 構建配置,則是這樣的:

cmake_minimum_required(VERSION 3.10)
# 約定 runtime 爲最終生成的可執行文件
project(runtime)
add_executable(runtime
        # 若拆分了多個 C 文件,逐行在此添加便可
        src/main.c)

# 導入 QuickJS 的頭文件和庫文件
include_directories(/usr/local/include)
add_library(quickjs STATIC IMPORTED)
set_target_properties(quickjs
        PROPERTIES IMPORTED_LOCATION
        "/usr/local/lib/quickjs/libquickjs.a")

# 將 QuickJS 連接到 runtime
target_link_libraries(runtime
        quickjs)
複製代碼

CMake 的使用很簡單,在此再也不贅述。總之,上面的配置能編譯出 runtime 二進制文件,直接運行它能輸出 Hello World,知道這些就夠啦。

爲 JS 引擎擴展原生能力

上一步走通後,咱們其實已經將 JS 引擎套在了一個 C 程序的殼裏了。然而,這只是個「純淨版」的引擎,也就意味着它並不支持語言標準以外,任何由平臺提供的能力。像瀏覽器裏的 document.getElementById 和 Node.js 裏的 fs.readFile,就都屬於這樣的能力。所以,在實現更復雜的 Event Loop 以前,咱們至少應該能在 JS 引擎裏調用到本身寫的 C 原生函數,就像瀏覽器控制檯裏司空見慣的這樣:

> document.getElementById
ƒ getElementById() { [native code] }
複製代碼

因此,該怎樣將 C 代碼封裝爲這樣的函數呢?和其它 JS 引擎同樣地,QuickJS 提供了標準化的 API,方便你用 C 來實現 JS 中的函數和類。下面咱們以計算斐波那契數的遞歸 fib 函數爲例,演示如何將 JS 的計算密集型函數改由 C 實現,從而大幅提高性能。

JS 版的原始 fib 函數是這樣的:

function fib(n) {
  if (n <= 0) return 0;
  else if (n === 1) return 1;
  else return fib(n - 1) + fib(n - 2);
}
複製代碼

而 C 版本的 fib 函數則是這樣的,怎麼看起來這麼像呢?

int fib(int n) {
  if (n <= 0) return 0;
  else if (n == 1) return 1;
  else return fib(n - 1) + fib(n - 2);
}
複製代碼

要想在 QuickJS 引擎中使用上面這個 C 函數,大體要作這麼幾件事:

  1. 把 C 函數包一層,處理它與 JS 引擎之間的類型轉換。
  2. 將包好的函數掛載到 JS 模塊下。
  3. 將整個原生模塊對外提供出來。

這一共只要約 30 行膠水代碼就夠了,相應的 fib.c 源碼以下所示:

#include <quickjs/quickjs.h>
#define countof(x) (sizeof(x) / sizeof((x)[0]))

// 原始的 C 函數
static int fib(int n) {
    if (n <= 0) return 0;
    else if (n == 1) return 1;
    else return fib(n - 1) + fib(n - 2);
}

// 包一層,處理類型轉換
static JSValue js_fib(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
    int n, res;
    if (JS_ToInt32(ctx, &n, argv[0])) return JS_EXCEPTION;
    res = fib(n);
    return JS_NewInt32(ctx, res);
}

// 將包好的函數定義爲 JS 模塊下的 fib 方法
static const JSCFunctionListEntry js_fib_funcs[] = {
    JS_CFUNC_DEF("fib", 1, js_fib ),
};

// 模塊初始化時的回調
static int js_fib_init(JSContext *ctx, JSModuleDef *m) {
    return JS_SetModuleExportList(ctx, m, js_fib_funcs, countof(js_fib_funcs));
}

// 最終對外的 JS 模塊定義
JSModuleDef *js_init_module_fib(JSContext *ctx, const char *module_name) {
    JSModuleDef *m;
    m = JS_NewCModule(ctx, module_name, js_fib_init);
    if (!m) return NULL;
    JS_AddModuleExportList(ctx, m, js_fib_funcs, countof(js_fib_funcs));
    return m;
}
複製代碼

上面這個 fib.c 文件只要加入 CMakeLists.txt 中的 add_executable 項中,就能夠被編譯進來使用了。這樣在本來的 main.c 入口裏,只要在 eval JS 代碼前多加兩行初始化代碼,就能準備好帶有原生模塊的 JS 引擎環境了:

// ...
int main(int argc, char **argv) {
  // ...
  // 在 eval 前註冊上名爲 fib.so 的原生模塊
  extern JSModuleDef *js_init_module_fib(JSContext *ctx, const char *name);
  js_init_module_fib(ctx, "fib.so");

  // eval JS 字節碼
  js_std_eval_binary(ctx, qjsc_hello, qjsc_hello_size, 0);
  // ...
}
複製代碼

這樣,咱們就能用這種方式在 JS 中使用 C 模塊了:

import { fib } from "fib.so";

fib(42);
複製代碼

做爲嵌入式 JS 引擎,QuickJS 的默認性能天然比不過帶 JIT 的 V8。實測 QuickJS 裏 fib(42) 須要約 30 秒,而 V8 只要約 3.5 秒。但一旦引入 C 原生模塊,QuickJS 就能一舉超越 V8,在不到 2 秒內完成計算,輕鬆提速 15 倍

能夠發現,現代 JS 引擎對計算密集任務的 JIT 已經很強,所以若是將瀏覽器裏的 JS 替換爲 WASM,加速效果未必足夠理想。詳見個人這篇文章:一個白學家眼裏的 WebAssembly

移植默認 Event Loop

到此爲止,咱們應該已經明白該如何嵌入 JS 引擎,併爲其擴展 C 模塊了。可是,上面的 fib 函數只是個同步函數,並非異步的。各種支持回調的異步能力,是如何被運行時支持的呢?這就須要傳說中的 Event Loop 了。

目前,前端社區中已有太多關於 Event Loop 的概念性介紹,惋惜仍然鮮有人真正用簡潔的代碼給出可用的實現。好在 QuickJS 隨引擎附帶了個很好的例子,告訴你們如何化繁爲簡地從頭實現本身的 Event Loop,這也就是本節所但願覆蓋的內容了。

Event Loop 最簡單的應用,可能就是 setTimeout 了。和語言規範一致地,QuickJS 默認並無提供 setTimeout 這樣須要運行時能力的異步 API 支持。可是,引擎編譯時默認會內置 stdos 兩個原生模塊,能夠這樣使用 setTimeout 來支持異步:

import { setTimeout } from "os";

setTimeout(() => { /* ... */ }, 0);
複製代碼

稍微檢查下源碼就能發現,這個 os 模塊並不在 quickjs.c 引擎本體裏,而是和前面的 fib.c 一模一樣地,經過標準化的 QuickJS API 掛載上去的原生模塊。這個原生的 setTimeout 函數是怎麼實現的呢?它的源碼其實不多,像這樣:

static JSValue js_os_setTimeout(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
    int64_t delay;
    JSValueConst func;
    JSOSTimer *th;
    JSValue obj;

    func = argv[0];
    if (!JS_IsFunction(ctx, func))
        return JS_ThrowTypeError(ctx, "not a function");
    if (JS_ToInt64(ctx, &delay, argv[1]))
        return JS_EXCEPTION;
    obj = JS_NewObjectClass(ctx, js_os_timer_class_id);
    if (JS_IsException(obj))
        return obj;
    th = js_mallocz(ctx, sizeof(*th));
    if (!th) {
        JS_FreeValue(ctx, obj);
        return JS_EXCEPTION;
    }
    th->has_object = TRUE;
    th->timeout = get_time_ms() + delay;
    th->func = JS_DupValue(ctx, func);
    list_add_tail(&th->link, &os_timers);
    JS_SetOpaque(obj, th);
    return obj;
}
複製代碼

能夠看出,這個 setTimeout 的實現中,並無任何多線程或 poll 的操做,只是把一個存儲 timer 信息的結構體經過 JS_SetOpaque 的方式,掛到了最後返回的 JS 對象上而已,是個很是簡單的同步操做。所以,就和調用原生 fib 函數同樣地,在 eval 執行 JS 代碼時,遇到 setTimeout 後也是同步地執行一點 C 代碼後就馬上返回,沒有什麼特別之處

但爲何 setTimeout 能實現異步呢?關鍵在於 eval 以後,咱們就要啓動 Event Loop 了。而這裏的奧妙其實也在 QuickJS 編譯器生成的代碼裏明確地寫出來了,沒想到吧:

// ...
int main(int argc, char **argv) {
  // ...
  // eval JS 字節碼
  js_std_eval_binary(ctx, qjsc_hello, qjsc_hello_size, 0);
  // 啓動 Event Loop
  js_std_loop(ctx);
  // ...
}
複製代碼

所以,eval 後的這個 js_std_loop 就是真正的 Event Loop,而它的源碼則更是簡單得像是僞代碼同樣:

/* main loop which calls the user JS callbacks */
void js_std_loop(JSContext *ctx) {
    JSContext *ctx1;
    int err;

    for(;;) {
        /* execute the pending jobs */
        for(;;) {
            err = JS_ExecutePendingJob(JS_GetRuntime(ctx), &ctx1);
            if (err <= 0) {
                if (err < 0) {
                    js_std_dump_error(ctx1);
                }
                break;
            }
        }

        if (!os_poll_func || os_poll_func(ctx))
            break;
    }
}
複製代碼

這不就是在雙重的死循環裏先執行掉全部的 Job,而後調 os_poll_func 嗎?但是,for 循環不會吃滿 CPU 嗎?這是個前端同窗們容易誤解的地方:在原生開發中,進程裏即使寫着個死循環,也未必始終在前臺運行,能夠經過系統調用將本身掛起

例如,一個在死循環裏經過 sleep 系統調用不停休眠一秒的進程,就只會每秒被系統執行一個 tick,其它時間裏都不佔資源。而這裏的 os_poll_func 封裝的,就是原理相似的 poll 系統調用(準確地說,用的實際上是 select),從而能夠藉助操做系統的能力,使得只在【定時器觸發、文件描述符讀寫】等事件發生時,讓進程回到前臺執行一個 tick,把此時應該運行的 JS 回調跑一遍,而其他時間都在後臺掛起。在這條路上繼續走下去,就能以經典的異步非阻塞方式來實現整個運行時啦。

poll 和 select 想實現的東西是一致的,只是原理不一樣,前者性能更好然後者更簡單而已。

鑑於 os_poll_func 的代碼較長,這裏只歸納下它與 timer 相關的工做:

  • 若是上下文中存在 timer,將到期 timer 對應的回調都執行掉。
  • 找到全部 timer 中最小的時延,用 select 系統調用將本身掛起這段時間。

這樣,setTimeout 的流程就說得通了:先在 eval 階段簡單設置一個 timer 結構,而後在 Event Loop 裏用這個 timer 的參數去調用操做系統的 poll,從而在被喚醒的下一個 tick 裏把到期 timer 對應的 JS 回調執行掉就行

因此,看明白這個 Event Loop 的機制後,就不難發現若是隻關心 setTimeout 這個運行時 API,那麼照抄,啊不移植的方法其實並不複雜:

  • os 原生模塊裏的 setTimeout 相關部分,仿照 fib 的形式抄進來。
  • js_std_loop 及其依賴抄進來。

這其實就是件循序漸進就能完成的事,實際代碼示例會和下篇一塊兒給出。

到如今爲止這些對 QuickJS 的分析,是否能讓你們發現,許多常常聽到的高大上概念,實現起來其實也沒有那麼複雜呢?別忘了,QuickJS 出自傳奇程序員 Fabrice Bellard。讀他代碼的感覺,就像讀高中習題的參考答案同樣,既不漏過每一個關鍵的知識點又絕不拖泥帶水,很是有啓發性。他本人也像金庸小說裏創造「天下武學正宗」的中神通王重陽那樣,十分使人歎服。帶着問題閱讀更高段位的代碼,也幾乎總能帶來豐富的收穫。

好了,這就是上篇的所有內容了。在接下來的下篇中,咱們將在熟悉了 QuickJS 和 Event Loop 的基礎上,將 Event Loop 改由更可擴展的 libuv 來實現,屆時全文涉及的代碼示例也將一併給出。若是感興趣,敬請關注噢~

相關文章
相關標籤/搜索