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 ::nj_sys::{napi_delete_reference, napi_reference_unref}; use ::node_bindgen::core::napi_call_result; let result = Box::into_raw(Box::new(u32::MAX)); // 1. 将引用计数值减一 napi_call_result!(napi_reference_unref( <N-API 调用上下文>, <N-API 引用计数·智能指针>, result // 引用计数减一之后的结果数值 )).unwrap(); let result = unsafe { Box::from_raw(result) }; // 2. 判断减一后的最新引用计数值是否已经归零。 if *result == 0 { // 当且仅当不再有任何 N-API 引用复本还指向该 JS 堆对象时, // 3. 显式地释放引用计数智能指针实例。 napi_call_result!(napi_delete_reference( // 这一步是必须的。要不然,内存就漏了! <N-API 调用上下文>, <N-API 引用计数·智能指针> )).unwrap(); }
只有四类
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::Rc<napi_ref>
处理。然后,addon
业务实现代码仅需负责
【始】调用
napi_create_reference()
接口,构造一个单复本引用计数指针实例,锁住JS
堆对象不被GC
回收。【末】调用
napi_reference_unref()
与napi_delete_reference()
接口,清空引用复本与析构唯一的引用计数指针实例,解锁GC
回收JS
堆对象。
接着看图,依旧一图抵千词!
于是,整个设计方案的“难点”就聚焦于:
监听智能指针
std::rc::Rc<napi_ref>
的引用复本清空事件,并在事件处理函数内,调用
napi_reference_unref()
与napi_delete_reference()
接口通知VM GC
回收JS
堆对象。
难点不难,因为Newtypes设计模式允许 @Rustacean
对
std::rc::Rc<napi_ref>
做AOP
编程。以“拦截+重写”
std::rc::Rc<napi_ref>
的析构函数<Rc as Drop>::drop(&mut self)
。于是,在每个引用复本的析构处理后,都重新统计剩余引用复本的数量。最后,
若没有剩余引用复本了,就立即调用
N-API
接口napi_reference_unref()
与napi_delete_reference()
。
文章写得再自恰也不如呈现一段既注释丰富又可独立运行的参考实现[例程]来得清晰明白。整个例程由四个部分组成:
模块
nj_sys
模拟nj_sys crate的部分导出项,因为nj_sys crate
并没有入选playground.org
的top 100
热门依赖包榜单。模块
napi_rc
包含了对智能指针std::rc::Rc<napi_ref>
的AOP
封装。函数
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
,和(两者都)转发。