非同步程式碼

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,這些睡眠實際上會讓步給瀏覽器的主事件迴圈,並且計時器可以發生!

讓非同步 Web API 的行為如同它們是同步的

除了 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 完成後才會繼續執行。

在較舊的引擎中使用 Asyncify API 的方法

如果您的目標 JS 引擎不支援現代的 async/await JS 語法,您可以使用 EM_JSAsyncify.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,則從其他連結模組匯入的方法(且將在非同步操作的堆疊中)應列在 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 使用

如果您使用 Embind 與 JavaScript 互動,並想 await 動態檢索的 Promise,您可以直接在 val 實例上呼叫 await() 方法

val my_object = /* ... */;
val result = my_object.call<val>("someAsyncMethod").await();

在這種情況下,您無需擔心 ASYNCIFY_IMPORTSJSPI_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 和 JSPI 對非同步匯入和匯出的處理方式也不同。Asyncify 會根據可能呼叫非同步匯入 (ASYNCIFY_IMPORTS) 的內容,自動決定哪些匯出將變為非同步。但是,使用 JSPI 時,必須使用 JSPI_IMPORTSJSPI_EXPORTS 設定顯式設定非同步匯入和匯出。

注意

當使用上述各種輔助工具(例如:EM_ASYNC_JS、Embind 的非同步支援、ccall 等)時,不需要 <JSPI/ASYNCIFY>_IMPORTSJSPI_EXPORTS

最佳化 Asyncify

注意

本節不適用於 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)

如果您看到從 asyncify_* API 拋出的例外,則可能是堆疊溢位。您可以使用 ASYNCIFY_STACK_SIZE 選項來增加堆疊大小。

重入性

在等待非同步操作時,可能會發生瀏覽器事件。這通常是使用 Asyncify 的重點,但也可能發生意外事件。例如,如果您只想暫停 100 毫秒,則可以呼叫 emscripten_sleep(100),但是如果您有任何事件監聽器,例如針對按鍵,則如果按下按鍵,則會觸發處理常式。如果該處理常式呼叫編譯後的程式碼,則可能會讓人感到困惑,因為它開始看起來像是協程或多執行緒,並且有多個執行交錯。

在另一個非同步操作已經在執行時開始一個非同步操作是「不」安全的。第一個操作必須完成,第二個操作才能開始。

這種交錯也可能會破壞程式碼庫中的假設。例如,如果函數使用全域變數,並假設在函數傳回之前沒有其他內容可以修改它,但是如果該函數暫停,並且事件導致其他程式碼變更該全域變數,則可能會發生糟糕的事情。

在堆疊上有編譯後的程式碼時開始重新捲動 (Asyncify)

上面的範例顯示 wakeUp() 是從 JS 呼叫的(通常在回呼之後),並且堆疊上沒有任何編譯後的程式碼。如果堆疊上「有」編譯後的程式碼,則可能會以令人困惑的方式干擾正確地重新捲動和恢復執行,因此在具有 ASSERTIONS 的建置中會拋出一個斷言。

(具體來說,那裡的問題是,雖然重新捲動會正常運作,但如果您稍後再次展開,則展開也會展開堆疊上的額外編譯程式碼 - 導致稍後的重新捲動行為不佳。)

您可能會發現一個簡單的因應措施是執行 setTimeout 為 0,以 setTimeout(wakeUp, 0); 取代 wakeUp()。這將在稍後的回呼中執行 wakeUp,而不會有其他任何內容在堆疊上。

從較舊的 Asyncify API 遷移

如果您的程式碼使用舊版的 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 以取得更多範例。