9月16日,Node.js 26.9.0发布。更新日志里有一行改动,悄悄改写了"直接调用C库"这件事在Node里的含义:node:ffi这个模块,现在默认开启。

在此之前,想用它得先加上--experimental-ffi参数。从26.9.0开始,它直接就能用,而那个曾经用来打开它的参数,现在的作用变成了关闭它。

打开网易新闻 查看精彩图片

有人下载了26.9.0,写了一个小型的C库,花了一上午时间,想搞清楚这个开关到底要付出什么代价,又会在哪里出问题。

默认开启意味着什么

不带任何参数运行,模块能跑起来,但会带一条实验性警告。加上那个曾经必需的参数,不会带来任何新变化,因为模块本来就已经在那里了。

唯一能回到旧行为的方式,是加上--no-experimental-ffi。这时会直接抛出错误:找不到名为node:ffi的内置模块。

这说明它是一次真正的默认值变更,不是文档层面的更新。任何导入node:ffi的代码,过去在没加参数的旧版26.x上会失败,现在在26.9.0上会静默成功。如果你的CI里锁定了版本,这一点值得留意。

和编译型插件比,谁更快

node:ffi存在的意义,是作为另外两条老路的替代方案:用C写一个N-API插件,或者去调用一个编译好的二进制文件。这里选择了和插件对比,因为这是更公平的较量——两者都是从运行中的Node进程调用原生代码。

测试方式是编译一个包含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真正擅长的形态:批量缓冲区操作,而不是大量的小调用。

反过来看也成立。跑一次原生的fib(75),用C里紧凑的迭代循环计算,对比JS里同样的循环:

  • js-fib(75):0.10毫秒
  • ffi-fib(75):0.09毫秒

基本没有区别。V8的JIT编译这种简单循环的速度,和gcc -O2差不多。所以对于单次中等规模的计算,跨进原生代码什么也换不来。FFI固定的每次调用成本,只有在调用次数或数据量足够大、能把它吞掉时才划算。一次性的"让我调一下C"通常不属于这种情况。

四种写错的方式,三种结果

文档把node:ffi称为"不安全",并警告错误的签名可能导致进程崩溃。测试了四种写错的方式,得到了三种不同的结果。

在需要uint64参数的地方传一个普通数字,会被直接拒绝,抛出类型错误,提示参数必须是uint64。必须传BigInt,一个Number,哪怕小到10000000,也会被拒绝,而不是被强制转换。

用两个参数的函数只传一个参数,同样会被捕获,抛出参数数量错误。

这两种都是模块替你做的参数形状检查,单看它们,并不符合"不安全、会崩溃"的说法。

声明错误的返回类型,才是那个说法开始成立的地方。fib函数返回int64,把它声明成返回int32再调用,没有报错,没有警告,只有一个被静默截断的错误数字。这种bug能通过代码审查,因为它看起来没有任何不对。

再给sum_doubles传一个真实的指针,但长度是错的——一个10元素的缓冲区,却告诉函数它有一千万个元素。结果是段错误,核心已转储,退出码139。这才是文档警告的那种真正的崩溃,而制造它并不需要什么稀奇的操作,只是一个不一致的长度,一次重构把缓冲区大小改了、调用点却没跟着更新,就会引入。

所以这个模块会校验参数数量和基本标量类型,但不校验指针边界,也不校验返回类型的正确性。这是一条值得记住的分界线。

权限模型里的独立闸门

Node的权限模型把FFI当作一道独立的闸门。在--permission下运行、又没有明确允许它时,会抛出访问被拒绝的错误,提示需要用--allow-ffi来管理权限。

换句话说,默认开启不等于默认放行。想用FFI,权限这一关还得单独过。