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

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

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

  • 將 Event Loop 切換到基於 libuv 實現
  • 支持宏任務與微任務

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

支持 libuv Event Loop

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

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

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

#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;
}
複製代碼

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

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)
複製代碼

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

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

因此簡單說,libuv 的基本使用方式就至關於:把 Callback 綁到 Handle 上,把 Handle 綁到 Loop 上,最後啓動 Loop。固然 libuv 裏還有 Request 等重要概念,但這裏暫時用不到,就不離題了。瀏覽器

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

// ...
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;
}
複製代碼

這裏最後的 uv_run 就像上篇中的 js_std_loop 那樣,內部就是個能夠「長時間把本身掛起」的死循環。在進入這個函數前,其它對 libuv API 的調用都是很是輕量而同步返回的。那咱們天然能夠這麼設想:只要咱們能在上篇的代碼中按一樣的順序依次調用 libuv,最後改成啓動 libuv 的 Event Loop,那就能讓 libuv 來接管運行時的下層實現了app

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

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

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

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);
}
複製代碼

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

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

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

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

globalThis.setTimeout = uv.setTimeout;
複製代碼

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

支持宏任務與微任務

有經驗的前端同窗們都知道,setTimeout 並非惟一的異步來源。好比大名鼎鼎的 Promise 也能夠實現相似的效果:

// 日誌順序是 A B
Promise.resolve().then(() => {
  console.log('B')
})
console.log('A')
複製代碼

可是,若是基於上一步中咱們實現的運行時來執行這段代碼,你會發現只輸出了 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 來觸發回調,以下所示:

┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
複製代碼

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

// ...
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;
}
複製代碼

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

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;
        }
    }
}
複製代碼

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

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

Promise.resolve().then(() => console.log('A'))
複製代碼

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

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

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

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


複製代碼

注意到了嗎?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 時主要的參考資料:

本篇的代碼示例已經整理到了個人 Minimal JS Runtime 項目裏,它的編譯使用徹底無需修改 QuickJS 和 libuv 的上游代碼,歡迎你們嘗試噢。上篇中的 QuickJS 原生 Event Loop 集成示例也在裏面,參見 README 便可。

後記

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

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

受水平所限,本文的內容顯然還遠不算深刻(例如該如何集成調試器,如何支持 Worker,如何與原生渲染線程交互…)。但若是你們對 JS 運行時的實現感興趣,相信本文應該足夠成爲一篇合格的入門指南。而且,我相信這條路線還能爲廣大前端同窗們找到一種新的可能性:只要少許的 C / C++ 配合現代的 JavaScript,就能使傳統的 Web 技術棧走出瀏覽器,將 JavaScript 像 Lua 那樣嵌入使用了。在這條路線上還能作到哪些有趣的事情呢?敬請關注噢~

相關文章
相關標籤/搜索