透過 WebAssembly 使用非同步網路 API

Ingvar Stepanyan
Ingvar Stepanyan

網路上的 I/O API 是非同步的,但是在大多數系統語言中,都是同步的。將程式碼編譯為 WebAssembly 時,您需要將一種 API 連結至另一種 API,而這項連結就是 Asyncify。本文將說明何時及如何使用 Asyncify,以及其背後的運作方式。

系統語言中的輸入/輸出

我會先從 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

不過,只有記憶體內儲存空間和 localStorage 這兩個 API 可同步使用,且這兩個 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, (使用者名稱)!」

由於這些步驟具有非同步性,因此原始函式可在 I/O 排程後立即將控制權交還給瀏覽器,並在 I/O 在背景執行時,讓整個 UI 保持回應狀態,並可用於其他工作,包括轉譯、捲動等。

最後一個範例是,即使是讓應用程式等待指定秒數的簡單 API (例如「sleep」),也是一種 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 在實作「sleep」的預設方式就是如此,但這非常耗時,且會阻斷整個 UI,同時也不允許處理任何其他事件。一般來說,請勿在實際工作環境的程式碼中執行這項操作。

相反地,JavaScript 中更常見的「sleep」版本會涉及呼叫 setTimeout(),並使用處理常式訂閱:

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

所有這些範例和 API 有什麼共同之處?在每個情況下,原始系統語言中的慣用程式碼都會使用 I/O 的封鎖 API,而網頁的對應範例則會使用非同步 API。將程式碼編譯為網頁時,您需要以某種方式在兩個執行模式之間轉換,而 WebAssembly 目前尚未內建這項功能。

使用 Asyncify 縮小差距

這時就需要使用 Asyncify。Asyncify 是 Emscripten 支援的編譯時間功能,可讓您暫停整個程式,並在稍後非同步地繼續執行。

描述 JavaScript -> WebAssembly -> 網頁 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);

事實上,對於 fetch() 這類以承諾為基礎的 API,您甚至可以將 Asyncify 與 JavaScript 的 async-await 功能結合,而非使用以回呼為基礎的 API。請呼叫 Asyncify.handleAsync(),不要呼叫 Asyncify.handleSleep()。接著,您可以傳遞 async JavaScript 函式,並在其中使用 awaitreturn,這樣就不必安排 wakeUp() 回呼,讓程式碼看起來更自然且同步,同時不會失去非同步 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 程式碼中 async-await 的 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 檔案。轉換作業會做為 Binaryen 工具鏈wasm-opt 最佳化工具提供,可透過以下方式叫用:

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

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

剩下的步驟就是提供支援的執行階段程式碼,讓系統實際執行這項作業,也就是暫停及繼續執行 WebAssembly 程式碼。同樣地,在 C/C++ 的情況下,Emscripten 會納入這個項目,但現在您需要自訂 JavaScript 黏合程式碼,才能處理任意的 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,並再次呼叫該函式。這次,系統會略過「正常執行」分支,因為上次已執行該工作,且我希望避免兩次列印「A」,因此會直接進入「倒轉」分支。觸及完成後,系統會還原所有儲存的本機使用者,將模式變更回「正常」,並在程式碼從一開始就一直停止的情況下繼續執行。

轉型費用

可惜的是,Asyncify 轉換並非完全自由,原因是必須插入大量支援程式碼來儲存及還原所有本機資料,以及在不同模式下瀏覽呼叫堆疊等。它只會嘗試修改在指令列上標示為非同步的函式,以及任何潛在的呼叫端,但在壓縮前,程式碼大小的額外負擔仍可能增加約 50%。

圖表:顯示各種基準的程式碼大小開銷,從精細調整條件下的近 0% 到最糟情況下的 100% 以上

這並不理想,但在許多情況下,如果替代版本完全沒有功能,或必須大幅改寫原始程式碼,則在大多數的情況下可以接受。

請務必一律為最終版本啟用最佳化功能,避免效能提升。您也可以檢查 Asyncify 專屬最佳化選項,將轉換限制在指定函式和/或直接函式呼叫,藉此降低額外負擔。執行階段效能也會產生輕微成本,但只限於非同步呼叫本身。不過,與實際工作成本相比,這項費用通常可以忽略不計。

實際示範

您已查看簡單的範例,接下來我將介紹更複雜的情況。

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

另一方面,控制台和伺服器端的 WebAssembly I/O 有一個實際標準,稱為 WASI。它是系統語言的編譯目標,並以傳統同步形式公開各種檔案系統和其他作業。

如果可以,彼此對應呢?接著,您可以使用任何支援 WASI 目標的工具鍊,以任何來源語言編譯任何應用程式,並在網路上的沙箱中執行,同時讓應用程式可在實際使用者檔案上運作!Asyncify 就能幫上忙。

在這項示範中,我已編譯 Rust coreutils crate,並為 WASI 提供一些次要修補程式,透過 Asyncify 轉換傳遞,並在 JavaScript 端實作從 WASI 到 File System Access API 的非同步繫結。搭配使用 Xterm.js 終端機元件後,即可在瀏覽器分頁中執行逼真的殼層,並在實際的使用者檔案上運作,就像實際的終端機一樣。

請前往 https://wasi.rreverser.com/ 查看直播。

Asyncify 用途不限於計時器和檔案系統。您可以進一步使用網路上更多專屬 API。

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

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

不過,這可能是一個適合用於其他網誌文章的故事。

這些範例說明 Asyncify 的功能是否強大,可以彌補鴻溝,將各類應用程式移植到網路上,如此一來,您就能獲得跨平台存取權、沙箱機制,並提升安全性,而且不影響任何功能。