Deno FFI 深度解析:Deno.dlopen 双快慢路径与 JIT 蹦床的设计原理
2026/9/7 4:30:12 网站建设 项目流程

Deno FFI 深度解析:Deno.dlopen 双快慢路径与 JIT 蹦床的设计原理

【免费下载链接】denoA modern runtime for JavaScript and TypeScript.项目地址: https://gitcode.com/GitHub_Trending/de/deno

Deno 的 FFI 模块(deno_ffi)让 JavaScript/TypeScript 可以直接加载 C 动态库并调用其中导出的函数,其核心卖点是以纳秒级开销实现“接近原生代码”的调用速度。本文基于仓库中的 ext/ffi/README.md 及其配套源码,完整梳理Deno.dlopen的类型系统、符号加载调用链、V8 Fast API 优化路径与 fallback 路径的双轨设计、非阻塞调用与回调机制,并给出可直接运行的基准测试方法。

一、deno_ffi 是什么:一个专注动态库调用的 FFI 扩展

ext/ffi/README.md 对模块的定位一句话概括:"This crate implements dynamic library ffi."——deno_ffi负责实现动态库(dylib/so/dll)层面的外部函数接口。它由 Rust 实现(ext/ffi/下的 8 个.rs文件)加一个懒加载 JS 层(00_ffi.js)组成,通过deno_core::extension!宏注册进运行时。

从 lib.rs 的扩展声明可以确认几个关键事实:

  • 仅支持 64 位平台:源码开头有硬约束#[cfg(not(target_pointer_width = "64"))] compile_error!("platform not supported"),并用编译期断言校验指针宽度为 8 字节(lib.rs#L38-L45);
  • 属于 unstable 特性pub const UNSTABLE_FEATURE_NAME: &str = "ffi",即使用Deno.dlopen需要显式开启--unstable-ffi
  • Ops 面:共注册了 30 个 op,覆盖四类能力——库加载(op_ffi_loadop_ffi_get_static)、函数调用(op_ffi_call_ptrop_ffi_call_nonblockingop_ffi_call_ptr_nonblocking)、指针内存读写(op_ffi_read_u8op_ffi_cstr_read等十余个)、回调管理(op_ffi_unsafe_callback_create/ref/close)与 turbo 调试(op_ffi_get_turbocall_target)。

二、README 的性能主张及其来源

ext/ffi/README.md 的 Performance 一节给出了三条核心性能主张:

  1. 极低开销:Deno FFI 调用开销极低(原文引用:约 1ns @ M1 16GB RAM),性能"与原生代码相当"(perform on par with native code);
  2. 两条路径Deno.dlopen会为每个符号同时生成一条optimized path和一条fallback path。优化路径在 V8 决定优化该函数时通过 Fast API 触发;fallback 路径处理 Fast API 不支持的类型(如函数回调),并实现针对意外参数类型的完整错误处理;
  3. JIT 蹦床:优化调用进入一个 JIT 编译的"trampoline"(蹦床函数),将 Fast API 值直接翻译为符号调用参数;README 将编译速度归因于tinycc。需要指出的是,从当前源码看,蹦床机器码的实际生成器已换成 Cranelift(见下文第四节),这一点以 turbocall.rs 为准;
  4. 平台限制:README 明确"目前优化路径仅支持 Linux 和 MacOS"。

三、JS 层 API 全貌:Deno.dlopen 与四个 Unsafe 类

所有面向用户的 API 定义在 00_ffi.js 末尾的模块导出中:dlopenUnsafeCallbackUnsafeFnPointerUnsafePointerUnsafePointerView

3.1 符号表定义与 dlopen 入口

dlopen(path, symbols)将路径经pathFromURL转换为文件系统路径后构造DynamicLibrary(00_ffi.js#L568-L570)。符号表的每条定义对应 Rust 侧的ForeignFunction结构(dlfcn.rs#L90-L101):

const dylib = Deno.dlopen("./libfoo.so", { // 基本形式:键名即符号名 "add_u32": { parameters: ["u32", "u32"], result: "u32" }, // 完整形式:可重定向符号名、标记非阻塞、标记可选 "sleep_ms": { name: "usleep", // 可选:库内真实符号名 parameters: ["u32"], result: "void", nonblocking: true, // 可选:在后台线程执行,返回 Promise optional: true, // 可选:符号不存在时置 null 而不报错 }, // 静态变量:带 type 字段的条目按静态变量处理 "VERSION": { type: "u32" }, });

DynamicLibrary构造器(00_ffi.js#L457-L566)随后按条目分派:带type的走op_ffi_get_static读静态变量;nonblocking: true的生成异步包装函数;其余使用 Rust 侧op_ffi_load返回的同步函数。库通过dylib.close()释放。

3.2 原生类型系统

Rust 侧的类型枚举在 symbol.rs#L7-L28:

类型字符串含义JS 侧接受的实参 / 返回形态
void无返回不传参;返回undefined
bool布尔boolean
u8/i8/u16/i16/u32/i32整数无符号/有符号整数 Number
u64/i64/usize/isize64 位整数BigInt 或 Number 均可
f32/f64浮点Number
buffer缓冲区指针nullArrayBufferArrayBufferView
pointer/function裸指针 / 函数指针nullExternalDeno.UnsafePointer产出的指针)
{ struct: [...] }结构体ArrayBufferArrayBufferView(按字段顺序与 C ABI 布局)

其中u64/i64/usize/isize的"BigInt 或 Number"双支持在解析函数里可以直接看到:ffi_parse_u64_arg 先尝试 BigInt、再回退 Number,注释解释了顺序考量——BigInt 罕见且 Fast API 不支持,故在慢速路径优先检查。buffer参数的解析逻辑见 parse_buffer_arg(检查顺序:ArrayBuffer → ArrayBufferView → null)。

3.3 结构体:JS 自己算大小和内存对齐

结构体类型没有原生布局支持,JS 层的 getTypeSizeAndAlignment 按 C ABI 规则递归计算每个结构体的字节大小与对齐(基础类型查表:bool/u8/i8 为 1,u16/i16 为 2,u32/i32/f32 为 4,u64/i64/f64/pointer/buffer/function/usize/isize 为 8),并对循环结构体抛TypeError。这个大小有两个用途:调用返回结构体的同步函数时预分配输出缓冲区;UnsafeFnPointer手动调用时同理。

四、符号加载调用链:op_ffi_load 里发生了什么

op_ffi_load(dlfcn.rs#L144-L247)是整个 FFI 的入口 op,其执行步骤:

  1. 权限检查permissions.check_ffi_partial_with_path(path),即--allow-ffi(可带=path限定)在此强制;
  2. 打开库dlopen2::raw::Library::open完成实际的dlopen/LoadLibrary,Windows 下还专门实现了带库路径参数的FormatMessageW错误格式化(format_error);
  3. 解析符号:对每个ForeignFunction,用lib.symbol::<*const c_void>()取函数地址;optional: true的符号查不到时直接写入null而跳过,否则抛带符号名的DlfcnError::RegisterSymbol
  4. 建立 libffi CIF:用libffi::middle::Cif::new把参数/返回类型(NativeTypelibffi::middle::Type,转换见 symbol.rs#L30-L66)编译为调用信息,连同函数指针打包进Symbol
  5. 生成 JS 函数:同步符号经make_sync_fn创建绑定函数,然后返回[resourceId, 符号对象]二元数组。

Symbol与可选的Turbocall一起装进 cppgc 管理的FunctionData(dlfcn.rs#L249-L256),作为函数模板的 data 携带——这样 Rust 侧状态的生命周期由 V8 的函数 GC 管理。

五、性能核心:optimized 与 fallback 双路径

这正是 ext/ffi/README.md 主张的机制,源码对应关系如下。

5.1 兼容性与蹦床编译

make_sync_fn 对每个符号先做兼容性判断:

let turbocall = if turbocall::is_compatible(&symbol) { match turbocall::compile_trampoline(&symbol) { Ok(trampoline) => Some(turbocall::make_template(&symbol, trampoline)), Err(e) => { log::warn!("Failed to compile FFI turbocall: {e}"); None } } } else { None }; // ... let func = if let Some(overloads) = overloads { builder.build_fast(scope, overloads) // 优化路径:挂 Fast API } else { builder.build(scope) // fallback:普通 JS 回调 };

兼容性条件很简单(turbocall.rs#L42-L48):返回类型不是 struct,且参数中没有 struct。struct 参数/返回值走 fallback 的sync_fn_impl(dlfcn.rs#L309-L337),因为 struct 需要最后一个 TypedArray 参数作为输出缓冲区、并做大小校验。

5.2 Cranelift 生成的可执行蹦床

compile_trampoline(turbocall.rs#L62-L365)为每个符号生成一段包装机器码:

  • cranelift::prelude构建三个签名:wrapper_sig(V8 调用的入口,参数类型按 Fast API 约定展开为 i32/i64/f32/f64 等)、target_sig(真正调用 C 函数的平台 ABI 签名)、raise_sig(错误上报);
  • wrapper 入口先做参数收窄(如 i32 收窄为 i8/i16),对buffer参数调用turbocall_ab_contents从 V8 值提取裸指针,若类型非法(返回isize::MAX哨兵)则跳转错误块,经turbocall_raiseInvalidBufferType异常;
  • 返回值做符号/零扩展后返回;
  • 编译产物校验(verify_function)、优化后写入memmap2::MmapMutmake_exec()变成可执行内存,包装为Trampoline(turbocall.rs#L359-L364);
  • make_template(turbocall.rs#L407-L451)把蹦床地址与CFunctionInfo(含每个参数的CTypeInfoInt64Representation::BigInt)打包成 V8 的CFunction,交build_fast注册为 Fast API 重载。此后 V8 在优化状态下可以直接以 C ABI 调用蹦床,完全绕过 op 边界与 JS 调用栈

源码注释还记录了一个重要的 Apple silicon 修正:V8 在 arm64 上按 AAPCS64 把栈参数打进 8 字节槽,而 Darwin 默认 ABI 按自然对齐读取,会导致读到垃圾值;因此 macOS aarch64 上 wrapper 被强制使用SystemV调用约定,而内部调用用户 C 函数的签名仍用平台默认(turbocall.rs#L76-L93)。这与 README "优化路径仅支持 Linux 和 MacOS" 的平台限定互为印证。

另外仓库保留了调试钩子:设置环境变量DENO_UNSTABLE_FFI_TRACE_TURBO=1后,蹦床会记录被 turbo 调用的符号名,JS 侧可通过getTurbocallTarget()(底层为op_ffi_get_turbocall_target,turbocall.rs#L495-L517)查询,用于验证某次调用确实走了 Fast API 快路径。

5.3 fallback 路径的调用与错误处理

没有 turbo 重载时,函数体是sync_fn_impl:若返回类型为 struct,取最后一个 TypedArray 参数作为输出缓冲区(out_buffer_as_ptr,ir.rs#L129-L136),再进入ffi_call_sync(call.rs#L83)——逐个参数调用ir.rs中的ffi_parse_*_arg解析为NativeValue联合(ir.rs#L161-L178),经libffiffi_call发起真正的 C 调用,再按声明类型把NativeValue转回 V8 值(NativeValue::to_v8,其中 64 位整数转 BigInt、指针转External、null 指针转 JS null)。参数类型不符时抛出 IRError 中定义的细粒度 TypeError(如Invalid FFI u8 type, expected unsigned integer),这就是 README 所说"fallback 路径实现 Fast API 不支持的完整错误处理"。

六、非阻塞调用:把 C 调用挪到后台线程

在符号定义中声明nonblocking: true后,调用返回 Promise。JS 层包装见 00_ffi.js#L509-L541(struct 返回值时自动分配输出 buffer 并在 promise 完成后 resolve 为该 buffer)。Rust 侧由op_ffi_call_nonblocking/op_ffi_call_ptr_nonblocking处理,用deno_core::unsync::spawn_blockingffi_call丢到阻塞线程池,从而不卡住事件循环。

有两个值得注意的安全细节:

  • GC 保护:BackingStoreHolder 在解析 buffer/struct 参数前保留其BackingStore的共享引用,防止 V8 在后台线程仍持有裸指针期间回收底层内存;
  • 禁止用户可 resize 的 bufferholder.push检测到is_resizable_by_user_javascript()时抛ResizableBackingStore错误——共享可调整大小的存储其数据指针在异步调用期间无法保证稳定。

注意 dlfcn.rs 中的注释:turbo 优化目前不适用于非阻塞调用nonblocking符号在op_ffi_load阶段不生成 turbo 函数,统一走 op 通道。

七、静态变量与函数回调

7.1 静态变量(Foreign Static)

符号表中带type字段的条目(如"VERSION": { type: "u32" })由op_ffi_get_static(static.rs#L29-L37)处理:按符号名取地址后按类型read_unaligned读内存。限制明确:voidInvalidTypeVoidstructInvalidTypeStructpointer/function/buffer类型读出的是External指针值。optional: true时符号不存在返回null而非报错。JS 侧还会先拦截type: "void"并抛 TypeError(00_ffi.js#L474-L501)。

7.2 UnsafeCallback:把 JS 函数暴露给 C

UnsafeCallback(00_ffi.js#L394-L453)将 JS 回调包装为可传给 C 的函数指针:

const c = new Deno.UnsafeCallback( { parameters: ["u32", "u32"], result: "u32" }, (a, b) => a + b, ); dylib.symbols.register_handler(c.pointer); // 传给 C c.close(); // 必须显式释放,否则 C 侧悬垂

Rust 侧 callback.rs 用libffi::middle::Closure创建真实 C 函数指针并注册为资源;C 端线程被回调时,CallbackInfo记录thread_id,若不在原线程则通过V8CrossThreadTaskSpawner把参数序列化后调度回 isolate 线程执行 JS,再取回返回值,实现线程安全的回调(测试见 tests/ffi/testdata/thread_safe_test.ts)。ref()/unref()控制回调是否阻止 Deno 进程退出(内部 ref 一个 op promise),UnsafeCallback.threadSafe()构造即自动 ref。构造函数还硬性禁止nonblocking定义(回调必须同步)。

7.3 裸指针工具

  • Deno.UnsafePointer.of(value)/.value(ptr)/.offset(ptr, n)/.equals/.create:在 buffer 与 C 指针之间互转(Pointer类型参数只接受External或 null,见 ffi_parse_pointer_arg);
  • Deno.UnsafeFnPointer.call(...):对裸函数指针按定义手动调用,struct 返回值时自动分配输出 buffer(00_ffi.js#L280-L332);
  • Deno.UnsafePointerView:指针内存读取全家桶——getBool/getUint8getFloat64/getPointer/getCString/getArrayBuffer/copyInto,全部映射到 lib.rs 中注册的op_ffi_read_*/op_ffi_cstr_read/op_ffi_get_bufop,可用于把 C 返回的pointer解析为字符串或拷贝到 JS 缓冲区。

八、运行官方 FFI 基准测试

README 给出的基准命令为:

target/release/deno bench --allow-ffi --allow-read --unstable-ffi ./tests/ffi/tests/bench.js

需要说明两点适用前提:其一,命令假设已构建出target/release/deno,且当前需要--unstable-ffi解锁 FFI;其二,以当前仓库为准,基准脚本实际位于 tests/ffi/testdata/bench.js(README 中的旧路径tests/ffi/tests/bench.js已不存在),运行前需先构建对应的test_ffi测试动态库(基准脚本从Deno.execPath()同级目录加载libtest_ffi.{dylib,so,dll},见 bench.js#L4-L10)。

该基准覆盖了性能文档关心的全部维度:所有标量类型的无参/有参调用(nop_*/return_*)、u64的 Number 与 BigInt 两种传参方式、buffer参数(hash)、C 字符串读取(ffi_string+UnsafePointerView.getCString)、26 参数的大参数列表调用(nop_many_parameters)、上述全部的nonblocking变体,以及UnsafePointer.of/value与各UnsafePointerView#get*操作本身的开销——对照同步与非阻塞两组的差值,即可直观看到 op 通道与 Fast API 路径的成本对比。更多类型层面的行为验证可参考 tests/ffi/testdata/ffi_types.ts 与 tests/ffi/testdata/test.js。

九、总结与延伸阅读

deno_ffi的设计可以概括为三层:JS 声明层(符号表定义 + Unsafe 工具类)、op 通道层(权限、资源表、libffi 调用、错误处理)、以及一层按符号动态生成的机器码快路径(Cranelift 蹦床 + V8 Fast API)。这条"声明一次、热路径零 JS 开销"的路径,正是 README 性能主张的实现来源;而 fallback 路径保证了 struct、回调等复杂场景的正确性与可诊断性。

继续深入时可按此脉络阅读源码:

  • 扩展注册与 op 清单:ext/ffi/lib.rs
  • 库加载与函数生成:ext/ffi/dlfcn.rs
  • Fast API 蹦床:ext/ffi/turbocall.rs
  • 参数解析与值转换:ext/ffi/ir.rs、ext/ffi/call.rs
  • 类型系统:ext/ffi/symbol.rs
  • 静态变量:ext/ffi/static.rs
  • 回调:ext/ffi/callback.rs
  • JS API:ext/ffi/00_ffi.js
  • 测试与基准:tests/ffi/testdata/bench.js

【免费下载链接】denoA modern runtime for JavaScript and TypeScript.项目地址: https://gitcode.com/GitHub_Trending/de/deno

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询