本篇一方面作為 BPS 開發分享系列之一,另一方面也接續另一個系列的文章來繼續分享 Emscripten 的使用心得。
我自己覺得,對初學 Emscripten 的人來說,最傷腦筋的地方在於它的設計比較低階、以至於同一種需求往往會有很多種不同的手法可以達成,而這反而會讓入門者無所適從、不知道要用哪一種作法才適合。我當然可以直接講我最後的選擇,但是那樣做也不見得對所有的讀者都有幫助;所以在本篇當中,針對幾個主要的課題,我會試著呈現出幾種不同作法之間的優缺點比較,讓大家能夠針對自己的情況去決定要怎麼做比較好。
內容目錄
從 Pyodide 到 Emscripten
在 BPS v7 當中,最重大的新功能就是加入了摺紙設計佈局的最佳化求解器,也就是說,給予一個摺紙設計,我試圖去計算出把紙張應用效率最大化的有效佈局去實現該設計。最一開始在 0.7.0 版當中,我其實是用 Pyodide 去執行 SciPy 套件、利用裡面實作的 SLSQP 演算法來進行最佳化求解。因為 Pyodide 版本的 SciPy 也是把原生的 SLSQP Fortran 原始碼編譯成 WASM 才得以讓它能在瀏覽器中執行,我本來以為,相對於演算法本身的耗時,Pyodide 本身的 overhead 應該是可以被忽略的,從而整體的執行速度應該是無可挑剔的才對。
然而,就在我把 SciPy 的求解器實作出來之後不久,我就發現了 NLopt 的這個 C/C++ 套件,裡面一樣有實作 SLSQP 演算法(而且是基於跟 SciPy 一樣的 Fortran 原始碼,所以兩者的效能是可以直接比較的),我好奇之下就把整個 C++ 的求解器也一樣實作了出來,而大出乎我意料地,兩者的速度竟然差了足足有 10 ~ 50 倍之多(視輸入的問題而定)。由於 SLSQP 的核心部份兩者應該是基本上一樣快的,唯一的合理解釋就是用 Pyodide 執行 Python 的 overhead 跟 C++ 比起來實在差太多了,遠多過我本來的預期。不僅如此,改用 C++ 之後,執行時總共需要載入的東西也從 Pyodide + SciPy 的 69MB 縮小到只剩不到 500KB。所以理所當然,從 0.7.1 版之後,BPS 就完全改用 C++ 的求解器了。
我的心得就是:除非實在沒別的辦法,不然 Pyodide 這個東西能不用則不用,因為真的太肥、太慢了。
加入第三方程式庫
那這邊的一個重點在於我需要把第三方程式庫(在此是 NLopt)整合進來到我的 WASM 裡面。這有幾個可能的作法:
- 直接把第三方程式庫的原始碼放到我的專案裡頭,一起編譯。
這種作法在概念上最簡單,但比較適合當該程式庫沒有額外相依性、原始碼不多、且更新不頻繁的情況。如果該程式庫常常更新,這樣做的話同步起來會比較麻煩。而如果該程式庫的原始碼量大、或是有額外相依套件的話,把對應的原始碼全部都放到自己的專案裡也很囉唆。
以 NLopt 來說,它的原始碼規模不小、而且又還算常常更新,所以我不會選擇這種作法。 - 把第三方程式庫編譯成 side-module,然後在執行階段動態連結。
這樣做的好處是不同的 WASM 模組之間可以共用這個動態模組,而不需要每一個都自帶一包進來;但缺點則是整體的打包大小會比較大(主要模組會多出動態載入的架構,且子模組為求通用、沒辦法高度最佳化)、以及載入的時候會有額外的網路請求。
由於我在 BPS 裡頭只有一個 WASM 程式,這種作法對我也沒有好處。 - 把第三方程式庫編譯成靜態程式庫(.a 檔案),然後在編譯階段中連結進來。
這樣做的好處是,最後的產出會是單一的 WASM 模組,而且打包大小可以高度最佳化,跟前一種作法相比起來就是最適合我的選項。
為了做到這一點,首先我們必須一樣用 Emscripten 來編譯 NLopt(如果它有其它上游的相依套件,那麼那些也都要用 Emscripten 來編譯一遍,幸好 NLopt 是零相依的套件),並確保在編譯的時候設定產生的是靜態程式庫(這要看個別套件的編譯腳本是怎麼寫的;以 NLopt 來說,方法是在執行 cmake 的時候加上 -DBUILD_SHARED_LIBS=OFF
旗標)。如同我在上一篇中提到的,NLopt 很幸運地直接套 emcmake + emmake 就可以直接編譯成功;然後只要把編譯出來的 nlopt.a 和 nlopt.h 檔案放在我們的專案裡面,讓我們的程式碼正確地引用 nlopt.h,並且在編譯的時候在連結器選項中加上這樣的旗標:
-L[.a 檔案的所在目錄] -lnlopt
就可以成功編譯了。
執行模式
用 Emscripten 編譯出來的 C/C++ 程式主要有兩種類型:
- 跟傳統的 CLI 程式一樣,程式具有一個
main()
函數作為執行入口,然後 WASM 模組一載入它就會立刻執行這個main()
1。如果我們的程式是直接從一個既有的 CLI 程式專案改造過來的,通常會維持這樣的作法,因為對程式碼的更動較小;但相對地,這種作法要從 JS 傳遞資料給 WASM 會需要採用比較間接的作法。 - 程式碼裡面沒有
main()
函數,此時 WASM 模組被載入的時候不會自動執行任何東西,而所有的功能都要等到 JavaScript 這邊來呼叫 C/C++ 的函數才能使用。這種寫法可以直接把要傳遞的資料當成參數去呼叫函數,比前一種作法直觀,如果是從頭開始自己刻的 WASM 模組、我會建議採用此作法。
然而,Emscripten 在設置了編譯最佳化選項(像是 -O3
)的時候,是會自動移除沒有使用到的函數的。在第一種模式當中,它會以 main()
函數為起點去找出所有的相依性,然後移除沒用到的東西;但在第二種模式中,因為沒有一個特定的入口點,我們必須要告訴 Emscripten 有哪些函數是要對外輸出的,否則對 Emscripten 來說、全部的函數都是「沒有被使用到的」,所以全部都會被移除掉。
要指定輸出的函數,首先如果是使用 C++ 的話,要記得在 extern "C" { ... }
區塊裡面宣告那些要輸出的函數,這樣那些函數的名稱才不會在編譯的時候被 mangle 掉。接著,為了要讓 Emscripten 知道那些函數必須保留,有幾種作法。
假設我們有一個要輸出的函數叫 my_func()
,第一種方法是可以在編譯選項中加上 -sEXPORTED_FUNCTIONS=_my_func,...
的旗標(後面接的是逗點分隔的函數名稱清單,注意到這些函數名稱前面都會多加一個「_
」符號;另外注意到假如我們的程式也是有 main()
函數的話,那 _main
也要明確地寫進去)。不過,這種作法有一個小缺點:如果這些被輸出的函數在我們的程式碼裡面有被別的函數使用到,那麼編譯器有可能會把那個函數編譯成 inline 的型式,如此一來就算我們在這邊指定也沒有用,因為到頭來根本就沒有一個那樣名稱的函數存在。
第二個方法、且也是比較可靠的方法,是用 EMSCRIPTEN_KEEPALIVE
巨集來標示出那些要輸出的函數,例如:
#include <emscripten.h>
extern "C" {
EMSCRIPTEN_KEEPALIVE void my_func() {
...
}
}
這個巨集兼具 -sEXPORTED_FUNCTIONS
旗標的效果與防止 inline 的作用。最後第三個方法,透過 Embind 宣告出來的函數也會兼具 EMSCRIPTEN_KEEPALIVE
的效果,這個我們等一下會看到。
那麼設定好輸出了之後,編譯出來的模組就會有一個 _my_func()
方法可以被呼叫:
const module = await moduleFactory(modulePrototype);
module._my_func(); // 可以這樣直接呼叫
另外一種寫法是在編譯時設定 -sEXPORTED_RUNTIME_METHODS=ccall
來輸出內建的 ccall()
函數,以便間接呼叫:
module.ccall("my_func"); // 注意到這種寫法不用加上前面的「_」
使用 ccall()
的好處在於,它在處理函數的傳入參數跟傳回值方面會比直接呼叫要簡單(參見文件),但是還有另外一個更重要的差異。假設我們的函數裡面有使用 Asyncify(參見上一篇),那麼單純使用 EMSCRIPTEN_KEEPALIVE
(而沒有使用等一下會講的 Embind)宣告出來的函數在直接呼叫的時候,其實不會傳回 Promise
物件、而是立刻同步地傳回當時在傳回值所在的堆疊位置中的值(通常會是一個沒有意義的值)。必須改用 ccall()
呼叫才會拿到 Promise
物件:
await module.ccall(
"my_func", // 函數名稱
null, // 這個函數沒有傳回值(其它可能的選項如 "number", "string" 等等)
[], // 參數的類別清單,這邊我們沒有參數,所以是空陣列
[], // 傳入的參數本身,一樣因為這邊沒有參數所以是空陣列
{ async: true } // 指定這是非同步呼叫,傳回 Promise
);
詳細的用法可以參考 ccall()
的文件。或者,更簡單的作法是使用等一下會介紹的 Embind,因為它會自動根據函數裡面有沒有用到 Asyncify 來回傳 Promise
或純量。
中斷運算
很多情境中,我們之所以會想要使用 WASM、就是因為我們需要高速執行一些很耗時的複雜演算法。在這種應用中,基本上我們都會選擇在 worker 裡面執行 WASM、以免 UI 執行緒被佔用而呈現出有如當機般的停頓狀態。不僅如此,用 worker 執行還有另一個好處:假設演算法實作跑太久、使用者想放棄,那麼我們可以直接執行 worker.terminate()
方法來強制終止其執行、並釋放所有對應的記憶體空間。
不過,有的時候我們並不希望把 WASM 給整個幹掉、而是只要讓它暫停運算而已,其狀態仍然希望可以保留下來,以便這個 WASM 實體能夠繼續去執行別的工作、或者甚至等一下之後繼續接續剛才未完成的運算。
而這樣的一種需求就比較需要一點功夫了。畢竟,這個暫停的操作必然是來自 UI 執行緒,但是同一時間中 worker 正在忙著執行 WASM 的運算、沒事是不會停下來理會 UI 執行緒下達的命令的。怎麼辦呢?這有兩者方法可解。
解法一:Asyncify
第一個作法就是真的叫 WASM 三不五時地停下來一下、檢查看看 UI 那邊有沒有「暫停」的命令發送過來,沒有的話再繼續。不過,由於 worker 一定要等到當前的執行堆疊結束了之後、才有辦法接收來自 UI 的 postMessage()
方法丟過來的命令,這整個流程必然是非同步的,所以就要用到 Asyncify。
整套機制大致是這樣的。首先一樣,在編譯的時候要加上 -sASYNCIFY=1
旗標。接下來我們注入一個 checkInterruptAsync()
方法給我們的 WASM 模組如下:
import moduleFactory from "myWASM.js";
// 用來判斷是否有中斷命令的 callback
let interruptResolve = () => {};
// 監聽來自 UI 透過 postMessage() 方法發送過來的訊息
addEventListener("message", event => {
const command = event.data;
if(command == "pause") {
interruptResolve(true); // 有接到命令的話就丟 true 給 callback
}
});
moduleFactory({
..., // 其它我們注入的方法,參考前一篇
checkInterruptAsync: () => new Promise(resolve => {
// 當 C++ 部份呼叫 checkInterruptAsync 的時候,底下這兩行會先被執行,
// 然後當前的執行堆疊才會結束、進而使得 message 事件被觸發,執行上面的部份。
interruptResolve = resolve;
setTimeout(
// 如果沒有事件進來,那麼就會在很短的時間之後傳回 false
() => resolve(false),
0 // 實際上會是 4ms 的延遲(瀏覽器設下的極限)
);
}),
});
然後在 C++ 的部份則宣告:
#include <emscripten.h>
EM_ASYNC_JS(bool, check_interrupt_async, (), {
return await Module.checkInterruptAsync();
});
最後,在執行耗時演算法的迴圈當中,每隔一定的迭代次數就呼叫一次 check_interrupt_async()
函數,如果傳回 true
的話就跳出迴圈(或者在 BPS 的設計中,會跳過當前的步驟、直接繼續執行下一個階段)。
這個解法的優點在於相容性比較高、稍微舊一點的瀏覽器版本也能支援,但是缺點則在於它多少會犧牲掉一點效能:如果暫停檢查很頻繁地執行,演算法的整體速度就會明顯折扣(畢竟每次都要花上 4ms 來判斷);但如果檢查間隔拉太長,則當使用者按下「暫停」的時候就會明顯延遲才生效。
我個人的建議是以「每秒鐘檢查約 2~3 次」這樣的頻率為原則。然而,WASM 迴圈執行的速度還會隨著裝置而有差異,於是「那樣的頻率相當於是幾次的迴圈迭代」也是沒辦法事先寫死的;解決的方法就是一開始可以先執行例如 100 次迴圈來計時,然後根據計時的結果去估計。
解法二:SharedArrayBuffer
前一個解法之所以會消耗效能,關鍵在於我們是用 postMessage()
方法在進行 UI 和 worker 之間的通訊的。一個更高級的解法則是改用 SharedArrayBuffer 來進行通訊;簡單來說,它就是一個可以被 UI 和 worker 共用的記憶體空間,使得 UI 寫進去的資料可以被 worker 直接同步地讀取出來。因為這樣的操作方式不涉及 Asyncify、而是直接查看記憶體,此解法對效能的影響近乎是零,頻繁檢查也無所謂,因此可以零延遲地回應使用者的暫停操作 2。
可是,就是因為 SharedArrayBuffer 能夠帶來這種極佳的效能,這意味著我們可以利用它來在網頁上做出一個很精密的計時器,使得有心的攻擊者可以透過觀察 CPU 執行的副作用來竊取資料(詳情參見 Spectre 事件);這件事導致瀏覽器從 2018 年初開始禁止使用 SharedArrayBuffer,直到後來解法被提出了之後才又再次可用。到了今天,這個東西在 Safari 上至少需要 15.2 版才能用(參見 CanIUse),而且還有不少的設置門檻,底下逐一說明。(在 BPS 當中,我兩種解法都有實作;能用 SharedArrayBuffer 的話就優先用它,不行的話再 fallback 到 Asyncify 之上。)
首先,它只能在 HTTPS 底下使用。當然這在今天算不上什麼門檻,但是接下來的就是了:它要求執行環境必須是跨來源隔離的(cross-origin isolated),簡單來說就是這樣的網頁會防止來自第三方來源(例如攻擊者)的程式碼竊取資訊。所有能提供高精密度計時的功能都必須在這樣的環境下才能使用 3。
COOP/COEP
要設定跨來源隔離,我們必須確保有關連的執行頁框在 HTTPS 讀取的時候都會傳回所謂的 COOP/COEP 標頭:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp # 或是較弱的 credentialless 也可以
# 如果仍舊需要使用第三方資源(一個常見的例子是 badge)的話,可能就必須用 credentialless
所謂的執行頁框,就是會發起一個執行上下文的東西,簡單來說就是每一個 HTML 檔案(包括 <iframe>
中出現的)以及 worker 的入口 .js 檔案。我們只需要讓這些檔案本身有 COOP/COEP 標頭即可,至於其它會被它們引用到的 .js 檔案和資源則不用加也無妨。以 BPS 來說,在 Rsbuild 的開發階段中,這可以在 rsbuild.config.ts 裡面設定:
export default defineConfig({
...,
server: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
...,
});
至於正式佈署方面,因為它執行的伺服器是我自己的,要設置 HTTPS 標頭當然沒什麼大問題;我在 Apache 伺服器中採用類似像這樣的 .htaccess 去設定即可:
<IfModule mod_headers.c>
# 下面這行的設定具體要看實際的檔名
<FilesMatch "(index.htm|worker.js)$">
Header set Cross-Origin-Opener-Policy "same-origin"
Header set Cross-Origin-Embedder-Policy "require-corp"
</FilesMatch>
</IfModule>
但是如果網站是架設在 GitHub Pages 之類的這種無法設定 HTTPS 標頭的服務上 4,就必須要多動一點手腳了。關鍵在於,我們可以利用 ServiceWorker 來竄改回傳的 HTTPS 標頭(畢竟 ServiceWorker 也是在同一個 HTTPS 網域之下的,對瀏覽器來說、它做出來的修改跟伺服器本身的回傳具有同等級的效力);然而,這個作法有一個本質上的極限:在使用者第一次造訪網站的時候,因為 ServiceWorker 尚未載入,拿到的 HTML 必定是沒有設置好跨來源隔離的,所以至少要等到頁面重新載入一次之後才能生效,這就會使得第一次造訪的訪客暫時無法是無法使用 SharedArrayBuffeer 的。不過,這個問題解法也很簡單:在網頁載入的時候,檢查跨來源隔離是否有啟用(去看 window.crossOriginIsolated 就知道),如果沒有的話,先不要顯示網頁(或者顯示一個載入的動畫),然後等待 ServiceWorker 載入完畢之後自動重新載入當前的頁面;這些動作只有訪客第一次造訪時會發生,所以不太會令人在意。關於這些作法的一套完整實作可以參考 coi-serviceworker 套件,我在這邊就不多展示程式碼了。
實作中斷機制
好,那麼有了 SharedArrayBuffer 之後,整個中斷機制的實作方式如下。首先我們要先在 UI 執行緒這邊建立一個 SharedArrayBuffer:
// 一個 byte 就夠了
const interruptBuffer = new Uint8Array(new SharedArrayBuffer(1));
然後我們可以在 worker 啟動的時候,用 postMessage()
方法把這個 interruptBuffer
傳遞過去(直接把它放在傳遞的 payload 裡面即可;它不是 transferable 物件,無須另外再做設定),使得 worker 在接收到它的時候一樣保存起來成同名的變數。
接著,我們在 WASM 模組當中注入檢查的方法:
moduleFactory({
...,
checkInterrupt: () => {
const result = interruptBuffer[0];
interruptBuffer[0] = 0; // 自動把它歸零,以確保中斷不會連續觸發
return result;
},
});
對應的 C++ 程式碼則是:
#include <emscripten.h>
// 因為是同步執行的,所以這邊是 EM_JS 而非 EM_ASYNC_JS
EM_JS(bool, check_interrupt_sync, (), {
return Module.checkInterrupt();
});
然後當然在演算法的迴圈裡面改用 check_interrupt_sync()
去檢查中斷,且編譯時不用再加上 -sASYNCIFY=1
。這些都設置好了之後,從 UI 端我們只要執行 interruptBuffer[0] = 1;
這一行指令,就可以瞬間觸發 WASM 內的中斷判定了。
複雜資料傳遞
複雜的演算法常常會涉及到結構較為複雜的資料作為輸入和輸出,因此無法用的型別單純的函數參數與傳回值來表示。關於輸出的部份,其實要處理任意程度複雜的資料都不會太困難:例如我們可以將結果透過 stdout 來輸出成 JSON 字串的格式,然後我們在模組初始化時覆寫的 print()
方法可以用來捕捉這些輸出,最後再用 JavaScript 內建的 JSON.parse()
方法將輸出還原成結構化資料即可。在 ReferenceFinder 當中,我就是用這種方法來處理程式的輸出的。
但是資料的輸入就沒有這麼簡單的解法了,因為 C/C++ 的標準庫裡面並沒有內建 JSON 格式的處理能力。此時我至少可以想到兩種解法來處理「把複雜的資料從 JS 端傳給 C++」的方法。
解法一:資料序列化
第一個方法就是設法把我們的資料完全序列化成數值的陣列。簡單來說就是,JS 這邊會根據一個固定的順序把資料結構中的值一個一個放進陣列之中,把這個陣列傳遞給 WASM,然後 C++ 這邊再用同樣的順序把陣列還原成結構化的資料(例如 struct 或 class 的組合)。其中,當 JS 遇到一個陣列的時候,標準的序列化方法就是先輸出陣列的長度 n
、然後再把陣列內的資料格式反覆輸出 n
次,而 C++ 這邊則用同樣的邏輯去讀取它們。
舉個具體的例子,假設我的資料長這樣:
const data = {
"a": 0.1,
"b": 200,
"c": [
{ "x": 1, "y": 2 },
{ "x": 0, "y": 20 },
{ "x": 5, "y": -1 }
],
"d": 50
}
那麼序列化的結果大概就會是:
0.1, 200, 3, 1, 2, 0, 20, 5, -1, 50
其中那個「3」就是 c
欄位的陣列之長度。當 C++ 這邊看到這個 3 的時候,它就知道接下來它即將讀取三個元素、而且一開始就約定好每個元素會有兩個欄位,所以總共會讀取六個值,然後再來的那個才是欄位 d
,不會有所混淆。如果資料裡面有字串,也可以用類似的手法:先輸出字串的長度,然後逐一把字元的代碼輸出就行了。
那麼,在序列化成數值陣列之後,要怎麼傳遞給 C++ 呢?其中一種方法,是用跟上一篇當中一樣的 put()
和 get()
的方法把數值逐一傳遞過去;這當然完全可行、而且如果略加修改的話也可以省去掉 Asyncify 的使用、變成直接同步地讀取,但是無論同步與否,都還是會大量地往返在 JS 跟 C++ 的程式碼之間,感覺上並不是很清爽。
第二種方法,是我們可以在 JS 這邊把序列化的資料一口氣寫進 Emscripten 虛擬檔案系統當中的一個檔案裡,然後在 C++ 這邊讀取檔案、一次把全部的資料讀出。這省去了往返,但是 Emscripten 的虛擬檔案系統如果純粹就只是拿來做這件事的話,又感覺頗殺雞用牛刀的;畢竟如果在編譯的時候加上 -sFILESYSTEM=0
旗標來去掉虛擬檔案系統,那麼最終產出的 JS 檔案大概可以差個 50KB 左右,這其實省得不算小。
第三種方法則是直接把資料寫入 WASM 的記憶體當中、然後單純把一個指標傳給 C++。這個是我在 BPS 裡面實際用了好一陣子的作法。要使用這個作法,首先我們必須在編譯時加上 -sEXPORTED_FUNCTIONS=_malloc
旗標,然後寫入記憶體的方法如下:
/** 把傳入的陣列寫到 WASM 的記憶體裡面,並且傳回寫入位置的指標。 */
function createDoubleArrayPointer(jsArray: number[]): number {
// 計算這個陣列總共需要幾個位元組
const arrayLength = jsArray.length;
const arrayBytes = arrayLength * Float64Array.BYTES_PER_ELEMENT;
// 呼叫 malloc 來配置 WASM 的記憶體空間,取得指標位置
const arrayPtr = this.instance._malloc(arrayBytes);
// 把陣列寫進去
this.instance.HEAPF64.set(jsArray, arrayPtr / Float64Array.BYTES_PER_ELEMENT);
// 把位置回傳
return arrayPtr;
}
然後在 C++ 這邊,把函數的參數宣告成 double *ptr
型態就可以接收這個指標、並且逐一用 *(ptr++)
來逐一把序列化的數值讀取出來了。
整體而言,序列化作法的優點在於不需要引入額外的框架來處理資料、可以省下一些打包大小,而且效能會比較高一點點,但是相對地、它的缺點則是不容易維護,因為它需要 JS 這邊寫入與 C++ 這邊讀取的順序完全一樣才行;未來如果我需要調整資料結構的規格,要確保兩邊的邏輯完全一致會是比較麻煩的,而且寫出來的程式碼之可閱讀性也很差。
解法二:Embind
而長遠來說,我會比較推薦的作法是使用 Emscripten 提供的一套框架 Embind。使用它會稍微增加一些打包後的大小(但只有幾 KB 而已)、且理論上會有一些效能的 overhead(但實務上不會感覺到差異,畢竟跑演算法的部份的耗時跟這個比起來當然差太多了),但換來的好處是好維護好閱讀很多倍的程式碼,而且不用再自己刻類似上面那些低階的部份。
要使用 Embind,必須在編譯時加入 -lembind
旗標。然後我們可以用像這樣的方法來宣告函數:
#include <emscripten.h>
#include <emscripten/bind.h> // 要多加這一個
// data 的資料格式參見上面的範例
void my_func(const emscripten::val &data) {
// 取得資料中指定名稱的欄位、並且讀出 double 型態的值
const double a = data["a"].as<double>();
// 讀取陣列的長度
const int l = data["c"]["length"].as<unsigned>();
for(int i = 0; i < l; i++) {
// 取得陣列中的元素
const auto pt = data["c"][i];
// 讀取元素的欄位
const int x = pt["x"].as<int>();
const int y = pt["y"].as<int>();
}
}
// 宣告 Embind 的區塊;括號裡面是識別用的模組名稱,隨便取個名字沒差
EMSCRIPTEN_BINDINGS(main) {
// 宣告方法,並指向上面的函數
// 如前述,用這種方法宣告之後就會兼具 EMSCRIPTEN_KEEPALIVE 的效果
emscripten::function("myFunc", &my_func);
}
如上面的例子所示,這個 emscripten::val
類別就像是 JS 物件的一個 proxy 物件那樣 5,操作起來很直觀的,直接用索引存取子去抓欄位、然後最後再用 .as<T>()
方法把值讀出來就好了。有了這樣的宣告之後,在 JS 這邊我們就可以非常簡單地把資料直接當參數傳過去:
// 函數會變成模組下的方法,名稱就是如我們宣告的那樣、沒有「_」字首
module.myFunc(data); // 就這麼簡單,資料直接傳!
今天就分享到這邊,改天有機會再來分享我另一個用到 Emscripten 的專案中的心得。
-
除非我們編譯時設置了
-sINVOKE_RUN=0
旗標、或是在模組原型物件裡面設置了noInitialRun: true
選項,如此一來main()
就不會被自動執行,而是要我們自己手動呼叫module.callMain()
方法才會執行。這個作法有一個用途是,可以用callMain(["param1", "param2", ...])
這樣的寫法傳入命令列參數到 C/C++ 的 CLI 程式之中。 ↩ -
當然,SharedArrayBuffer 還有一個遠遠比這個更強的應用:可以利用它能夠被不同 worker 共享的這個特性,達到 WASM 的多執行緒運算。這個功能 Emscripten 也有內建支援,不過我目前還沒有自己寫過就是了。 ↩
-
除了 SharedArrayBuffer 之外,
Performance.now()
方法也會受到影響:在沒有跨來源隔離的情況下,它的回傳值的精密度只有到 100ms 之內,但是啟用跨來源隔離的話,它可以精確到 5ms 之內。 ↩ -
事實上它也真的就是一個 proxy 沒錯。當我們對它進行操作的時候,它都會回到 JS 這邊來進行對應的操作然後再把結果回傳。這當然有一些 overhead,但如前述,其實可以忽略。 ↩
留言