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

news2024/11/24 2:39:19

N-APIJS堆对象生命周期管理

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)促成读者思绪的豁然开朗:

6c4f3e2aeb5d533cff76cb1ad829c5d0.png

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

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

32922512076e2f7e9acc84f031f96ecd.png

由上图可见,真实数据被保存于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】,

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

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

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

2dd416a3a60b28508d9016169bb4aa62.png

在作用域层叠嵌套的场景下,这绝对是“盛产”缺陷的泥沼。@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堆对象。

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

b3ee7d565f1db7ce37578d2e49979273.png

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

  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,和(两者都)转发。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1343360.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

米表网PHP域名销售管理系统网站源码 自适应电脑+手机端

PHP域名销售管理系统网站源码 自适应电脑手机端 功能使用简单&#xff0c;不复杂&#xff0c;非常适合个人米表使用&#xff0c;带广告栏 源码下载&#xff1a;https://download.csdn.net/download/m0_66047725/88646799

Spring Boot整合GraphQL

RPC选型入门测试系列文章 GraphQL是一种用于API开发的查询语言和运行时环境。它由Facebook开发并于2015年开源。GraphQL的主要目标是提供一种更高效、灵活和易于使用的方式来获取和操作数据。与传统的RESTful API相比&#xff0c;GraphQL允许客户端精确地指定需要的数据&#…

C语言实例_stdlib.h库函数功能及其用法详解

一、前言 C语言作为一种高效、灵活的编程语言&#xff0c;标准库的使用对于开发人员来说是不可或缺的。其中&#xff0c;stdlib.h是C语言中一个重要的标准库头文件&#xff0c;提供了许多常用的函数和工具&#xff0c;以便开发人员能够更加便捷地进行内存管理、字符串处理、随…

《深入理解JAVA虚拟机笔记》运行时栈帧、方法分派、动态类型

运行时栈帧结构 Java 虚拟机以方法作为最基本的执行单元&#xff0c;“栈帧”&#xff08;Stack Frame&#xff09;则是用于支持虚拟机进行方法调用和方法执行背后的数据结构&#xff0c;它也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈…

电气产品外壳常用材质PA、PC、PBT、ABS究竟是什么?

在如今工业制造领域&#xff0c;各种改性塑料、复合材料以及轻质合金材料的运用日趋成熟。在电气领域&#xff0c;不同电气产品的外壳、组件材质采用不同材料&#xff0c;以同为科技&#xff08;TOWE&#xff09;电气产品为例&#xff0c;工业连接器系列产品采用PA6外壳材质、机…

python+django网上购物商城系统o9m4k

语言&#xff1a;Python 框架&#xff1a;django/flask可以定制 软件版本&#xff1a;python3.7.7 数据库&#xff1a;mysql 数据库工具&#xff1a;Navicat 开发工具pycharm/vscode都可以 前端框架:vue.js 系统使用过程主要涉及到管理员和用户两种角色&#xff0c;主要包含个…

javascript实现数据双向绑定

ES5中的双向绑定 ES5中的对象属性类型有两种&#xff1a;分别是数据属性和访问器属性 一&#xff0c;数据属性 数据属性包含一个数据值的位置。在这个位置可以读取和写入值。数据属性有4个描述其行为的特性 1&#xff0c;configurable:表示能否通过delete删除属性而重新定义…

利用Pandas进行高效网络数据获取

利用Pandas进行高效网络数据获取 背景&#xff1a; ​ 最近看到一篇关于使用Pandas模块进行爬虫的文章&#xff0c;觉得很有趣&#xff0c;这里为大家详细说明。 基础铺垫&#xff1a; ​ pd.read_html pandas 库中的一个函数&#xff0c;用于从 HTML 页面中读取表格数据并…

CEC2017(Python):五种算法(PSO、RFO、SSA、DE、HHO)求解CEC2017

一、5种算法简介 1、粒子群优化算法PSO 2、红狐优化算法RFO 3、麻雀搜索算法SSA 4、差分进化算法DE 5、哈里斯鹰优化算法HHO 二、CEC2017简介 参考文献&#xff1a; [1]Awad, N. H., Ali, M. Z., Liang, J. J., Qu, B. Y., & Suganthan, P. N. (2016). “Problem de…

详解“量子极限下运行的光学神经网络”——相干伊辛机

量子计算和量子启发计算可能成为解答复杂优化问题的新前沿&#xff0c;而经典计算机在历史上是无法解决这些问题的。 当今最快的计算机可能需要数千年才能完成高度复杂的计算&#xff0c;包括涉及许多变量的组合优化问题&#xff1b;研究人员正在努力将解决这些问题所需的时间缩…

白话机器学习的数学-2-分类

1、设置问题 图片分类&#xff1a;只根据尺寸把它分类为 纵向图像和横向图像。 如果只用一条线将图中白色的点和黑色的点分开&#xff1a; 这次分类的目的就是找到这条线。 2、内积 找到一条线&#xff0c;这是否意味着我们要像学习回归时那样&#xff0c;求出一次函数的斜率…

写在2023岁末:敏锐地审视量子计算的当下

本周&#xff0c;《IEEE Spectrum》刊登了一篇出色的文章&#xff0c;对量子计算&#xff08;QC&#xff09;的近期前景进行了深入探讨。 文章的目的并不是要给量子计算的前景泼冷水&#xff0c;而是要说明量子计算的前景还很遥远&#xff0c;并提醒读者量子计算的用例可能很窄…

【Minikube Prometheus】基于Prometheus Grafana监控由Minikube创建的K8S集群

文章目录 1. 系统信息参数说明2. Docker安装3. minikube安装4. kubectl安装5. Helm安装6. 启动Kubernetes集群v1.28.37. 使用helm安装Prometheus8. 使用helm安装Grafana9. Grafana的Dashboard设定10. 设定Prometheus数据源11. 导入Kubernetes Dashboard12. 实验过程中的常见问题…

RabbitMQ之快速入门、上手

前言 学习一样新技术、新框架&#xff0c;最重要的是学习其思想、原理。即原理性思维。 如果是因为工作原因&#xff0c;需要快速上手RabbitMQ&#xff0c;本篇或许适合你。 核心概念 Connection&#xff1a;publisher&#xff0f;consumer 和 broker 之间的 TCP 连接Channel…

亚信安慧AntDB数据并行加载工具的实现(一)

1.概述 数据加载速度是评判数据库性能的重要指标&#xff0c;能否提高数据加载速度&#xff0c;对文件数据进行并行解析&#xff0c;直接影响数据库运维管理效率。基于此&#xff0c;AntDB分布式数据库提供了两种数据加载方式&#xff1a; 一是类似于PostgreSQL的Copy命令&am…

java spring boot 自定义 aop

以一个锁的加锁和释放为例 1、先定义注解 /*** 锁切面* author fmj*/ Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface VersionLockAOP { }2、然后定义切面类以及切点 /*** 切面*/ Component Aspect Slf4j public class VersionLockAOPAspe…

GitHub Copilot 终极详细介绍

编写代码通常是一项乏味且耗时的任务。现代开发人员一直在寻找新的方法来提高编程的生产力、准确性和效率。 像 GitHub Copilot 这样的自动代码生成工具可以使这成为可能。 GitHub Copilot 到底是什么&#xff1f; GitHub Copilot 于 2021 年 10 月推出&#xff0c;是 GitHub 的…

idea配置docker推送本地镜像到远程私有仓库

目录 1&#xff0c;搭建远程Docker 私有仓库 Docker registry 2&#xff0c;Windows10/11系统上安装Docker Desktop 3&#xff0c;idea 配置远程私有仓库地址 4&#xff0c;idea 配置Docker 5&#xff0c;idea在本地构建镜像 6&#xff0c;推送本地Docker镜像到远程 Dock…

第3课 获取并播放音频流

本课对应源文件下载链接&#xff1a; https://download.csdn.net/download/XiBuQiuChong/88680079 FFmpeg作为一套庞大的音视频处理开源工具&#xff0c;其源码有太多值得研究的地方。但对于大多数初学者而言&#xff0c;如何快速利用相关的API写出自己想要的东西才是迫切需要…

USB -- STM32F103 USB VIDEO(视频)Camera同步传输讲解(九)

目录 链接快速定位 前沿 1 描述符修改 1.1 设备描述符修改 1.2 配置描述符修改 1.3 字符串描述符修改 1.4 编译报错修改 2 增加功能函数 2.1 Camera功能模块介绍 2.2 USB复位函数修改 2.3 Speaker_Data_Setup函数修改 2.4 非零端点函数修改 2.5 JEPG数据获取 3…