網路上的 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 物件)、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, (使用者名稱)!」
由於這些步驟具有非同步性,因此原始函式可在 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 支援的編譯時間功能,可讓您暫停整個程式,並在稍後非同步地繼續執行。
在 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 函式,並在其中使用 await
和 return
,這樣就不必安排 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%。
這並不理想,但在許多情況下,如果替代版本完全沒有功能,或必須大幅改寫原始程式碼,則在大多數的情況下可以接受。
請務必一律為最終版本啟用最佳化功能,避免效能提升。您也可以檢查 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 測試和範例。
不過,這可能是一個適合用於其他網誌文章的故事。
這些範例說明 Asyncify 的功能是否強大,可以彌補鴻溝,將各類應用程式移植到網路上,如此一來,您就能獲得跨平台存取權、沙箱機制,並提升安全性,而且不影響任何功能。