Emscripten 支援兩種方式(Asyncify 和 JSPI)讓同步 C 或 C++ 程式碼與非同步 JavaScript 互動。這允許以下事項:
C 語言中的同步呼叫會讓步給事件迴圈,這允許處理瀏覽器事件。
C 語言中的同步呼叫會等待 JS 中的非同步操作完成。
一般而言,這兩個選項非常相似,但依賴不同的底層機制運作。
Asyncify - Asyncify 會自動將您編譯的程式碼轉換為可以暫停和恢復的形式,並為您處理暫停和恢復,使其成為非同步(因此命名為「Asyncify」),即使您是以正常的同步方式撰寫。這在大多數環境中都有效,但可能會導致 Wasm 輸出變得更大。
JSPI(實驗性) - 使用 VM 對 JavaScript Promise Integration (JSPI) 的支援,與非同步 JavaScript 互動。程式碼大小會保持不變,但對此功能之支援仍處於實驗階段。
如需更多關於 Asyncify 的資訊,請參閱Asyncify 介紹部落格文章,了解一般背景和內部運作方式的詳細資訊(您也可以觀看這個關於 Asyncify 的演講)。以下將擴充該文章中的 Emscripten 範例。
讓我們從該部落格文章中的範例開始
// example.cpp
#include <emscripten.h>
#include <stdio.h>
// start_timer(): call JS to set an async timer for 500ms
EM_JS(void, start_timer, (), {
Module.timer = false;
setTimeout(function() {
Module.timer = true;
}, 500);
});
// check_timer(): check if that timer occurred
EM_JS(bool, check_timer, (), {
return Module.timer;
});
int main() {
start_timer();
// Continuously loop while synchronously polling for the timer.
while (1) {
if (check_timer()) {
printf("timer happened!\n");
return 0;
}
printf("sleeping...\n");
emscripten_sleep(100);
}
}
您可以使用 -sASYNCIFY 或 -sJSPI 來編譯該程式碼
emcc -O3 example.cpp -s<ASYNCIFY or JSPI>
注意
當使用 Asyncify 時,最佳化(此處的 -O3
)非常重要,因為未最佳化的建置非常大。
您可以使用以下命令執行它
nodejs a.out.js
或使用 JSPI
nodejs --experimental-wasm-stack-switching a.out.js
您應該會看到類似以下內容
sleeping...
sleeping...
sleeping...
sleeping...
sleeping...
timer happened!
程式碼是使用直接的迴圈撰寫的,該迴圈在執行時不會退出,這通常不允許瀏覽器處理非同步事件。使用 Asyncify/JSPI,這些睡眠實際上會讓步給瀏覽器的主事件迴圈,並且計時器可以發生!
除了 emscripten_sleep
和 Asyncify 支援的其他標準同步 API 之外,您也可以新增自己的函式。為此,您必須建立從 Wasm 呼叫的 JS 函式(因為 Emscripten 從 JS 執行期控制暫停和恢復 Wasm)。
一種方法是使用 JS 函式庫函式。另一種方法是使用 EM_ASYNC_JS
,我們將在下一個範例中使用它
// example.c
#include <emscripten.h>
#include <stdio.h>
EM_ASYNC_JS(int, do_fetch, (), {
out("waiting for a fetch");
const response = await fetch("a.html");
out("got the fetch response");
// (normally you would do something with the fetch here)
return 42;
});
int main() {
puts("before");
do_fetch();
puts("after");
}
在此範例中,非同步操作是 fetch
,這表示我們需要等待 Promise。雖然該操作是非同步的,但請注意 main()
中的 C 程式碼完全是同步的!
若要執行此範例,請先使用以下命令進行編譯
emcc example.c -O3 -o a.html -s<ASYNCIFY or JSPI>
若要執行此範例,您必須執行本機 Web 伺服器,然後瀏覽至 https://127.0.0.1:8000/a.html
。您會看到類似以下內容
before
waiting for a fetch
got the fetch response
after
這表示 C 程式碼只有在非同步 JS 完成後才會繼續執行。
如果您的目標 JS 引擎不支援現代的 async/await
JS 語法,您可以使用 EM_JS
和 Asyncify.handleAsync
,將上述 do_fetch
的實作反糖化,以直接使用 Promises
EM_JS(int, do_fetch, (), {
return Asyncify.handleAsync(function () {
out("waiting for a fetch");
return fetch("a.html").then(function (response) {
out("got the fetch response");
// (normally you would do something with the fetch here)
return 42;
});
});
});
當使用此形式時,編譯器不會靜態知道 do_fetch
是否為非同步。相反地,您必須使用 ASYNCIFY_IMPORTS
告訴編譯器 do_fetch()
可以執行非同步操作,否則它不會檢測程式碼以允許暫停和恢復(稍後會有更多詳細資訊)
emcc example.c -O3 -o a.html -sASYNCIFY -sASYNCIFY_IMPORTS=do_fetch
最後,如果您也無法使用 Promises,您可以使用 Asyncify.handleSleep
將範例反糖化,這會將 wakeUp
回呼傳遞至您的函式實作。當呼叫此 wakeUp
回呼時,C/C++ 程式碼將會恢復
EM_JS(int, do_fetch, (), {
return Asyncify.handleSleep((wakeUp) => {
out("waiting for a fetch");
fetch("a.html").then(function (response) {
out("got the fetch response");
// (normally you would do something with the fetch here)
wakeUp(42);
});
});
});
請注意,當使用此形式時,您無法從函式本身傳回值。相反地,您需要將其作為引數傳遞至 wakeUp
回呼,並透過在 do_fetch
本身中傳回 Asyncify.handleSleep
的結果來傳播它。
ASYNCIFY_IMPORTS
的資訊¶如同上述範例,您可以加入執行非同步操作的 JS 函數,但在 C 語言的角度來看,它們看起來是同步的。如果您不使用 EM_ASYNC_JS
,就必須將這些方法加入 ASYNCIFY_IMPORTS
。此匯入列表是 Asyncify instrumentation 必須知道的 Wasm 模組的匯入列表。提供此列表會告知它所有其他 JS 呼叫都「不會」執行非同步操作,使其不會在不需要的地方增加額外負擔。
注意
如果匯入不在 env
內,則必須指定完整路徑,例如:ASYNCIFY_IMPORTS=wasi_snapshot_preview1.fd_write
如果您想在動態函式庫中使用 Asyncify,則從其他連結模組匯入的方法(且將在非同步操作的堆疊中)應列在 ASYNCIFY_IMPORTS
中。
// sleep.cpp
#include <emscripten.h>
extern "C" void sleep_for_seconds() {
emscripten_sleep(100);
}
在側模組中,您可以用一般 emscripten 動態連結的方式編譯 sleep.cpp
emcc sleep.cpp -O3 -o libsleep.wasm -sASYNCIFY -sSIDE_MODULE
// main.cpp
#include <emscripten.h>
extern "C" void sleep_for_seconds();
int main() {
sleep_for_seconds();
return 0;
}
在主模組中,編譯器並不知道 sleep_for_seconds
是非同步的。因此,您必須將 sleep_for_seconds
加入 ASYNCIFY_IMPORTS
列表。
emcc main.cpp libsleep.wasm -O3 -sASYNCIFY -sASYNCIFY_IMPORTS=sleep_for_seconds -sMAIN_MODULE
如果您使用 Embind 與 JavaScript 互動,並想 await
動態檢索的 Promise
,您可以直接在 val
實例上呼叫 await()
方法
val my_object = /* ... */;
val result = my_object.call<val>("someAsyncMethod").await();
在這種情況下,您無需擔心 ASYNCIFY_IMPORTS
或 JSPI_IMPORTS
,因為它是 val::await
的內部實作細節,Emscripten 會自動處理。
請注意,當使用 Embind 匯出時,Asyncify 和 JSPI 的行為有所不同。當 Asyncify 與 Embind 一起使用,並且程式碼是從 JavaScript 呼叫時,如果匯出呼叫任何暫停函數,則該函數將傳回一個 Promise
,否則結果將會同步傳回。但是,使用 JSPI 時,必須使用參數 emscripten::async()
將函數標記為非同步,且無論匯出是否暫停,匯出都將始終傳回一個 Promise
。
#include <emscripten/bind.h>
#include <emscripten.h>
static int delayAndReturn(bool sleep) {
if (sleep) {
emscripten_sleep(0);
}
return 42;
}
EMSCRIPTEN_BINDINGS(example) {
// Asyncify
emscripten::function("delayAndReturn", &delayAndReturn);
// JSPI
emscripten::function("delayAndReturn", &delayAndReturn, emscripten::async());
}
使用以下方式建置
emcc -O3 example.cpp -lembind -s<ASYNCIFY or JSPI>
然後從 JavaScript 呼叫(使用 Asyncify)
let syncResult = Module.delayAndReturn(false);
console.log(syncResult); // 42
console.log(await syncResult); // also 42 because `await` is no-op
let asyncResult = Module.delayAndReturn(true);
console.log(asyncResult); // Promise { <pending> }
console.log(await asyncResult); // 42
與總是傳回 Promise
的 JavaScript async
函數相反,傳回值是在執行時期決定的,並且僅在遇到 Asyncify 呼叫時(例如 emscripten_sleep()
、val::await()
等)才會傳回 Promise
。
如果程式碼路徑不確定,呼叫端可以檢查傳回值是否為 instanceof Promise
,或者直接 await
傳回值。
當使用 JSPI 時,傳回值將始終為 Promise
,如下所示
let syncResult = Module.delayAndReturn(false);
console.log(syncResult); // Promise { <pending> }
console.log(await syncResult); // 42
let asyncResult = Module.delayAndReturn(true);
console.log(asyncResult); // Promise { <pending> }
console.log(await asyncResult); // 42
ccall
使用¶若要從 Javascript 使用使用 Asyncify 的 Wasm 匯出,您可以使用 Module.ccall
函數,並將 async: true
傳遞給其呼叫選項物件。ccall
接著會傳回一個 Promise,一旦計算完成,該 Promise 將解析為函數的結果。
在此範例中,會呼叫一個傳回 Number 的 "func" 函數。
Module.ccall("func", "number", [], [], {async: true}).then(result => {
console.log("js_func: " + result);
});
除了使用不同的底層機制外,Asyncify 和 JSPI 對非同步匯入和匯出的處理方式也不同。Asyncify 會根據可能呼叫非同步匯入 (ASYNCIFY_IMPORTS
) 的內容,自動決定哪些匯出將變為非同步。但是,使用 JSPI 時,必須使用 JSPI_IMPORTS
和 JSPI_EXPORTS
設定顯式設定非同步匯入和匯出。
注意
當使用上述各種輔助工具(例如:EM_ASYNC_JS
、Embind 的非同步支援、ccall
等)時,不需要 <JSPI/ASYNCIFY>_IMPORTS
和 JSPI_EXPORTS
。
注意
本節不適用於 JSPI。
如先前所述,使用 Asyncify 的未最佳化建置可能會很大且很慢。使用最佳化 (例如,-O3
) 建置以獲得良好的結果。
Asyncify 會增加額外負擔,包括程式碼大小和速度,因為它會檢測程式碼以允許展開和重新捲動。這種額外負擔通常不是極端,大約在 50% 左右。Asyncify 通過執行全程式分析來找出哪些函數需要檢測,哪些不需要,來實現這一點 - 基本上,哪些函數可以呼叫會到達 ASYNCIFY_IMPORTS
的內容。該分析避免了許多不必要的開銷,但是它受到**間接呼叫**的限制,因為它無法判斷它們要去哪裡 - 它可能是函數表中的任何內容 (具有相同的類型)。
如果您知道在展開時,間接呼叫永遠不在堆疊上,則可以告知 Asyncify 使用 ASYNCIFY_IGNORE_INDIRECT
忽略間接呼叫。
如果您知道某些間接呼叫很重要而其他則不重要,則可以提供一個手動函數列表給 Asyncify
ASYNCIFY_REMOVE
是不展開堆疊的函數列表。當 Asyncify 處理呼叫樹狀結構時,將會移除此列表中的函數,並且它們和它們的呼叫端都不會被檢測(除非它們的呼叫端因其他原因而需要被檢測)。
ASYNCIFY_ADD
是一個展開堆疊的函數列表,並且會像匯入一樣處理。如果您使用 ASYNCIFY_IGNORE_INDIRECT
,但又想標記其他一些需要展開的函數,這非常有用。但是,如果停用了 ASYNCIFY_PROPAGATE_ADD
設定,則此列表僅在全程式分析之後才會加入。如果停用了 ASYNCIFY_PROPAGATE_ADD
,您還必須加入它們的呼叫端、它們的呼叫端的呼叫端,依此類推。
ASYNCIFY_ONLY
是唯一可以展開堆疊的函數列表。Asyncify 將只檢測這些函數,不檢測其他函數。
您可以啟用 ASYNCIFY_ADVISE
設定,這將告知編譯器輸出其目前正在檢測的函數以及原因。然後,您可以確定是否應將任何函數加入 ASYNCIFY_REMOVE
,或者啟用 ASYNCIFY_IGNORE_INDIRECT
是否安全。請注意,編譯器的此階段會在許多最佳化階段之後發生,並且可能已經內聯了一些函數。為了安全起見,請使用 -O0 執行。
有關更多詳細資訊,請參閱 settings.js
。請注意,此處提及的手動設定容易出錯 - 如果您沒有將設定完全正確,您的應用程式可能會崩潰。如果您絕對不需要最大的效能,通常使用預設值是可以的。
如果您看到從 asyncify_*
API 拋出的例外,則可能是堆疊溢位。您可以使用 ASYNCIFY_STACK_SIZE
選項來增加堆疊大小。
在等待非同步操作時,可能會發生瀏覽器事件。這通常是使用 Asyncify 的重點,但也可能發生意外事件。例如,如果您只想暫停 100 毫秒,則可以呼叫 emscripten_sleep(100)
,但是如果您有任何事件監聽器,例如針對按鍵,則如果按下按鍵,則會觸發處理常式。如果該處理常式呼叫編譯後的程式碼,則可能會讓人感到困惑,因為它開始看起來像是協程或多執行緒,並且有多個執行交錯。
在另一個非同步操作已經在執行時開始一個非同步操作是「不」安全的。第一個操作必須完成,第二個操作才能開始。
這種交錯也可能會破壞程式碼庫中的假設。例如,如果函數使用全域變數,並假設在函數傳回之前沒有其他內容可以修改它,但是如果該函數暫停,並且事件導致其他程式碼變更該全域變數,則可能會發生糟糕的事情。
上面的範例顯示 wakeUp() 是從 JS 呼叫的(通常在回呼之後),並且堆疊上沒有任何編譯後的程式碼。如果堆疊上「有」編譯後的程式碼,則可能會以令人困惑的方式干擾正確地重新捲動和恢復執行,因此在具有 ASSERTIONS
的建置中會拋出一個斷言。
(具體來說,那裡的問題是,雖然重新捲動會正常運作,但如果您稍後再次展開,則展開也會展開堆疊上的額外編譯程式碼 - 導致稍後的重新捲動行為不佳。)
您可能會發現一個簡單的因應措施是執行 setTimeout 為 0,以 setTimeout(wakeUp, 0);
取代 wakeUp()
。這將在稍後的回呼中執行 wakeUp
,而不會有其他任何內容在堆疊上。
如果您的程式碼使用舊版的 Emterpreter-Async API 或舊版的 Asyncify,那麼當您將 -sEMTERPRETIFY
的用法替換為 -sASYNCIFY
時,幾乎所有功能都應該能正常運作。 特別是像 emscripten_wget
之類的所有東西都應該像以前一樣工作。
一些小的差異包括:
Emterpreter 有「讓步 (yielding)」的概念,但在 Asyncify 中則不需要。您可以將
emscripten_sleep_with_yield()
的呼叫替換為emscripten_sleep()
。內部的 JS API 不同。 請參閱上面關於
Asyncify.handleSleep()
的說明,並參閱src/library_async.js
以取得更多範例。