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

这篇具有很好参考价值的文章主要介绍了【FFI】N-API的JS堆对象生命周期管理。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

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

N-APINode API的简写,同时也是nodejsJS VM(链)接入原生模块.node文件的应用程序二进制接口(i.e. ABI)。借助N-API引入的抽象隔离,升级nodejs运行时(虚拟机)

  • 【编译】不要求对原生扩展模块重新编译 — 为nodejs的不同版本分别准备不同的原生模块build真的好麻烦。

  • 【运行】不导致原生模块程序崩溃 — 精读每一版changelogs清单和微调原生模块源码更耗时费力。

N-API开放接口在nodejs 10+后才逐步稳定,和成为nodejs c-addon的主流编程标准。

不久前,我有机会在工程实践中独立完成“给node-webkit容器编写原生扩展模块的”程序开发任务。虽然扩展模块自身的业务处理逻辑很简单 — 馁馁的“胶水”代码,但其涉及到了跨越多个FFI接口调用的JS对象缓存处理。初版程序缓存不住JS堆内存中的变量值,因为JS VMGC总是在FFI接口调用的间隙回收由原生模块缓存的JS对象和导致程序崩溃。由此,我特意“死磕”C/C++ addons with Node-API厂方文档,在解决工程难题的同时汇总实践收获写下此文。

文章以名词解释统一术语理解开篇,以对比不同版本ABI标准引题,以技术细节展开讨论为依据,最后向读者图文并茂地描述我个人创新的实践方案。

名词解释

nodejs c-addon

nodejs原生扩展模块。所谓“原生”是相对JS模块而言的。它必须由【系统编程语言C / Cpp / Rust】编写,并经由nodejs开放接口N-API

  1. 接入nodejsJS VM,并

  2. nodejs交换数据·互操作。

为了文字简练,下文也将其记作为addon

nodejs c-addonCommonjs Module在科技树上处于相同的生态位,和对“上游”调用端的JS业务代码呈现一致的调用方式。

JS堆对象

它既包括由JS程序自身构造的对象实例,也包含由系统程序从addon内调用N-API接口(比如,napi_create_object())实例化的JS对象。它们都

  1. 被保存在JS VM内存中,和

  2. Rust内存中的napi_value可修改原始指针引用。

N-API引用计数

它是指向JS堆对象的“FFI引用计数”智能指针(后文有图,应该会更直观些)。其

  1. 被保存于JS VM内存中,和

  2. Rust内存中的napi_ref可修改原始指针引用。即,addonRust程序拿到的是指向了“智能指针”的“指针”。

  3. 被用于阻止JS VMGC回收正活跃于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应用场景多(包括但不限于:网页、nodejswasm-runtime独立虚拟机),社区关注度高,wasm-bindgen工具链迭代速度快,所以,wasm <-> js垫片程序就“厚”。JS堆对象向Rust的“投影”就更像【智能指针】,而不是“裸奔的”原始指针。WebAssembly工作组甚至规划将垫片程序逐步“固化”至wasm-runtime内(比如,TC39弱引用提案与引用类型提案等)以完备核心功能。工作量到位自然对接平滑!这不是黑魔法,而是真金白银的血汗努力。

相反,nodejs c-addon的应用场景就要少得多了。所以,技术社区鲜有热情面向N-API开放接口编写功能丰富的addon <-> js垫片程序。于是,@Rustacean 不得不直面

  1. “裸奔的”原始指针

  2. 简陋的Rust Bindings — 与C头文件概念对等的Rust语言项

  3. “安慰剂”式的编程工具。因为缺乏了js垫片程序的协同呼应,几个Rust宏也只是杯水车薪,能“糖”的内容很少。

  4. 转移更多精力从【业务逻辑实现】至【FFI编程】,并与各种FFI技术细节做“斗争”。赶快补课内存布局理论知识去吧!

具体地讲,在Rust - WASM程序上下文中,披上了“智能指针”马甲的JS堆对象几乎完全“锈化”了。@Rustacean 可忽视JS VM垃圾收集器的干扰和:

  1. static全局缓存JS堆对象。而不必担心活跃于addonJS堆对象会被JS VMGC回收。

  • 相对FFI函数的单次调用执行周期,延长JS堆对象的生命周期。

{ .. }块作用域限定JS堆对象,按需释放不再访问的变量值,提高内存利用效率。就有局部变量的函数而言,这可明显地降低JS堆内存占用的瞬时峰值。

  • 相对FFI函数的单次调用执行周期,缩短JS堆对象的生命周期

另一方面,N-API没有功能面面俱到的垫片程序。所以,@Rustacean 做不到仅凭Rust基本语法项就对FFI另一端的JS堆对象执行【全局缓存】或【块作用域】按需回收的程序处理。甚至(重点来了),即便JS端代码刻意保留了已FFI导出堆对象的引用,addon端(栈内存)所持有的原始指针依旧会,在FFI函数执行之后,丢失其原本指向的值和成为“野”指针。我怀疑JS VM就算没有回收也至少挪动了被导出JS堆对象的内存位置。由此,@Rustacean 需要在addon业务代码中额外实现部分本该由垫片程序完成的“公共服务”功能,包括但不限于:

  1. 徒手维护N-API引用计数智能指针,以“锁住”JS堆对象不被JS VMGC回收 — 延长JS堆对象的生命周期。

  2. 调用N-API程序接口构造可层叠嵌套的作用域【块】 — 缩短JS堆对象的生命周期。

这的确是一次接触底层“自己动手丰衣足食”的机会,但绝对不是什么令人愉快的开发体验。千言万语汇聚一张图(左侧WASM,右侧nodejs c-addon)促成读者思绪的豁然开朗:

【FFI】N-API的JS堆对象生命周期管理,javascript,开发语言,ecmascript,前端

N-API JS堆对象生命周期管理的技术细节

addonJS堆对象生命周期的管理分为如下三种情况(看图吧,一图抵千词):

【FFI】N-API的JS堆对象生命周期管理,javascript,开发语言,ecmascript,前端

由上图可见,真实数据被保存于JS端(堆)内存中。Rust端(栈)内存仅持有随时可能失效的原始指针。所以,@Rustacean 需要调用特定的N-API接口,远程操控JS堆对象的活跃周期。但是,N-API接口并不易用。这表现为...

N-API引用计数智能指针不智能

  1. 没有RAII Guard对活跃引用数量的自动跟踪。@Rustacean 还需书面编写N-API接口调用和人工增减引用个数跟踪引用复本数量 — 这是传统的缺陷产出“大户”。

  2. 引用数量意味着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();
    }
  3. 只有四类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】,

  • 相同点:块内声明的变量可从块外引用和访问

  • 不同点:【可逃逸作用域】有且只有一个块内声明的变量可从块外被访问。否则,程序崩溃。

所以,可逃逸作用域是“多入单出”的面向实用有限放开。再看图吧,一图抵千词!

【FFI】N-API的JS堆对象生命周期管理,javascript,开发语言,ecmascript,前端

在作用域层叠嵌套的场景下,这绝对是“盛产”缺陷的泥沼。@Rustacean 需要从程序设计之初就努力避免从Rust端远程管理JS变量的作用域。最好从产品架构上,多用addon构建【业务组件】,少封装【功能模块】,从根本上规避Rust <-> JS复杂互操作出现

智能化N-API引用计数 — “二段式”引用计数优化法

相比于最低也需要【过程宏】作为抽象工具才能描述清楚的JS堆对象作用域N-API引用计数智能化改造还是有捷径可走的。

简单地讲,将对引用复本数量变化的跟踪任务委托给遵循RAII with Guard设计模式的智能指针std::rc::Rc<napi_ref>处理。然后,addon业务实现代码仅需负责

  1. 【始】调用napi_create_reference() 接口,构造一个单复本引用计数指针实例,锁住JS堆对象不被GC回收。

  2. 【末】调用napi_reference_unref()napi_delete_reference()接口,清空引用复本与析构唯一的引用计数指针实例,解锁GC回收JS堆对象。

接着看图,依旧一图抵千词!

【FFI】N-API的JS堆对象生命周期管理,javascript,开发语言,ecmascript,前端

于是,整个设计方案的“难点”就聚焦于:

  1. 监听智能指针std::rc::Rc<napi_ref>的引用复本清空事件,并

  2. 在事件处理函数内,调用napi_reference_unref()napi_delete_reference()接口通知VM GC回收JS堆对象。

难点不难,因为Newtypes设计模式允许 @Rustacean

  1. std::rc::Rc<napi_ref>AOP编程。以

  2. “拦截+重写”std::rc::Rc<napi_ref>的析构函数<Rc as Drop>::drop(&mut self)。于是,

  3. 在每个引用复本的析构处理后,都重新统计剩余引用复本的数量。最后,

  4. 没有剩余引用复本了,就立即调用N-API接口napi_reference_unref()napi_delete_reference()

文章写得再自恰也不如呈现一段既注释丰富又可独立运行的参考实现[例程]来得清晰明白。整个例程由四个部分组成:

  1. 模块nj_sys模拟nj_sys crate的部分导出项,因为nj_sys crate并没有入选playground.orgtop 100热门依赖包榜单。

  2. 模块napi_rc包含了对智能指针std::rc::Rc<napi_ref>AOP封装。

  3. 函数napi_export_method()模仿nodejs c-addonFFI导出函数。

  4. 入口函数main()模仿JS程序调用Rust-FFI函数napi_export_method()

“二段式”引用计数优化方案的裨益

  1. 【程序性能】将FFI调用次数减少至一个常量3

  2. 【代码健壮性】将引用复本的数量跟踪任务从易错的人工完成转为机器自动完成。addon业务代码仅需关注引用复本的个数归零事件。

结束语

关于nodejs c-addon技术方向,我这次仅准备了上述偏【编程】内容与大家分享。其实,交叉编译与动态库链接也是一项可以聊出些许深度的话题。比如,如何做到“从一个工程,一个分支,一套Rust程序同时编译出三版.node链接库文件,以分别适用于nodejs / nwjs / electron三款应用程序容器”的呢?。哎!无处不是“黑科技” — 从条件编译,至编译时修改链接目标。在我输出下一篇相关主题的文章前,感兴趣的读者不防率先品鉴我的另一个github工程request-window-attention寻找答案,和给我的工程点个star

创作不易,值得(文章)点赞,(github工程)点star,和(两者都)转发。文章来源地址https://www.toymoban.com/news/detail-764468.html

到了这里,关于【FFI】N-API的JS堆对象生命周期管理的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • C++面试:对象生命周期 & 垃圾回收

            在C++中,理解对象的生命周期和垃圾回收是非常重要的,尤其是在准备面试腾讯等大型科技公司的C++开发岗位时。这些概念涉及内存管理,是C++编程中的核心部分。 目录 对象生命周期 垃圾回收 手动内存管理 智能指针 std::unique_ptr 示例 std::shared_ptr 示例 资源获取

    2024年01月23日
    浏览(26)
  • 【Java基础】Java对象的生命周期

    一个类通过编译器将一个Java文件编译为Class字节码文件,然后通过JVM中的解释器编译成不同操作系统的机器码。虽然操作系统不同,但是基于解释器的虚拟机是相同的。java类的生命周期就是指一个class文件加载到类文件注销整个过程。 一个java类的完整的生命周期会经历加载

    2024年02月12日
    浏览(30)
  • Vue.js 生命周期详解

    Vue.js 是一款流行的 JavaScript 框架,它采用了组件化的开发方式,使得前端开发更加简单和高效。在 Vue.js 的开发过程中,了解和理解 Vue 的生命周期非常重要。本文将详细介绍 Vue 生命周期的四个阶段:创建、挂载、更新和销毁。 在创建阶段,Vue 实例被创建并初始化。这个阶

    2024年02月13日
    浏览(29)
  • JVM-JVM中对象的生命周期

    申明:文章内容是本人学习极客时间课程所写,文字和图片基本来源于课程资料,在某些地方会插入一点自己的理解,未用于商业用途,侵删。 原资料地址:课程资料 对象的创建 常量池检查 :检查new指令是否能在常量池中定位到这个类的符号引用,检查类之前是否被加载过

    2024年02月20日
    浏览(34)
  • 如何控制Spring工厂创建对象的次数?详解Spring对象的生命周期!

    😉😉 学习交流群: ✅✅1:这是孙哥suns给大家的福利! ✨✨2:我们 免费分享 Netty、Dubbo、k8s、Mybatis、Spring...应用和源码级别的视频资料 🥭🥭3:QQ群:583783824   📚📚  工作微信:BigTreeJava 拉你进微信群,免费领取! 🍎🍎4:本文章内容出自上述:Spring应用课程!💞💞

    2024年02月05日
    浏览(25)
  • React 中的常见 API 和生命周期函数

    useState useEffect useRef dangerouslySetInnerHTML 生命周期函数 constructor componentDidMount static getDerivedStateFromProps shouldComponentUpdate componentDidUpdate componentWillUnmount useState 是 React 的一个 Hook,用于在函数组件中添加状态。通过 useState,我们可以在函数组件中创建和管理状态变量,每当状态变量

    2024年02月15日
    浏览(30)
  • Vue.js生命周期及其应用示例

    Vue.js是一种流行的前端JavaScript框架,可以让开发人员轻松构建动态用户界面。Vue.js的一个关键特性是其生命周期系统,它允许开发人员在组件的不同阶段执行代码。在本文中,我们将探讨Vue.js生命周期的不同阶段以及如何在这些阶段执行代码,并举例说明。 Vue.js生命周期分

    2024年02月08日
    浏览(32)
  • Listener监听器----HttpServletRequest对象的生命周期监听器

    一、HttpServletRequest对象的生命周期监听器         ServletRequestListener接口定义了ServletRequest(是HttpServletRequest接口的父接口类型)对象生命周期的监听行为。 void requestInitialized(ServletRequestEvent sre)         HttpServletRequest对象创建后会触发该监听器方法,并将已创建HttpServletR

    2024年01月23日
    浏览(47)
  • Bean的生命周期揭秘:从诞生到消亡,一个对象的壮丽演绎!

    大家好,我是你们的小米。今天我要给大家揭秘一下Java开发中重要的概念——Spring Bean的生命周期。作为Java开发者,无论是在面试还是实际工作中,了解Bean的生命周期都是必备的知识点。让我们一起来深入了解吧!     在开始讲解生命周期之前,我们先来了解一下Spring Be

    2024年02月05日
    浏览(78)
  • 深入探究Vue.js生命周期及其应用场景

    当谈到Vue.js的生命周期时,我们指的是组件在创建、更新和销毁过程中发生的一系列事件。了解Vue的生命周期对于开发人员来说是至关重要的,因为它们提供了一个机会来执行特定任务,并在不同的阶段处理组件。 Vue的生命周期可以分为八个不同的阶段:创建前、创建后、挂

    2024年02月06日
    浏览(40)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包