N-API的JS堆對象生命周期管理
N-API是Node API的簡寫,同時也是nodejs的JS VM(鏈)接入原生模塊.node文件的應用程序二進制接口(i.e. ABI)。借助N-API引入的抽象隔離,升級nodejs運行時(虛擬機)
【編譯】不要求對原生擴展模塊重新編譯— 為nodejs的不同版本分別準備不同的原生模塊build真的好麻煩。
【運行】不導致原生模塊程序崩潰— 精讀每一版changelogs清單和微調原生模塊源碼更耗時費力。
N-API開放接口在nodejs 10+后才逐步穩定,和成為nodejs c-addon的主流編程標準。 不久前,我有機會在工程實踐中獨立完成“給node-webkit容器編寫原生擴展模塊的”程序開發任務。雖然擴展模塊自身的業務處理邏輯很簡單 — 餒餒的“膠水”代碼,但其涉及到了跨越多個FFI接口調用的JS對象緩存處理。初版程序緩存不住JS堆內存中的變量值,因為JS VM的GC總是在FFI接口調用的間隙回收由原生模塊緩存的JS對象和導致程序崩潰。由此,我特意“死磕”C/C++ addons with Node-API廠方文檔,在解決工程難題的同時匯總實踐收獲寫下此文。 文章以名詞解釋統一術語理解開篇,以對比不同版本ABI標準引題,以技術細節展開討論為依據,最后向讀者圖文并茂地描述我個人創新的實踐方案。
名詞解釋
nodejs c-addon
nodejs原生擴展模塊。所謂“原生”是相對JS模塊而言的。它必須由【系統編程語言C / Cpp / Rust】編寫,并經由nodejs開放接口N-API,
接入nodejs的JS VM,并
與nodejs交換數據·互操作。
為了文字簡練,下文也將其記作為addon。 nodejs c-addon與Commonjs Module在科技樹上處于相同的生態位,和對“上游”調用端的JS業務代碼呈現一致的調用方式。
JS堆對象
它既包括由JS程序自身構造的對象實例,也包含由系統程序從addon內調用N-API接口(比如,napi_create_object())實例化的JS對象。它們都
被保存在JS VM的堆內存中,和
被Rust棧內存中的napi_value可修改原始指針引用。
N-API引用計數
它是指向JS堆對象的“FFI引用計數”智能指針(后文有圖,應該會更直觀些)。其
被保存于JS VM的堆內存中,和
被Rust棧內存中的napi_ref可修改原始指針引用。即,addon端Rust程序拿到的是指向了“智能指針”的“指針”。
被用于阻止JS VM的GC回收正活躍于addon端的JS堆對象。這就賦予了 @Rustacean 從JS VM外部干預JS對象生命周期的能力。React Native可都做不到這一點。
WASM墊片程序
它既包括由wasm-bindgen-cli生成的JS墊片程序文件,也包含由wasm-bindgen crate導出的Rust開發框架。正是js <-> Rust兩端墊片程序的協同配合,JS堆對象才幾乎被“投影為”Rust所有權(棧)變量。比如,JS堆對象的wasm_bindgen::JsValue(似智能指針)結構體就比nj_sys::napi_value可修改原始指針更能發揮Rust類型系統與Borrow / Drop Checker對程序正確性的保障力。沒有“黑魔法”,滿眼都是對墊片程序開發迭代的工作量。
WASM vs. N-API堆對象生命周期管理策略
簡單地講,生命周期策略的差異取決于【墊片程序】的“薄/厚”。因為WASM應用場景多(包括但不限于:網頁、nodejs,wasm-runtime獨立虛擬機),社區關注度高,wasm-bindgen工具鏈迭代速度快,所以,wasm <-> js墊片程序就“厚”。JS堆對象向Rust的“投影”就更像【智能指針】,而不是“裸奔的”原始指針。WebAssembly工作組甚至規劃將墊片程序逐步“固化”至wasm-runtime內(比如,TC39弱引用提案與引用類型提案等)以完備核心功能。工作量到位自然對接平滑!這不是黑魔法,而是真金白銀的血汗努力。 相反,nodejs c-addon的應用場景就要少得多了。所以,技術社區鮮有熱情面向N-API開放接口編寫功能豐富的addon <-> js墊片程序。于是,@Rustacean 不得不直面
“裸奔的”原始指針
簡陋的Rust Bindings— 與C頭文件概念對等的Rust語言項
“安慰劑”式的宏編程工具。因為缺乏了js墊片程序的協同呼應,幾個Rust宏也只是杯水車薪,能“糖”的內容很少。
轉移更多精力從【業務邏輯實現】至【FFI編程】,并與各種FFI技術細節做“斗爭”。趕快補課內存布局理論知識去吧!
具體地講,在Rust - WASM程序上下文中,披上了“智能指針”馬甲的JS堆對象幾乎完全“銹化”了。@Rustacean 可忽視JS VM垃圾收集器的干擾和:
static全局緩存JS堆對象。而不必擔心僅活躍于addon的JS堆對象會被JS VM的GC回收。
相對FFI函數的單次調用執行周期,延長JS堆對象的生命周期。
{ .. }塊作用域限定JS堆對象,按需釋放不再訪問的變量值,提高內存利用效率。就有多局部變量的大函數而言,這可明顯地降低JS堆內存占用的瞬時峰值。
相對FFI函數的單次調用執行周期,縮短JS堆對象的生命周期
另一方面,N-API沒有功能面面俱到的墊片程序。所以,@Rustacean 做不到僅憑Rust基本語法項就對FFI另一端的JS堆對象執行【全局緩存】或【塊作用域】按需回收的程序處理。甚至(重點來了),即便JS端代碼刻意保留了已FFI導出堆對象的引用,addon端(棧內存)所持有的原始指針依舊會,在FFI函數執行之后,丟失其原本指向的值和成為“野”指針。我懷疑JS VM就算沒有回收也至少挪動了被導出JS堆對象的內存位置。由此,@Rustacean 需要在addon業務代碼中額外實現部分本該由墊片程序完成的“公共服務”功能,包括但不限于:
徒手維護N-API引用計數智能指針,以“鎖住”JS堆對象不被JS VM的GC回收 —延長JS堆對象的生命周期。
調用N-API程序接口構造可層疊嵌套的作用域【塊】 —縮短JS堆對象的生命周期。
這的確是一次接觸底層“自己動手豐衣足食”的機會,但絕對不是什么令人愉快的開發體驗。千言萬語匯聚一張圖(左側WASM,右側nodejs c-addon)促成讀者思緒的豁然開朗:
N-API JS堆對象生命周期管理的技術細節
addon對JS堆對象生命周期的管理分為如下三種情況(看圖吧,一圖抵千詞):
由上圖可見,真實數據被保存于JS端(堆)內存中。Rust端(棧)內存僅持有隨時可能失效的原始指針。所以,@Rustacean 需要調用特定的N-API接口,遠程操控JS堆對象的活躍周期。但是,N-API接口并不易用。這表現為...
N-API引用計數智能指針不智能
沒有RAII Guard對活躍引用數量的自動跟蹤。@Rustacean 還需書面編寫N-API接口調用和人工增減引用個數跟蹤引用復本數量 — 這是傳統的缺陷產出“大戶”。
零引用數量不意味著GC回收。@Rustacean 還需顯式地析構掉N-API【引用計數】智能指針實例,才能促使被“持久化于內存”的JS堆對象接受GC回收。否則,內存泄漏!具體作法請參見如下偽碼
use ::{napi_delete_reference, napi_reference_unref}; use ::napi_call_result; let result = Box::into_raw(Box::new(u32::MAX)); // 1. 將引用計數值減一 napi_call_result!(napi_reference_unref(
只有四類JS堆對象支持N-API引用計數。它們分別是
napi_object—ECMAScript規范中的Object
napi_function—ECMAScript規范中的Function
napi_symbol—ECMAScript規范中的Symbol
napi_external— 類似于ECMAScript中的Blob,專門引用進程外的某種“黑盒opaque”資源。
若多個N-API引用計數指針實例(注:不是引用復本)都指向同一個JS堆對象,那么只有當全部N-API引用計數指針實例都被napi_delete_reference()處理后,“持久化于內存”的JS堆對象才被允許GC回收。
可逃逸作用域與作用域提升不實用
在上圖中的(普通)作用域napi_handle_scope禁止其內部的JS堆對象溢出作用域,和向外傳值。即,普通作用域是“多入無出”的。 【可逃逸作用域napi_escapable_handle_scope】有限松綁了這條限制。它允許作用域像函數一樣向外輸出一個且僅一個值,而輸出形式不是Rust塊表達式【返回值】,而是JS堆對象【作用域·提升handle promoting】。類比JS動態語言的【變量提升variable hoisting】,
相同點:塊內聲明的變量可從塊外引用和訪問
不同點:【可逃逸作用域】有且只有一個塊內聲明的變量可從塊外被訪問。否則,程序崩潰。
所以,可逃逸作用域是“多入單出”的面向實用有限放開。再看圖吧,一圖抵千詞!
在作用域層疊嵌套的場景下,這絕對是“盛產”缺陷的泥沼。@Rustacean 需要從程序設計之初就努力避免從Rust端遠程管理JS變量的作用域。最好從產品架構上,多用addon構建【業務組件】,少封裝【功能模塊】,從根本上規避Rust <-> JS復雜互操作出現。
智能化N-API引用計數 — “二段式”引用計數優化法
相比于最低也需要【過程宏】作為抽象工具才能描述清楚的JS堆對象作用域,N-API引用計數智能化改造還是有捷徑可走的。 簡單地講,將對引用復本數量變化的跟蹤任務委托給遵循RAII with Guard設計模式的智能指針std::Rc
【始】調用napi_create_reference()接口,構造一個單復本引用計數指針實例,鎖住JS堆對象不被GC回收。
【末】調用napi_reference_unref()與napi_delete_reference()接口,清空引用復本與析構唯一的引用計數指針實例,解鎖GC回收JS堆對象。
接著看圖,依舊一圖抵千詞!
于是,整個設計方案的“難點”就聚焦于:
監聽智能指針std::Rc
在事件處理函數內,調用napi_reference_unref()與napi_delete_reference()接口通知VM GC回收JS堆對象。
難點不難,因為Newtypes設計模式允許 @Rustacean
對std::Rc
“攔截+重寫”std::Rc
在每個引用復本的析構處理后,都重新統計剩余引用復本的數量。最后,
若沒有剩余引用復本了,就立即調用N-API接口napi_reference_unref()與napi_delete_reference()。
文章寫得再自恰也不如呈現一段既注釋豐富又可獨立運行的參考實現[例程]來得清晰明白。整個例程由四個部分組成:
模塊nj_sys模擬nj_sys crate的部分導出項,因為nj_sys crate并沒有入選playground.org的top 100熱門依賴包榜單。
模塊napi_rc包含了對智能指針std::Rc
函數napi_export_method()模仿nodejs c-addon的FFI導出函數。
入口函數main()模仿JS程序調用Rust-FFI函數napi_export_method()。
“二段式”引用計數優化方案的裨益
【程序性能】將FFI調用次數減少至一個常量3。
【代碼健壯性】將引用復本的數量跟蹤任務從易錯的人工完成轉為機器自動完成。addon業務代碼僅需關注引用復本的個數歸零事件。
結束語
關于nodejs c-addon技術方向,我這次僅準備了上述偏【編程】內容與大家分享。其實,交叉編譯與動態庫鏈接也是一項可以聊出些許深度的話題。比如,如何做到“從一個工程,一個分支,一套Rust程序同時編譯出三版.node鏈接庫文件,以分別適用于nodejs / nwjs / electron三款應用程序容器”的呢?。哎!無處不是“黑科技” — 從條件編譯,至編譯時修改鏈接目標。在我輸出下一篇相關主題的文章前,感興趣的讀者不防率先品鑒我的另一個github工程request-window-attention尋找答案,和給我的工程點個star! 創作不易,值得(文章)點贊,(github工程)點star,和(兩者都)轉發。
審核編輯:湯梓紅
-
接口
+關注
關注
33文章
8496瀏覽量
150834 -
API
+關注
關注
2文章
1484瀏覽量
61810 -
編程
+關注
關注
88文章
3591瀏覽量
93592 -
應用程序
+關注
關注
37文章
3240瀏覽量
57600
原文標題:堆對象生命周期管理
文章出處:【微信號:Rust語言中文社區,微信公眾號:Rust語言中文社區】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論