網頁上的 I/O API 是非同步,但大多數系統語言都是同步。將程式碼編譯為 WebAssembly 時,您需要將一種 API 橋接至另一種 API,而這個橋接器就是 Asyncify。在這篇文章中,您將瞭解何時及如何使用 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 物件)、localStorage、IndexedDB、伺服器端儲存空間,以及新的 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, (username)!」列印到控制台。
由於這些步驟是非同步,因此只要排定 I/O 作業,原始函式就能將控制權傳回瀏覽器,讓整個 UI 保持回應狀態,並可供其他工作使用 (包括算繪、捲動等),而 I/O 作業則在背景執行。
最後一個例子是,即使是簡單的 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 支援的編譯階段功能,可暫停整個程式,並在稍後非同步恢復執行。
搭配 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 的 async-await 功能結合,而不使用以回呼為基礎的 API。為此,請呼叫 Asyncify.handleAsync(),不要呼叫 Asyncify.handleSleep()。然後,您不必排定 wakeUp() 回呼,可以傳遞 async JavaScript 函式,並在其中使用 await 和 return,讓程式碼看起來更自然且同步,同時不會失去非同步 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++,Emscripten 會為我們執行這項操作,但這裡並未使用 Emscripten,因此程序會稍微手動一些。
幸好,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 應用程式的狀態、訂閱 Promise 完成事件,然後在稍後解析 Promise 時,順暢地還原呼叫堆疊和狀態,並繼續執行,彷彿一切如常。
由於模組中的任何函式都可能進行非同步呼叫,因此所有匯出項目也可能變成非同步,因此也會遭到包裝。您可能已注意到,在上述範例中,您需要 await instance.exports.main() 的結果,才能知道執行作業是否真正完成。
這項功能背後的運作原理為何?
Asyncify 偵測到對 ASYNCIFY_IMPORTS 函式之一的呼叫時,會啟動非同步作業、儲存應用程式的整個狀態 (包括呼叫堆疊和任何暫時性區域變數),並在該作業完成後還原所有記憶體和呼叫堆疊,然後從相同位置以相同狀態繼續執行,彷彿程式從未停止。
這與我稍早展示的 JavaScript 中的 async-await 功能非常相似,但與 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%。

這並非理想做法,但如果替代方案是完全沒有該功能,或是必須大幅重寫原始程式碼,那麼在許多情況下,這種做法是可以接受的。
請務必為最終版本啟用最佳化功能,以免用量進一步增加。您也可以查看 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,以便在網路上非同步存取這類裝置。完成對應和編譯後,我就可以在網頁的沙箱中,針對所選裝置執行標準 libusb 測試和範例。

不過,這大概是另一篇網誌文章的故事了。
這些範例顯示 Asyncify 的強大功能,可彌合差距並將各種應用程式移植到網路上,讓您跨平台存取、使用沙箱和提升安全性,同時保留所有功能。