N-API的JS堆对象生命周期管理

电子说

1.3w人已加入

描述

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( , , result // 引用计数减一之后的结果数值 )).unwrap(); let result = unsafe { Box::from_raw(result) }; // 2. 判断减一后的最新引用计数值是否已经归零。 if *result == 0 { // 当且仅当不再有任何 N-API 引用复本还指向该 JS 堆对象时, // 3. 显式地释放引用计数智能指针实例。 napi_call_result!(napi_delete_reference( // 这一步是必须的。要不然,内存就漏了! ,  )).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处理。然后,addon业务实现代码仅需负责

【始】调用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做AOP编程。以

“拦截+重写”std::Rc的析构函数::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的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,和(两者都)转发。  

  审核编辑:汤梓红

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分