網路上的 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舉例來說,如果您將範例轉譯為 Rust,API 看起來會比較簡單, 相同的原則您只需發出呼叫,然後同步等待呼叫傳回結果。 同時執行所有昂貴作業,最後將結果傳回在單一 叫用:
fn main() {
let s = std::fs::read_to_string("name.txt");
println!("Hello, {}!", s);
}
但當您嘗試將這些範例編譯到 WebAssembly 並轉譯為 網路?提供具體範例,證明應該「讀取檔案」每個作業都會轉譯到嗎?會 需要讀取某些儲存空間中的資料
網頁的非同步模型
網路提供各種可以對應的儲存空間選項,例如記憶體內儲存空間 (JS)
物件)、localStorage
、
IndexedDB、伺服器端儲存空間
和新的 File System Access API。
不過,這些 API 只能使用兩種 API,也就是記憶體內儲存空間和 localStorage
而且兩者都是限制最多的儲存資料與時間長度選項。所有語言
其他選項只提供非同步 API。
這是在網路執行程式碼的核心屬性之一,就是任何耗時的作業。 包括任何 I/O 都必須以非同步方式運作
原因是網路一直以來都是單一執行緒,以及任何涉及 UI 的使用者程式碼 必須在與 UI 相同的執行緒上執行還必須與其他重要任務互相競爭 以便處理 CPU 作業時間的版面配置、轉譯和事件處理不想使用 JavaScript WebAssembly 可以開始「讀取檔案」這項作業會封鎖所有其他項目 例如整個分頁 或過去使用整個瀏覽器,從毫秒到數秒不等,直到設定完畢為止。
相反地,程式碼只能排定 I/O 作業與要執行回呼的時間 訓練完成後這類回呼會在瀏覽器的事件迴圈中執行。我不會 會詳細說明 但若你想瞭解事件迴圈的運作方式 退房 工作、微工作、佇列和排程 這部影片會深入說明這個主題
簡易版是指瀏覽器以無限迴圈的形式執行所有程式碼 逐一移除佇列中的這些事件觸發某些事件時,瀏覽器會將 而在下一個迴圈疊代時,會從佇列中移除並執行。 此機制可模擬並行,以及執行大量平行作業,同時只用於 單一執行緒。
請務必記住這個機制,但您的自訂 JavaScript (或 WebAssembly) 程式碼執行時,事件迴圈會遭到封鎖。 取得 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 在背景中執行。
最後是一個例子,即使是簡單的 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 在預設實作方法中 「睡著」 但效率很低,不僅會封鎖整個使用者介面,而且不允許處理任何其他事件 同時。通常請勿在實際工作環境的程式碼中執行此操作。
其實是更慣用的「睡眠」版本就是呼叫 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
您可以傳回以下來源的值:
非同步函式。內容
您需要傳回 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.handleSleep()
,呼叫 Asyncify.handleAsync()
。接著,就不必逐一安排
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();
正在等待複雜值
但本範例仍限制您只能使用數字。如果想將原始版 和 YAML 檔案 我在哪裡以字串形式從檔案中取得使用者名稱?別擔心,您也可以做到!
Emscripten 提供稱為
Embind,亦即
處理 JavaScript 和 C++ 值之間的轉換。它也支援 Asyncify,因此
您可以對外部 Promise
呼叫 await()
,其運作方式與 async-await 中的 await
相同
JavaScript 程式碼:
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 程式碼中有類似的同步呼叫,且想對應至 async 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 會替我們這麼做,但這裡不會用到,因此這個程序比較手動。
幸好,Asyncify 轉換本身完全適用於各種工具鍊。可以任意轉換
WebAssembly 檔案,無論它是由哪個編譯器產生的檔案都一樣。轉換會分開提供
此為 Binaryen 的 wasm-opt
最佳化工具之一。
工具鍊,並以下列方式叫用:
wasm-opt -O2 --asyncify \
--pass-arg=asyncify-imports@env.get_answer \
[...]
傳遞 --asyncify
來啟用轉換,然後使用 --pass-arg=…
提供以半形逗號分隔
非同步函式清單,其中程式狀態應暫停,並在稍後繼續執行。
接下來只提供支援執行階段程式碼,這些程式碼可以實際執行,即暫停使用並繼續 WebAssembly 程式碼。再次提醒,在 C / C++ 案例中,Emscripten 會納入這部分,但您現在需要 可處理任意 WebAssembly 檔案的自訂 JavaScript 黏合程式碼。我們已建立程式庫 分享這些資料
您可以在 GitHub 的以下網址找到該檔案:
https://github.com/GoogleChromeLabs/asyncify or 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();
當您嘗試從 呼叫這類非同步函式時 (如上述範例中的 get_answer()
),
WebAssembly 端的程式庫會偵測傳回的 Promise
,然後暫停並儲存
WebAssembly 應用程式、訂閱承諾完成,並在問題解決後,
順暢還原呼叫堆疊和狀態,並在沒有任何發生的情況下繼續執行作業。
由於模組中的任何函式都可能進行非同步呼叫,因此所有匯出內容都可能成為
所以系統也能順利包裝您可能在上述範例中發現
您需要 await
instance.exports.main()
的結果,才能得知何時確實執行
已完成。
這些功能實際上是如何運作的?
當 Asyncify 偵測到對其中一個 ASYNCIFY_IMPORTS
函式的呼叫時,就會啟動非同步作業
作業,儲存應用程式的完整狀態,包括呼叫堆疊及任何
本機儲存和呼叫堆疊,並在作業完成後還原所有記憶體和呼叫堆疊,
會從相同位置繼續,狀態會與程式從未停止的狀態相同。
這與我稍早顯示的 JavaScript 中的 async-await 功能相似, 不需要任何特殊的語法或執行階段支援這類語言, 的運作方式是在編譯期間轉換純同步函式。
編譯先前顯示的非同步睡眠範例時:
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 特定最佳化 各種選項 僅限指定函式和/或直接函式呼叫。此外,您也可以參考 執行階段效能的成本較低,但僅限於非同步呼叫本身。不過,相較於其他 API 但實際工作成本通常可以忽略不計
實際示範
閱讀完幾個簡單的範例後,接下來要講解一些更複雜的情況。
如文章的開頭所述,網路上的儲存空間選項就是 非同步 File System Access API。有了 Cloud Shell 從網頁應用程式導入真正的主機檔案系統
另一方面,有個名為 WASI 的不法標準 。主要是 以非同步的方式公開所有類型的檔案系統和其他作業 同步形式。
如果可以,彼此對應呢?接著,您就能使用任何來源語言編譯任何應用程式 與支援 WASI 目標的任何工具鍊執行,並且在網頁的沙箱中執行,同時確保 以便在實際的使用者檔案中運作!而 Asyncify 便能派上用場。
在這個示範中,我已使用 對 WASI 的一些小修補程式,透過 Asyncify 轉換傳遞,並以非同步方式實作 WASI 的繫結 更新至 File System Access API與 Xterm.js 終端機元件,這可提供在 和實際的使用者檔案一樣,就像使用實際的終端機一樣。
詳情請造訪 https://wasi.rreverser.com/。
非同步用途不僅限於計時器和檔案系統。如要進一步 在網路上使用更多小眾 API。
舉例來說,在 Asyncify 的協助下, libusb - 是最適合搭配使用的原生資料庫 USB 裝置至 WebUSB API,可讓這類裝置以非同步方式存取這類裝置 在網路上。完成繪製及編譯後,我就取得了標準的 Libusb 測試和範例 裝置。
也許是另一篇網誌文章的故事。
這些範例展現了 Asyncify 的威力,可說是消弭落差, 多種應用程式部署至網路,方便您取得跨平台存取權、採用沙箱機制,以及 安全性,同時確保功能不會遺失。