9月16日,Node.js 26.9.0发布。更新日志里有一行改动,改变了"在Node里直接调用C库"这件事的含义:node:ffi这个模块,现在默认开启。
在此之前,想用它得加上--experimental-ffi参数。从26.9.0开始,什么都不用加就能用;而那个曾经用来打开它的参数,现在的作用变成了关闭。
![]()
这不是文档层面的调整。有人下载了26.9.0,写了一个小型的C库,花了一个上午实测这个开关到底要付出什么代价、又会在哪里出问题。
默认开启意味着什么
不带任何参数直接运行,模块可以正常加载,只是会打印一条实验性警告。而加上--no-experimental-ffi,才会回到旧行为,报出"没有这个内置模块"的错误。
这个变化对CI环境有实际影响。任何导入node:ffi的代码,在26.9.0上会静默成功,而在更早的26.x版本上、没有加参数的情况下则会失败。如果项目里锁定了Node版本,这一点值得留意。
node:ffi的定位,是替代两种更老的做法:用C写一个N-API插件,或者调用外部编译好的二进制程序。实测选择了前者做对比,因为两者都是从运行中的Node进程调用原生代码,比较更公平。
与N-API插件的性能对比
测试方式是编译一个包含add_i32(a, b)的共享库,再写一个功能等价的N-API插件,各调用五百万次,同时用纯JS做同样的加法作为基准。
结果是:FFI比编译插件慢大约7%到8%,而两者都是纯JS做这件小事的约十五倍成本。这个差距来自每次调用都要在JS和原生代码之间搬运参数,无论你是写C还是只声明一个签名,这笔开销都躲不掉。
关键不在于"FFI慢"——37纳秒本身不算什么——而在于FFI并没有打败它本要替代的东西。如果项目里已经有原生插件,node:ffi不是性能升级。它的价值在便利:不需要node-gyp,部署镜像里不需要编译器工具链,也不用为每个Node ABI版本重新编译。
加两个数字的测试,开销主要来自调用本身,而不是计算。换成真正干活的函数——通过指针累加一千万个float64——结论就反过来了。
这一次FFI稳定快出15%到20%,因为一次调用搬运了一千万个元素,而不是把固定开销摊在千万次调用上。这才是FFI擅长的形态:批量缓冲区操作,而不是大量小调用。
单次计算,跨不跨边界没区别
反过来看也成立。用C写一个紧凑迭代循环计算fib(75),再用JS写同样的循环,各跑一次:JS耗时0.10毫秒,FFI耗时0.09毫秒,基本没有差别。
V8的JIT编译这种简单循环,速度和gcc -O2差不多。所以对于单次中等规模的计算,跨进原生代码什么也换不来。FFI的固定单次开销,只有在调用次数或数据量足够大、能把它吞掉的时候才划算。临时起意"这段干脆调C吧",通常不属于这种情况。
文档把node:ffi称为"不安全",并警告错误的签名可能导致进程崩溃。实测了四种出错方式,得到了三种不同结果。
三种错误,三种结局
第一种,在需要uint64参数的位置传一个普通数字,会被直接拒绝,报出类型错误。必须传BigInt,哪怕是很小的数字,比如一千万,也不会被自动转换。
第二种,用少于声明数量的参数调用函数,同样会被拦下,报出参数数量错误。这两类都是模块替你做的参数形状检查,单看它们,和"不安全、会崩溃"的说法并不吻合。
第三种,声明错误的返回类型,才是那句话开始成立的地方。一个返回int64的函数,如果声明成返回int32再调用,不会报错,也没有警告,只会得到一个被静默截断的错误数字。这种bug能通过代码审查,因为它看起来没有任何异常。
第四种,给函数传一个真实的指针,但长度是错的——一个只有十个元素的缓冲区,却告诉函数它有一千万个元素。结果是段错误,进程直接崩溃,退出码139。这正是文档警告的那种崩溃,触发它不需要什么特别的操作,只是一个不一致的长度,比如重构时缓冲区大小变了、调用处却没跟着更新。
所以这个模块会校验参数数量和基本标量类型,但不校验指针边界和返回类型的正确性。这是一条值得记住的边界。
Node的权限模型把FFI当作独立的一道门。在--permission下运行、没有显式允许它时,会报出访问被拒绝的错误,提示需要用--allow-ffi来管理权限。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.