iT邦幫忙

2025 iThome 鐵人賽

DAY 17
0
Rust

把前端加速到天花板:Rust+WASM 即插即用外掛系列 第 17

Day 17|把「住在你家」的位元組管理好

  • 分享至 

  • xImage
  •  

到這裡,我們已經把資料流關在 Wasm,互動操作幾乎不再跨界拷貝。接下來的現實問題是:這些像素會住多久、住在哪裡、什麼時候要請它們搬家。如果不把「生命週期」講清楚,你的 demo 會跑得很快,但用戶一換大圖、切十次濾鏡或開新分頁,就開始看到莫名其妙的記憶體暴衝與畫面錯亂。

這一篇談的不是演算法,而是空間治理:Wasm 線性記憶體的成長、常駐緩衝的重用/釋放、Worker 的生死、以及 JS 視圖的壽命管理。

Wasm 線性記憶體

Wasm 的 memory.grow() 只能成長,無法縮小;更重要的是,成長後底層 ArrayBuffer 會被替換。任何之前透過

new Uint8Array(memory.buffer, ptr, len)

建立的視圖,都會指向舊房子。你貼圖前若還在用舊的 view,就會貼出錯亂畫面或拋錯。

準則

  • 每次要把結果貼回畫布之前,重新讀一次 memory.buffer 再建視圖。不要把 viewmemory.buffer 存在模組全域。

  • 對外 API 層面,直接提供不暴露視圖的貼圖函式,由套件內部重抓 buffer

    export function blitToImageData(imgData: ImageData) {
      const ptr = buffer_ptr(), len = buffer_len()
      const view = new Uint8Array((memory as WebAssembly.Memory).buffer, ptr, len)
      imgData.data.set(view)
    }
    

    使用者只要呼叫 blitToImageData(imgData),不用管視圖是否失效。

確保容量、避免碎片

我們用 BUF/SCR 兩塊 thread-local Vec<u8> 當乒乓緩衝。策略是「只長不縮」,以避免頻繁配置。這帶來兩個問題:

  1. 換小圖後,緩衝仍然很大(浪費空間)。
  2. 長期工作階段,使用者可能從 2MP 切到 48MP,再切回 2MP;高峰容量會留著。

做法:在對外 API 提供兩個維運入口:

#[wasm_bindgen]
pub fn ensure_buffer(capacity: usize) { /* 已有 */ }

#[wasm_bindgen]
pub fn shrink_buffers() {                 // 主動縮到「目前最後一次使用大小」
    BUF.with(|b| b.borrow_mut().shrink_to_fit());
    SCR.with(|s| s.borrow_mut().shrink_to_fit());
}

#[wasm_bindgen]
pub fn buffers_capacity() -> usize {
    BUF.with(|b| b.borrow().len())
}
  • 使用者切換到「小圖批次模式」:你可以在開始批次前 shrink_buffers(),把高峰記憶體還給系統。
  • 儀表化:在 demo 面板顯示 buffers_capacity(),讓操作者知道現在吃了多少。

圖片切換與畫布改尺寸

畫布尺寸變了(或載入新圖)→ 應該做:

  1. ensure_buffer(w*h*4):容量夠再搬。
  2. load_pixels(bytes):把新圖灌進 Wasm。
  3. run_pipeline_inplace(w, h, /* 可給空陣列代表不做事 */):同步尺寸語境(有時你會在 in-place 裡快取寬高)。

不要保留任何上一張圖的 ImageData 視圖或 Uint8Array 指標;只保留數字(w/h)

一人一房 vs. 公用宿舍

Day 14/15 我們把任務丟進 Worker 里跑。生命週期該怎麼訂?

  • 短任務/偶爾用:每次開一個 Worker,用完 terminate()。優點是乾淨;缺點是初始化成本。
  • 長期互動(滑桿即時調整):常駐單一 Worker,重用 Wasm、重用緩衝,避免反覆下載與初始化。
  • 多張圖序列:仍用單 Worker,但在每張圖開始前先 ensure_buffer + load_pixels,中間只傳參數(Transferable 只在首次載入時用得到)。

傳輸資料記得用 TransferablepostMessage(data, [data.buffer])),讓來源端的 ArrayBuffer 變成 detached,真正做到了「搬家不是複製是過戶」。但注意:Canvas 的 ImageData.data 不是可轉移,載入時請用 createImageBitmap() + OffscreenCanvas 畫進 Worker 端的畫布,再 getImageData() 抓 bytes;或主緒先 getImageData(),複製一份普通 Uint8Array 再轉移。

JS 端

  1. 閉包捕捉

    你在事件處理器外層宣告 const big = new Uint8Array(w*h*4),結果每個 listener 都抓住它,換頁也沒釋放。

    解法:把大陣列的生命週期縮到函式內;或把它藏在 Wasm 裡(我們已經做了)。

  2. FinalizationRegistry 不是萬靈丹

    可以用它在 JS 物件 GC 時呼叫 shrink_buffers()dispose(),但時間點不可預期。關鍵路徑仍要顯式地在「離開此圖」時呼叫釋放/縮容。

設計一個「可預期」的銷毀點

給使用者一個明確的按鈕或程式 API 可以「收工」:

// JS wrapper(同步版本)
export function dispose(): void {
  // 可選:把 Wasm 端緩衝縮容
  shrink_buffers()
  // Worker 版:terminate()
  // UI 版:清掉畫布/事件
}

如果你有 Worker 版本,則提供 await worker.dispose()

  • 停止接收新訊息
  • 回應 pending request「任務取消」
  • terminate() Worker

把「收工」變成顯式動作,不要指望 GC 幫你決定何時該退房。


上一篇
Day 16|把 memory bound 的坑填起來
下一篇
Day 18|API 契約 v2:同步入口 vs. Worker 包裝,為什麼要「腳踏兩條船」
系列文
把前端加速到天花板:Rust+WASM 即插即用外掛18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言