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


從 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 引擎嵌入到原生項目中」了。

本節內容是面向我這樣前端背景(沒有正經做過 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:

<code>console.log("Hello World");/<code>

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

<code>#include <quickjs>

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;
}/<quickjs>/<code>

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

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

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

<code>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)/<code>

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

為 JS 引擎擴展原生能力

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

<code>> document.getElementById
ƒ getElementById() { [native code] }/<code>

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

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

<code>function fib(n) {
if (n <= 0) return 0;
else if (n === 1) return 1;
else return fib(n - 1) + fib(n - 2);
}
/<code>

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

<code>int fib(int n) {
if (n <= 0) return 0;
else if (n == 1) return 1;
else return fib(n - 1) + fib(n - 2);
}/<code>

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

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

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

<code>#include <quickjs>
#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;
}/<quickjs>/<code>

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

<code>// ...
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);
// ...
}/<code>

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

<code>import { fib } from "fib.so";

fib(42);
/<code>

作為嵌入式 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 支持。但是,引擎編譯時默認會內置 std 和 os 兩個原生模塊,可以這樣使用 setTimeout 來支持異步:

<code>import { setTimeout } from "os";

setTimeout(() => { /* ... */ }, 0);
/<code>

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

<code>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;
}/<code>

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

但為什麼 setTimeout 能實現異步呢?關鍵在於 eval 之後,我們就要啟動 Event Loop 了。而這裡的奧妙其實也在 QuickJS 編譯器生成的代碼裡明確地寫出來了,沒想到吧:

<code>// ...
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);
// ...
}/<code>

因此,eval 後的這個 js_std_loop 就是真正的 Event Loop,而它的源碼則更是簡單得像是偽代碼一樣:

<code>/* 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;
}
}/<code>

這不就是在雙重的死循環裡先執行掉所有的 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 來實現,屆時全文涉及的代碼示例也將一併給出。

最後想學習編程又沒有學習資料的話,可以關注我的頭條號並在後臺私信我:前端,即可免費獲取。


分享到:


相關文章: