透過 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舉例來說,如果您將範例轉譯為 Rust,API 看起來會比較簡單, 相同的原則您只需發出呼叫,然後同步等待呼叫傳回結果。 同時執行所有昂貴作業,最後將結果傳回在單一 叫用:

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

但當您嘗試將這些範例編譯到 WebAssembly 並轉譯為 網路?提供具體範例,證明應該「讀取檔案」每個作業都會轉譯到嗎?會 需要讀取某些儲存空間中的資料

網頁的非同步模型

網路提供各種可以對應的儲存空間選項,例如記憶體內儲存空間 (JS) 物件)、localStorageIndexedDB、伺服器端儲存空間 和新的 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 支援的編譯時間功能 能夠暫停整個程式 以便稍後以非同步的方式恢復

呼叫圖表
描述 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

您可以傳回以下來源的值: 非同步函式。內容 您需要傳回 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 函式,並使用 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();

正在等待複雜值

但本範例仍限制您只能使用數字。如果想將原始版 和 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 測試和範例 裝置。

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

也許是另一篇網誌文章的故事。

這些範例展現了 Asyncify 的威力,可說是消弭落差, 多種應用程式部署至網路,方便您取得跨平台存取權、採用沙箱機制,以及 安全性,同時確保功能不會遺失。