透過 WebAssembly 使用非同步網路 API

Ingvar Stepanyan
Ingvar Stepanyan

網路上的 I/O API 為非同步性質,但在大多數系統語言中都是同步的。編譯程式碼到 WebAssembly 時,您需要將一種 API 橋接到另一個 API,而這個橋接器就是 Asyncify。這篇文章將介紹 Asyncify 的使用時機和方法,以及這項功能的運作原理。

系統語言 I/O

我先用 C 這個簡單的範例開始假設您您想要從檔案中讀取使用者名稱,然後用「Hello, (username)!」訊息問候對方:

#include <stdio.h>

int main() {
    FILE *stream = fopen("name.txt", "r");
    char name[20+1];
    size_t len = fread(&name, 1, 20, stream);
    name[len] = '\0';
    fclose(stream);
    printf("Hello, %s!\n", name);
    return 0;
}

雖然此範例中的功能不多,但已經示範了您將在任何大小應用程式中發現的各種內容:它會從外部世界讀取部分輸入內容,在內部進行處理,並將輸出內容寫入外部世界。所有與外部的這類互動都是透過幾種一般稱為輸入輸出函式的函式進行,且縮短為 I/O。

如要讀取 C 中的名稱,您至少需要兩個重要的 I/O 呼叫:fopen 開啟檔案,以及 fread 從檔案讀取資料。擷取資料後,您可以使用另一個 I/O 函式 printf 將結果列印至控制台。

這些函式乍看之下很簡單,讓您不必為了讀取或寫入資料的機器而考慮兩次。不過,視環境而定,內部可能會有很多 需要處理的情況:

  • 如果輸入檔案位於本機磁碟,應用程式需要執行一系列記憶體和磁碟存取權,才能找到檔案、檢查權限、開啟檔案進行讀取,然後逐區塊讀取區塊,直到擷取要求的位元組數為止。這項作業可能會相當緩慢,具體取決於磁碟速度和要求的大小。
  • 或者,輸入檔案可能位於掛接的網路位置,在這種情況下,現在也會涉及網路堆疊,並增加各項作業的複雜性、延遲時間和潛在重試次數。
  • 最後,即使有 printf 也不保證會將內容列印至主控台,並且可能會重新導向到檔案或網路位置,在這種情況下,必須執行上述相同的步驟。

說不定,I/O 的速度可能很慢,而且您無法快速看一眼程式碼,預測特定通話花費的時間。當這項作業執行時,整個應用程式會凍結,且無法回應使用者。

這不限於 C 或 C++。大多數系統語言會以同步 API 的形式呈現所有 I/O。例如,如果您將範例轉譯為 Rust,API 看起來會更簡單,但適用相同的原則。您只需發出呼叫並同步等候傳回結果,就會執行所有昂貴的作業,最終以單一叫用傳回結果:

fn main() {
    let s = std::fs::read_to_string("name.txt");
    println!("Hello, {}!", s);
}

但是,如果嘗試將任何範例編譯為 WebAssembly,並將其轉譯至網路,會發生什麼事?如要提供具體範例,「檔案讀取」作業會轉譯成什麼?需要從某些儲存空間讀取資料。

網路的非同步模型

網頁版服務提供多種與您對應的儲存空間選項,例如記憶體內儲存空間 (JS 物件)、localStorageIndexedDB、伺服器端儲存空間,以及新的 File System Access API

不過,只有其中兩個 API (記憶體內儲存空間和 localStorage) 可以同步使用,而且這兩種 API 是可儲存內容以及持續時間上限的選項。其他所有選項都只能提供非同步 API。

這是在網站上執行程式碼的其中一項核心屬性:任何耗時的作業 (包括任何 I/O) 都必須為非同步。

這是因為網頁過去是單執行緒,而且任何接觸 UI 的使用者程式碼都必須在與 UI 相同的執行緒上執行。它必須與其他重要任務競爭,例如 CPU 作業時間的版面配置、轉譯和事件處理。您不會希望部分 JavaScript 或 WebAssembly 能啟動「檔案讀取」作業,並封鎖整個分頁或整個瀏覽器等其他項目,範圍介於毫秒到數秒之間,直到等待為止。

相反地,程式碼只能安排在完成後執行的回呼,一起執行 I/O 作業。這類回呼會做為瀏覽器事件迴圈的一部分執行。我不會深入說明,但如果您想瞭解事件迴圈的運作方式,請參閱工作、微工作、佇列和排程一文,其中深入說明這個主題。

簡單來說,瀏覽器會透過逐一從佇列中逐一執行所有程式碼,並以無限迴圈的方式執行程式碼。觸發某些事件時,瀏覽器會將對應的處理常式排入佇列,然後在下一個循環疊代中,從佇列取出並執行該事件。這項機制可讓您在僅使用單一執行緒的情況下,模擬並行和執行許多平行作業。

請記住,這項機制的重要是,當自訂 JavaScript (或 WebAssembly) 程式碼執行時,會封鎖事件迴圈,而且無法回應任何外部處理常式、事件、I/O 等。如要取得 I/O 結果,唯一的方法就是註冊回呼、執行程式碼處理作業,並將待處理工作提供給瀏覽器。I/O 作業完成後,處理常式就會成為其中一項工作並執行。

舉例來說,如果您要使用新型 JavaScript 重寫上述範例,並決定讀取遠端網址的名稱,您可以使用 Fetch API 和 async-await 語法:

async function main() {
  let response = await fetch("name.txt");
  let name = await response.text();
  console.log("Hello, %s!", name);
}

雖然看起來是同步的,但實際上每個 await 基本上都是回呼的語法糖:

function main() {
  return fetch("name.txt")
    .then(response => response.text())
    .then(name => console.log("Hello, %s!", name));
}

在此已進行脫糖的範例中比較清楚,會啟動要求,並向第一個回呼訂閱回應。瀏覽器收到初始回應 (僅是 HTTP 標頭) 後,就會以非同步方式叫用這個回呼。回呼會使用 response.text() 開始以文字形式讀取內文,並透過另一個回呼訂閱結果。最後,fetch 擷取所有內容時,就會叫用最後一個回呼,將「Hello, (username)!」輸出到主控台。

多虧這些步驟的非同步性質,原始函式可以在 I/O 排程後立即傳回瀏覽器控制項,並且讓整個 UI 得以回應,並可供其他工作 (包括轉譯、捲動等) 使用,同時當 I/O 在背景執行時。

最後一個例子,即使是像是「sleep」這樣簡單的 API,讓應用程式等待指定的秒數,也屬於 I/O 作業的形式:

#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");

沒問題,您能以簡單直接的方式進行翻譯,這會在時間到期前封鎖目前的執行緒:

console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");

事實上,這正是 Emscripten 預設「休眠」實作模式的做法,但效率很差,會封鎖整個 UI,也無法同時處理任何其他事件。一般而言,請勿在實際工作環境程式碼中執行這項操作。

相反地,在 JavaScript 中較慣用的「sleep」版本是指呼叫 setTimeout(),並透過處理常式訂閱:

console.log("A");
setTimeout(() => {
    console.log("B");
}, 1000);

所有範例和 API 都符合什麼情況?在每種情況下,原始系統語言中的慣用程式碼都會針對 I/O 使用阻斷 API,而另一個網路範例則是使用非同步 API。編譯網頁時,您需要在這兩種執行模型之間轉換,而 WebAssembly 尚無內建功能。

使用 Asyncify 消弭落差

這時 Asyncify 就能派上用場。Asyncify 是 Emscripten 支援的編譯時間功能,可暫停整個程式,並於稍後以非同步方式繼續。

描述 JavaScript -> WebAssembly -> web API -> 非同步工作叫用的呼叫圖,其中 Asyncify 會將非同步工作的結果重新連線至 WebAssembly

搭配 Emscripten 使用 C / C++ 的情況

如果您想針對最後一個範例使用 Asyncify 實作非同步睡眠,可以如下所示:

#include <stdio.h>
#include <emscripten.h>

EM_JS(void, async_sleep, (int seconds), {
    Asyncify.handleSleep(wakeUp => {
        setTimeout(wakeUp, seconds * 1000);
    });
});
…
puts("A");
async_sleep(1);
puts("B");

EM_JS 是一種巨集,可將 JavaScript 程式碼片段定義為 C 函式。請在內部使用 Asyncify.handleSleep() 函式,該函式指示 Emscripten 將程式暫停,並提供應在非同步作業完成後呼叫的 wakeUp() 處理常式。在上述範例中,處理常式會傳遞至 setTimeout(),但可用於接受回呼的任何其他結構定義中。最後,您可以在任何想要使用一般 sleep() 或任何其他同步 API 的地方呼叫 async_sleep()

編譯這類程式碼時,您需要指示 Emscripten 啟用 Asyncify 功能。方法是使用可能非同步的函式清單傳遞 -s ASYNCIFY 以及 -s ASYNCIFY_IMPORTS=[func1, func2]

emcc -O2 \
    -s ASYNCIFY \
    -s ASYNCIFY_IMPORTS=[async_sleep] \
    ...

這可讓 Emscripten 瞭解,對這些函式的呼叫可能需要儲存及還原狀態,因此編譯器會在這類呼叫周圍插入支援程式碼。

現在,在瀏覽器中執行這個程式碼時,您會看到流暢的輸出記錄,而 B 會在 A 後短暫延遲後載入。

A
B

您也可以從 Asyncify 函式傳回值。需要採取的行動是傳回 handleSleep() 的結果,並將結果傳遞至 wakeUp() 回呼。舉例來說,如果您想從遠端資源擷取數字,而非從檔案讀取,您可以使用如下所示的程式碼片段發出要求、將 C 程式碼暫停,然後在擷取回應主體後繼續運作,一切都如同同步呼叫一樣簡單。

EM_JS(int, get_answer, (), {
     return Asyncify.handleSleep(wakeUp => {
        fetch("answer.txt")
            .then(response => response.text())
            .then(text => wakeUp(Number(text)));
    });
});
puts("Getting answer...");
int answer = get_answer();
printf("Answer is %d\n", answer);

事實上,對於以 Promise 為基礎的 API (例如 fetch()),您甚至可以結合 Asyncify 與 JavaScript 的非同步等待功能,而不是使用回呼式 API。為此,請呼叫 Asyncify.handleAsync(),而非 Asyncify.handleSleep()。然後,您不需要排定 wakeUp() 回呼,可以傳遞 async JavaScript 函式並使用 awaitreturn,讓程式碼看起來更自然且同步,同時還能享有非同步 I/O 帶來的好處。

EM_JS(int, get_answer, (), {
     return Asyncify.handleAsync(async () => {
        let response = await fetch("answer.txt");
        let text = await response.text();
        return Number(text);
    });
});

int answer = get_answer();

正在等待複雜值

不過,本例依然限制您僅限於數字。如果想要實作原始範例,我曾嘗試從檔案中取得使用者名稱做為字串,該怎麼做?當然,也可以做到!

Emscripten 提供名為 Embind 的功能,可處理 JavaScript 和 C++ 值之間的轉換。此外,這個程式庫也支援 Asyncify,因此您可以在外部 Promise 上呼叫 await(),其運作方式與非同步等待 JavaScript 程式碼中的 await 相同:

val fetch = val::global("fetch");
val response = fetch(std::string("answer.txt")).await();
val text = response.call<val>("text").await();
auto answer = text.as<std::string>();

使用這個方法時,您甚至不需要將 ASYNCIFY_IMPORTS 做為編譯標記傳遞,因為系統已預設納入該標記。

好的,這對 Emscripten 來說都非常實用。那其他工具鍊和語言呢?

其他語言的使用情形

假設您在 Rust 程式碼中有一個類似的同步呼叫,而且您想對應至網路上的非同步 API。總之,我也可以做到!

首先,您必須將這類函式定義為透過 extern 區塊 (或您所選語言的外函式語言語法) 的一般匯入內容。

extern {
    fn get_answer() -> i32;
}

println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);

並將程式碼編譯為 WebAssembly:

cargo build --target wasm32-unknown-unknown

現在,您需要使用用於儲存/還原堆疊的程式碼來檢測 WebAssembly 檔案。對 C / C++ 來說,Escripten 會執行這項作業,但這裡並未使用,因此這項程序有較多手動設定。

幸好,Asyncify 轉換本身就完全不受工具鍊的影響。無論由哪個編譯器產生何種編譯器,您都可以轉換任意 WebAssembly 檔案。轉換作業會在二進位工具鍊wasm-opt 最佳化工具中單獨提供,並可透過以下方式叫用:

wasm-opt -O2 --asyncify \
      --pass-arg=asyncify-imports@env.get_answer \
      [...]

傳遞 --asyncify 以啟用轉換,然後使用 --pass-arg=… 提供以半形逗號分隔的非同步函式清單,其中程式狀態應暫停,稍後繼續執行。

現在只要提供可實際執行此作業的支援執行階段程式碼即可,請暫停並重新啟用 WebAssembly 程式碼。同樣,在 C / C++ 中,這會由 Emscripten 加入,但現在您需要自訂的 JavaScript glue 程式碼可以處理任意 WebAssembly 檔案。我們建立了一個專門的程式庫

您可以在 GitHub (https://github.com/GoogleChromeLabs/asyncify 或 npm) 的 asyncify-wasm 底下找到該服務。

這會模擬標準的 WebAssembly 執行個體化 API,但位在其自己的命名空間下。唯一的差別在於,在一般 WebAssembly API 中,您只能提供同步函式做為匯入項目,而在 Asyncify 包裝函式下方,也可以提供非同步匯入項目:

const { instance } = await Asyncify.instantiateStreaming(fetch('app.wasm'), {
    env: {
        async get_answer() {
            let response = await fetch("answer.txt");
            let text = await response.text();
            return Number(text);
        }
    }
});
…
await instance.exports.main();

當您嘗試從 WebAssembly 端呼叫這類非同步函式 (例如上述範例中的 get_answer()) 時,程式庫會偵測傳回的 Promise、暫停並儲存 WebAssembly 應用程式的狀態、訂閱承諾完成,等問題解決後,順利還原呼叫堆疊和狀態並繼續執行,沒有任何發生的情況。

由於模組中的任何函式都可能發出非同步呼叫,因此所有匯出項目可能也變成非同步,因此一併包裝也會進行包裝。在上述範例中,您可能已註意到需要 await instance.exports.main() 的結果,才能知道執行作業何時完全完成。

運作方式為何?

Asyncify 偵測到對其中一個 ASYNCIFY_IMPORTS 函式的呼叫時,會啟動非同步作業,儲存應用程式的完整狀態,包括呼叫堆疊和所有臨時本機。等作業完成後,會還原所有記憶體和呼叫堆疊,並保持程式從未停止的狀態,並保持狀態。

這與先前介紹的 JavaScript 中的非同步等待功能類似,但與 JavaScript 第一項功能不同,前者不需要在該語言提供任何特殊語法或執行階段支援,而是在編譯期間轉換純同步函式來運作。

編譯先前顯示的非同步睡眠範例時:

puts("A");
async_sleep(1);
puts("B");

Asyncify 會採用這個程式碼,然後將其轉換,大致如下所示 (虛擬程式碼、實際轉換的工作比這更多):

if (mode == NORMAL_EXECUTION) {
    puts("A");
    async_sleep(1);
    saveLocals();
    mode = UNWINDING;
    return;
}
if (mode == REWINDING) {
    restoreLocals();
    mode = NORMAL_EXECUTION;
}
puts("B");

一開始將 mode 設為 NORMAL_EXECUTION。相對地,系統第一次執行這類轉換的程式碼時,只會評估前面 async_sleep() 的部分。Asyncify 會在排定非同步作業後立即儲存所有本機值,並將每個函式傳回至頂端,藉此拆解堆疊,如此一來便能將控制權傳回瀏覽器事件迴圈。

接著,async_sleep() 解析後,Asyncify 支援程式碼會將 mode 變更為 REWINDING,然後再次呼叫該函式。這次系統會略過「normal 執行」分支版本,因為上次執行工作時就已執行完畢,我想避免列印「A」兩次,而改為直接執行到「倒轉」分支版本。叫用後,系統會還原所有儲存的本機值,將模式改回「一般」,然後像程式碼從一開始就從未停止的情況下繼續執行。

轉換費用

遺憾的是,Asyncify 轉換並非完全免費的,因為 Asyncify 轉換需要插入一些支援的程式碼來儲存和還原所有本機上,或是使用不同模式瀏覽呼叫堆疊。該函式會嘗試只修改指令列及其任何可能呼叫端中標示為非同步的函式,但程式碼大小負擔在壓縮前可能仍會增加約 50%。

圖表顯示各種基準的程式碼大小負擔,從經過微調的條件接近 0% 到最糟情況的 100% 以上

這種做法不理想,但在多數情況下,如果替代方案完全沒有相關功能,或者必須大幅改寫原始程式碼,情況會符合規定。

請務必一律為最終版本啟用最佳化功能,以免這種情況發生。您也可以勾選Asyncify 特定最佳化選項,將轉換限制為僅限指定函式及/或僅直接函式呼叫,以降低負擔。執行階段的效能也很低,但僅限於非同步呼叫本身。然而,相較於實際工作的費用,這通常可以忽略不計。

實際示範

在您查看簡易範例後,我現在要探討較複雜的情境。

如文章開頭所述,網路上的儲存空間選項之一是非同步的 File System Access API。可讓您從網頁應用程式存取真正的主機檔案系統。

另一方面,在主控台和伺服器端有一個名為 WASI 的 WebAssembly I/O 標準。它是設計成系統語言的編譯目標,以傳統同步格式公開各種類型的檔案系統和其他作業。

如果你可以對應彼此呢?然後,您可以使用任何支援 WASI 目標的工具鍊編譯任何應用程式,並在網頁的沙箱中執行,同時允許應用程式在實際使用者檔案中運作!只要使用 Asyncify,

在本示範中,我將一些小修補程式編譯至 Rust coreutils Crate,然後透過 Asyncify 轉換傳遞給 WASI,並在 JavaScript 端實作從 WASI 和 File System Access API 的非同步繫結。與 Xterm.js 終端機元件搭配使用後,就會提供在瀏覽器分頁中執行並對實際使用者檔案運作的殼層,就像實際終端機一樣。

歡迎前往 https://wasi.rreverser.com/ 一探究竟。

Asyncify 用途不限於計時器和檔案系統。也可以進一步在網路上使用更多小眾 API

舉例來說,在 Asyncify 的協助下,也可將 libusb (可能是與 USB 裝置搭配使用的最熱門原生程式庫) 對應至 WebUSB API,藉此以非同步的方式存取這類裝置。對應及編譯完成後,我就能直接在網頁的沙箱中針對所選裝置執行標準 libusb 測試和範例。

網頁上 libusb 偵錯輸出內容的螢幕截圖,顯示已連線的 Canon 相機相關資訊

但也可能是其他網誌文章的內容。

這些範例展示了 Asyncify 能夠如何有效彌補不足之處,並將各種類型的應用程式移植到網路上,讓您獲得跨平台存取權、沙箱機制及更完善的安全防護,而所有功能都會不犧牲功能。