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

在上篇文章中,我們已經為 JS 引擎擴展出了個最簡單的 Event Loop。但像這樣直接基於各操作系統不盡相同的 API 自己實現運行時,無疑是件苦差。有沒有什麼更好的玩法呢?是時候讓 libuv 粉墨登場啦。


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


我們知道,libuv 是 Node.js 開發過程中衍生的異步 IO 庫,能讓 Event Loop 高性能地運行在不同平臺上。可以說,今天的 Node.js 就相當於由 V8 和 libuv 拼接成的運行時。但 libuv 同樣具備高度的通用性,已被用於實現 Lua、Julia 等其它語言的異步非阻塞運行時。接下來,我們將介紹如何用同樣簡單的代碼,做到這兩件事:
將 Event Loop 切換到基於 libuv 實現

  • 支持宏任務與微任務
  • 到本文結尾,我們就能把 QuickJS 引擎與 libuv 相結合,實現出一個代碼更簡單,但也更貼近實際使用的(玩具級)JS 運行時了。

    支持 libuv Event Loop

    在嘗試將 JS 引擎與 libuv 相結合之前,我們至少需要先熟悉 libuv 的基礎使用。同樣地,它也是個第三方庫,遵循上篇文章中提到過的使用方式:

    1. 將 libuv 源碼編譯為庫文件。
    2. 在項目中 include 相應頭文件,使用 libuv。
    3. 編譯項目,鏈接上 libuv 庫文件,生成可執行文件。

    如何編譯 libuv 不必在此贅述,但實際使用它的代碼長什麼樣呢?下面是個簡單的例子,簡單幾行就用 libuv 實現了個 setInterval 式的定時器

    <code>#include <stdio.h>
    #include <uv.h> // 這裡假定 libuv 已經全局安裝好

    static void onTimerTick(uv_timer_t *handle) {
    printf("timer tick\\n");
    }

    int main(int argc, char **argv) {
    uv_loop_t *loop = uv_default_loop();
    uv_timer_t timerHandle;
    uv_timer_init(loop, &timerHandle);
    uv_timer_start(&timerHandle, onTimerTick, 0, 1000);
    uv_run(loop, UV_RUN_DEFAULT);
    return 0;
    }/<uv.h>/<stdio.h>/<code>

    為了讓這份代碼能正確編譯,我們需要修改 CMake 配置,把 libuv 依賴加進來。完整的 CMakeLists.txt 構建配置如下所示,其實也就是照貓畫虎而已

    <code>cmake_minimum_required(VERSION 3.10)
    project(runtime)
    add_executable(runtime
    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")

    # libuv
    add_library(libuv STATIC IMPORTED)
    set_target_properties(libuv
    PROPERTIES IMPORTED_LOCATION
    "/usr/local/lib/libuv.a")


    target_link_libraries(runtime
    libuv
    quickjs)/<code>

    這樣,quickjs.h 和 uv.h 就都可以 include 進來使用了。那麼,該如何進一步地將上面的 libuv 定時器封裝給 JS 引擎使用呢?我們需要先熟悉一下剛才的代碼裡涉及到的 libuv 基本概念:

    • Callback - 事件發生時所觸發的回調,例如這裡的 onTimerTick 函數。別忘了 C 裡也支持將函數作為參數傳遞噢。
    • Handle - 長時間存在,可以為其註冊回調的對象,例如這裡 uv_timer_t 類型的定時器。
    • Loop - 封裝了下層異步 IO 差異,可以為其添加 Handle 的 Event Loop,例如這裡 uv_loop_t 類型的 loop 變量。

    所以簡單說,libuv 的基本使用方式就相當於:把 Callback 綁到 Handle 上,把 Handle 綁到 Loop 上,最後啟動 Loop。當然 libuv 裡還有 Request 等重要概念,但這裡暫時用不到,就不離題了。

    明白這一背景後,上面的示例代碼就顯得很清晰了

    <code>// ...
    int main(int argc, char **argv) {
    // 建立 loop 對象
    uv_loop_t *loop = uv_default_loop();

    // 把 handle 綁到 loop 上
    uv_timer_t timerHandle;
    uv_timer_init(loop, &timerHandle);

    // 把 callback 綁到 handle 上,並啟動 timer
    uv_timer_start(&timerHandle, onTimerTick, 0, 1000);

    // 啟動 event loop
    uv_run(loop, UV_RUN_DEFAULT);
    return 0;
    }/<code>

    這裡最後的 uv_run 就像上篇中的 js_std_loop 那樣,內部就是個可以「長時間把自己掛起」的死循環。在進入這個函數前,其它對 libuv API 的調用都是非常輕量而同步返回的。那我們自然可以這麼設想:只要我們能在上篇的代碼中按同樣的順序依次調用 libuv,最後改為啟動 libuv 的 Event Loop,那就能讓 libuv 來接管運行時的下層實現了

    更具體地說,實際的實現方式是這樣的:

    • 在掛載原生模塊前,初始化好 libuv 的 Loop 對象。
    • 在初始的 JS 引擎 eval 過程中,每調用到一次 setTimeout,就初始化一個定時器的 Handle 並啟動它。
    • 待首次 eval 結束後,啟動 libuv 的 Event Loop,讓 libuv 在相應時機觸發 C 回調,進而執行掉 JS 中的回調。

    這裡需要額外提供的就是定時器的 C 回調了,它負責在相應的時機把 JS 引擎上下文裡到期的回調執行掉。在上篇的實現中,這是在 js_std_loop 中硬編碼的邏輯,並不易於擴展。為此我們實現的新函數如下所示,其核心就是一行調用函數對象的 JS_Call。但在此之外,我們還需要配合 JS_FreeValue 來管理對象的引用計數,否則會出現內存洩漏

    <code>static void timerCallback(uv_timer_t *handle) {
    // libuv 支持在 handle 上掛任意的 data
    MyTimerHandle *th = handle->data;
    // 從 handle 上拿到引擎 context
    JSContext *ctx = th->ctx;
    JSValue ret;

    // 調用回調,這裡的 th->func 在 setTimeout 時已準備好
    ret = JS_Call(ctx, th->func, JS_UNDEFINED, th->argc, (JSValueConst *) th->argv);

    // 銷燬掉回調函數及其返回值
    JS_FreeValue(ctx, ret);
    JS_FreeValue(ctx, th->func);
    th->func = JS_UNDEFINED;

    // 銷燬掉函數參數
    for (int i = 0; i < th->argc; i++) {
    JS_FreeValue(ctx, th->argv[i]);
    th->argv[i] = JS_UNDEFINED;
    }
    th->argc = 0;

    // 銷燬掉 setTimeout 返回的 timer
    JSValue obj = th->obj;
    th->obj = JS_UNDEFINED;
    JS_FreeValue(ctx, obj);
    }/<code>

    這樣就行了!這就是當 setTimeout 在 Event Loop 裡觸發時,libuv 回調內所應該執行的 JS 引擎操作了。

    相應地,在 js_uv_setTimeout 中,需要依次調用 uv_timer_init 和 uv_timer_start,這樣只要 eval 後在 uv_run 啟動 Event Loop,整個流程就能串起來了。這部分代碼只需在之前基礎上做點小改,就不贅述了。

    一個錦上添花的小技巧是往 JS 裡再加點 polyfill,這樣就可以保證 setTimeout 像瀏覽器和 Node.js 之中那樣掛載到全局了:

    <code>import * as uv from "uv"; // 都基於 libuv 了,換個名字唄

    globalThis.setTimeout = uv.setTimeout;
    /<code>

    到這裡,setTimeout 就能基於 libuv 的 Event Loop 跑起來啦

    支持宏任務與微任務

    有經驗的前端同學們都知道,setTimeout 並不是唯一的異步來源。比如大名鼎鼎的 Promise 也可以實現類似的效果:

    <code>// 日誌順序是 A B
    Promise.resolve().then(() => {
    console.log('B')
    })
    console.log('A')
    /<code>

    但是,如果基於上一步中我們實現的運行時來執行這段代碼,

    你會發現只輸出了 A,而 Promise 中的回調消失了。這是怎麼回事呢?

    根據 WHATWG 規範,標準 Event Loop 裡的每個 Tick,都只會執行一個形如 setTimeout 這樣的 Task 任務。但在 Task 的執行過程中,也可能遇到多個「既需要異步,但又不需要被挪到下一個 Tick 執行」的工作,其典型就是 Promise。這些工作被稱為 Microtask 微任務,都應該在這個 Tick 中執行掉。相應地,每個 Tick 所對應的唯一 Task,也被叫做 Macrotask 宏任務,這也就是宏任務和微任務概念的由來了。

    前有 Framebuffer 不是 Buffer,後有 Microtask 不是 Task,刺激不?

    所以,Promise 的異步執行屬於微任務,需要在某個 Tick 內 eval 了一段 JS 後立刻執行。但現在的實現中,我們並沒有在 libuv 的單個 Tick 內調用 JS 引擎執行掉這些微任務,這也就是 Promise 回調消失的原因了。

    明白原因後,我們不難找到問題的解法:只要我們能在每個 Tick 的收尾階段執行一個固定的回調,那就能在此把微任務隊列清空了。在 libuv 中,也確實可以在每次 Tick 的不同階段註冊不同的 Handle 來觸發回調,如下所示:

    <code>   ┌───────────────────────────┐
    ┌─>│ timers │

    │ └─────────────┬─────────────┘
    │ ┌─────────────┴─────────────┐
    │ │ pending callbacks │
    │ └─────────────┬─────────────┘
    │ ┌─────────────┴─────────────┐
    │ │ idle, prepare │
    │ └─────────────┬─────────────┘ ┌───────────────┐
    │ ┌─────────────┴─────────────┐ │ incoming: │
    │ │ poll ││ └─────────────┬─────────────┘ │ data, etc. │
    │ ┌─────────────┴─────────────┐ └───────────────┘
    │ │ check │
    │ └─────────────┬─────────────┘
    │ ┌─────────────┴─────────────┐
    └──┤ close callbacks │
    └───────────────────────────┘/<code>

    上圖中的 poll 階段,就是實際調用 JS 引擎 eval 執行各類 JS 回調的階段。在此階段後的 check 階段,就可以用來把剛才的 eval 所留下的微任務全部執行掉了。如何在每次 Tick 的 check 階段都執行一個固定的回調呢?這倒也很簡單,為 Loop 添加一個 uv_check_t 類型的 Handle 即可:

    <code>// ...
    int main(int argc, char **argv) {
    // 建立 loop 對象
    uv_loop_t *loop = uv_default_loop();

    // 把 handle 綁到 loop 上
    uv_check_t *check = calloc(1, sizeof(*check));
    uv_check_init(loop, check);

    // 把 callback 綁到 handle 上,並啟用它
    uv_check_start(check, checkCallback);

    // 啟動 event loop
    uv_run(loop, UV_RUN_DEFAULT);
    return 0;
    }/<code>

    這樣,就可以在每次 poll 結束後執行 checkCallback 了。這個 C 的 callback 會負責清空 JS 引擎中的微任務,像這樣:

    <code>void checkCallback(uv_check_t *handle) {
    JSContext *ctx = handle->data;
    JSContext *ctx1;
    int err;

    // 執行微任務,直到微任務隊列清空
    for (;;) {
    err = JS_ExecutePendingJob(JS_GetRuntime(ctx), &ctx1);
    if (err <= 0) {
    if (err < 0)
    js_std_dump_error(ctx1);
    break;
    }
    }
    }/<code>

    這樣,Promise 的回調就可以順利執行了!看起來,現在我們不就已經順利實現了支持宏任務和微任務的 Event Loop 了嗎?還差最後一步,考慮下面的這段 JS 代碼:

    <code>setTimeout(() => console.log('B'), 0)

    Promise.resolve().then(() => console.log('A'))
    /<code>

    作為面試題,大家應該都知道 setTimeout 的宏任務應該會在下一個 Tick 執行,而 Promise 的微任務應該在本次 Tick 末尾就執行掉,這樣的執行順序就是 A B。但基於現在的 check 回調實現,你會發現日誌順序顛倒過來了,這顯然是不符合規範的。為什麼會這樣呢?

    這並不是只有我犯的低級錯誤,libuv 核心開發 Saghul 為 QuickJS 搭建的 Txiki 運行時,也遇到過這個問題。不過 Txiki 的這個 Issue,既是我發現的,也是我修復的(嘿嘿),下面就簡單講講問題所在吧。

    確實,微任務隊列應該在 check 階段清空。對文件 IO 等常見情形這符合規範,也是 Node.js 源碼中的實現方式,但對 timer 來說則存在著例外。讓我們重新看下 libuv 中 Tick 的各個階段吧:

    <code>   ┌───────────────────────────┐
    ┌─>│ timers │
    │ └─────────────┬─────────────┘
    │ ┌─────────────┴─────────────┐
    │ │ pending callbacks │
    │ └─────────────┬─────────────┘
    │ ┌─────────────┴─────────────┐
    │ │ idle, prepare │
    │ └─────────────┬─────────────┘ ┌───────────────┐
    │ ┌─────────────┴─────────────┐ │ incoming: │
    │ │ poll ││ └─────────────┬─────────────┘ │ data, etc. │
    │ ┌─────────────┴─────────────┐ └───────────────┘
    │ │ check │
    │ └─────────────┬─────────────┘
    │ ┌─────────────┴─────────────┐
    └──┤ close callbacks │
    └───────────────────────────┘/<code>

    注意到了嗎?timer 的回調始終是最先執行的,比 check 回調還要早。這也就意味著,每次 eval 結束後的 Tick 中,都會先執行 setTimeout 對應的 timer 回調,然後才是 Promise 的回調。這就導致了執行順序上的問題了。

    為了解決這個 timer 的問題,我們可以做個特殊處理:在 timer 回調中清空微任務隊列即可。這也就相當於,在 timer 的 C 回調中再把 JS_ExecutePendingJob 的 for 循環跑一遍。相應的代碼實現,可以參考我為 Txiki 提的這個 PR,其中還包括了這類異步場景的測試用例呢。

    到此為止,我們就基於 libuv 實現了一個符合標準的 JS 運行時 Event Loop 啦——雖然它只支持 timer,但也不難基於 libuv 繼續為其擴展其它能力。如果你對如何接入更多的 libuv 能力到 JS 引擎感興趣,Txiki 也是個很好的起點。

    思考題:這個微任務隊列,能否支持調整單次任務執行的數量限制呢?能否在運行時動態調整呢?如果可以,該如何構造出相應的 JS 測試用例呢?

    最後,這裡列出一些在學習 libuv 和 Event Loop 時主要的參考資料:

    • libuv 設計概覽
    • Task Queue 規範
    • Microtask / Macrotask 區別

    本篇的代碼示例已經整理到了我的 Minimal JS Runtime 項目裡,它的編譯使用完全無需修改 QuickJS 和 libuv 的上游代碼,歡迎大家嘗試噢。上篇中的 QuickJS 原生 Event Loop 集成示例也在裡面,參見 README 即可。

    後記

    可能也只有 2020 年這個特殊的春節,有條件讓人在家裡認真鑽研技術並連載專欄了吧。全文中我原以為最難的地方,還是大年三十晚上在莆田的一個小村子裡完成的,也算是一種特別的體驗吧。

    畢業幾年來,我的工作一直是寫 JS 的。這次從 JS 轉來寫點 C,其實也沒有什麼特別難的,就是有些不方便,大概相當於把智能手機換成了諾基亞吧…畢竟都是不同時代背景下設計給人用的工具而已,不用太過於糾結它們啦。畢竟真正的大牛可以把 C 寫得出神入化,對我來說,前面的路還很長。

    受水平所限,本文的內容顯然還遠不算深入(例如該如何集成調試器,如何支持 Worker,如何與原生渲染線程交互…)。但如果大家對 JS 運行時的實現感興趣,相信本文應該足夠成為一篇合格的入門指南。並且,我相信這條路線還能為廣大前端同學們找到一種新的可能性:只要少量的 C / C++ 配合現代的 JavaScript,就能使傳統的 Web 技術棧走出瀏覽器,將 JavaScript 像 Lua 那樣嵌入使用了。

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



    分享到:


    相關文章: