深入 Ruby E04:C扩展与 FFI
文章卡
| 项目 | 本篇范围 |
|---|---|
| 先修 | 18–19、22、30、34–35:资源、打包、线程、GC 与实现路径 |
| 核心问题 | 跨语言资源由谁拥有,GC、GVL 与原生同步各保护什么? |
| 实验入口 | examples/ruby/labs/E04/run.rb,需要完整 CRuby 头文件、clang 与 make |
| 验收 | TypedData 参数/GC/幂等关闭;GVL 管道对照;FFI 真实调用与配对释放;C 原子更新 |
| 版本边界 | CRuby 3.4.11、Fiddle 1.1.6、Apple clang 21、arm64 macOS;其他平台未实测 |
原生调用把哪些责任交给调用者
Ruby 对象离开作用域后,关联的 C 缓冲区是否会释放?答案取决于扩展怎样声明引用和所有权。GC 能识别 Ruby 对象,不会自动推断一个任意地址由谁分配、何时仍在使用、应该交给哪个释放函数。FFI 减少包装代码,也不会删除这些责任。
本章在 CRuby 3.4.11 中构建一个 TypedData 扩展,并通过 Fiddle 1.1.6 调用独立 C 动态库。实验只处理合成字节与本地管道,所有构建产物位于临时目录。Apple clang 21 在 arm64 macOS 上完成实际编译;Linux 和 Windows 的原生构建未在本章实测。
两条路径分别回答不同问题。TypedData 把 C 状态封装为 Ruby 对象,并向 GC 报告引用和内存。Fiddle 根据声明的 C 参数类型调用动态库中的函数,调用者仍需维护指针的有效范围。对于只处理少量任务的普通 Ruby 脚本,没有必要为了使用这两种技术而增加原生边界。
TypedData 的引用图必须明确
NativeBox 的 C 结构包含 bytes、size 和 label。bytes 指向扩展拥有的缓冲区,label 是 Ruby 字符串的 VALUE。构造时复制并冻结标签,使用 Ruby 分配器申请一到一千零二十四字节的缓冲区,内容填为字母 A。类型错误、零长度和非法标签都在资源使用前被拒绝。
1 | |
rb_data_type_t 把 dmark、dfree 和 dsize 连接到结构。标记函数说明 label 仍被这个存活的 Ruby 包装对象引用;释放函数处理原生资源;大小函数报告结构与缓冲区大小,帮助运行时了解这类对象的内存占用。仅把 VALUE 存进 C 字段,不能替代标记过程。
实验使用普通 rb_gc_mark 保持标签可达,并采用会固定被引用对象的标记方式。若改用可移动标记,则还要在 dcompact 中通过 rb_gc_location 更新引用,不能只替换一个函数名。本章没有声明支持任意可移动原生对象,也没有启用要求维护写屏障的优化标志。
flowchart LR
Ruby["Ruby NativeBox 对象"] --> Struct["TypedData C 结构"]
Struct -->|"dmark:Ruby 引用"| Label["冻结的 label 字符串"]
Struct -->|"扩展拥有"| Buffer["原生 bytes 缓冲区"]
Close["显式 close 或 GC 的 dfree"] -->|"释放一次并置空"| Buffer
引用图中的两条边承担不同职责。label 的内存由 Ruby 管理,不对它调用 free;bytes 由扩展使用 ALLOC_N 取得,交给配对的 xfree。交换释放器、对借用指针释放或把同一地址包装成两个独立所有者,都可能破坏进程内存,而不是产生一个可恢复的普通 Ruby 异常。
显式关闭和 GC 兜底分别验证
close 首先检查 bytes 是否为空,释放后将地址置空、size 清零,并减少存活缓冲计数。第一次返回 true,第二次返回 false。read 检查关闭状态,关闭后抛出 IOError,不继续解引用旧地址。构造过的实例拒绝再次 initialize,避免覆盖仍被管理的状态。
这个空指针状态使显式关闭与后续 GC 释放可以共享同一清理函数。GC 回收包装结构时,即使 close 已经执行过,缓冲区也不会再次释放。dfree 只做原生清理,不调用 Ruby 方法、不创建 Ruby 对象,也不释放 GVL,因此本例可以设置 RUBY_TYPED_FREE_IMMEDIATELY。
实际断言先创建八字节对象,执行完整 GC 和 GC.compact,随后检查标签仍为 retained-by-C、读取结果仍为八个 A。它证明这次收集和压缩后引用关系有效;保留标签内容的检查不能替代完整内存检查器或所有 GC 时序下的正确性证明。
另一组实验丢弃五十个对象,观测缓冲计数为五十,再运行完整收集与立即清扫,计数归零。这个统计由教学扩展自行维护,不等于进程 RSS。操作系统不立即回收堆页,不能据此判定扩展泄漏;反过来,RSS 稳定也不能代替所有权检查。
本次参数和状态反例依次得到 TypeError、ArgumentError、TypeError 和 IOError。前两类分别对应类型与取值范围,关闭后访问属于资源状态错误。错误协议越靠近边界越清楚,调用者越不需要猜测原生函数是否已经取得资源。
释放 GVL 的证据来自可完成的协议
阻塞原生调用若一直持有 GVL,其他 Ruby 线程可能无法运行来满足它等待的条件。实验的 C 函数先向 ready 管道写入一个字节,再 poll 等待另一条管道中的 x,最长等待一千二百毫秒。另一个 Ruby 线程必须先读取 ready,再写入 x。
相同函数分别直接执行和通过 rb_thread_call_without_gvl 执行。直接执行时,等待期间 Ruby 响应方无法取得 GVL,原生等待超时,返回 false;释放 GVL 后,Ruby 响应方能够运行,在原生区间仍活跃时写入 x,函数返回 true。脚本还检查活跃标志,而不是仅以总耗时长短猜测是否发生了交错。
1 | |
参数转换在持有 GVL 时完成。无 GVL 区间只访问栈上的 C 状态、原子标志和受控文件描述符,不调用 Ruby C API。返回后才把原生结果转换为 Ruby true 或 false。GVL 保护哪些 API,与底层操作系统调用是否阻塞,是两个需要分别判断的问题。
本例未提供 unblock 函数,依靠有限 poll 超时结束等待,因此没有声称它是可立即取消的通用 I/O 包装器。信号导致 poll 提前返回时也可能得到 false。生产包装器需要按具体系统调用设计中断、重试、关闭与错误码协议,不能照搬这个教学函数处理无限等待。
原生线程安全需要自己的同步规则
GVL 释放后,原生共享数据不会自动得到保护。独立动态库的 native_counter 创建两个 pthread。反例使用 C 原子变量,但把更新拆为 atomic_load 和 atomic_store;两个线程通过条件变量确保都先读零,然后各自写一,最终为一。
这里刻意使用原子读写,使访问本身有定义,再展示复合操作丢更新。没有采用两个线程同时写普通 C 整数的未定义行为。原子性需要覆盖业务操作的整体;把每个小步骤变为原子操作,仍可能留下步骤之间的交错窗口。
修复版本用 atomic_fetch_add,每个线程执行一千次,结果为两千。两个 pthread 都 join 后再销毁条件变量和互斥锁,避免原生线程继续访问已经离开作用域的结构。线程创建失败会终止这个隔离实验进程,而不会留下一个永远等待第二参与者的屏障。
这个结果与 GVL 管道实验分开记录:一个证明 Ruby 线程能够在原生等待期间运行,另一个证明 C 共享状态需要正确的原子更新。它们都没有证明某个任意第三方原生库是线程安全的,也没有测量 CPU 并行加速。
Fiddle 调用必须配对类型和释放器
bridge.c 导出 owned_new、owned_free、owned_live 和 checksum。申请函数只接受合法大小,失败返回空地址;校验和函数遇到空地址或非法大小返回负一。Ruby 端显式声明 TYPE_INT、TYPE_VOIDP 和返回类型,使 libffi 按正确的调用约定传参。
八个 A 的字节和为 520,脚本实际调用动态库并断言该值。函数声明不能替代边界校验:原生 checksum 接受地址和长度,它不知道地址后到底有多少可读字节。本例固定传入自身刚申请的八字节空间,不把这个函数开放给任意用户地址或长度。
1 | |
只有 owner 持有释放回调,raw 是没有释放器的借用包装。再次调用 owner.call_free 不会再次释放;实际 freed? 为 true,动态库的存活计数归零。这个幂等性属于同一个 Fiddle 包装对象,不会让两个独立包装对象自动协调所有权。不得把同一地址再交给另一所有者,也不得绕过包装器直接重复调用 C free。
所有外部函数以 need_gvl: false 建立调用。它们不使用 Ruby C API,原生计数采用 C 原子操作;这种声明仅决定调用期间是否需要 GVL,不是一份线程安全认证。动态库句柄、函数对象与指针所有者保持存活直到最后一次调用和释放结束,避免卸载后继续使用函数地址。
隔离构建与复现
从仓库根目录使用固定 CRuby 运行:
1 | |
run.rb 新建临时目录,使用 mkmf 和当前解释器的头文件构建 native_box,再用 clang 构建 bridge 动态库,随后启动独立 Ruby 子进程执行 probe.rb。编译命令、标准输出、错误输出和退出码均进入日志。子进程成功后删除临时目录,仓库只保存源代码。
本次结果为缓冲计数 50 到 0、两种 GVL 模式 false 与 true、FFI 校验和 520、释放后存活数零、原生拆分更新一与原子更新两千,构建目录已删除。隔离进程可限制崩溃对测试驱动的影响,但它不是恶意代码沙箱;这里仅编译仓库内可审阅的实验源码。
练习与验收
把缓冲区大小改为边界值一、一千零二十四及越界值一千零二十五,分别检查内容长度、错误类型和存活计数。再触发 read 期间的业务异常,使用 ensure 调用 close,确认重复关闭与后续 GC 都不造成重复释放。
为 Fiddle 增加一个 Ruby 所有者类,隐藏裸指针,只公开 checksum 和 close。关闭后调用应先抛出 Ruby 状态错误;另为两个对象误用同一地址设计构造限制,不通过实际重复 free 来验证危险行为。
| 责任 | 本次验证 | 不能替代 |
|---|---|---|
| Ruby 引用 | GC 后标签仍可达 | 任意压缩策略正确性 |
| 原生所有权 | close 幂等、GC 计数归零 | 进程全部内存审计 |
| GVL | 管道协议可完成 | 原生共享数据同步 |
| FFI 声明 | 正确签名与配对释放 | 任意地址安全检查 |
