前言
创意:张汉东 绘画:ChatGPT DALL•E3
创意来源:本人学习 Rust 过程中的一些踩坑、成长与思考。
如果大家喜欢,欢迎点赞、转发和赞赏,每一位读者对认可是我持续创作的动力。
漫画赏析
你好啊,作为一名程序员,参加线下的 Meetup 技术交流会也许是你唯一的社交活动。无论是线上还是线下,请都不要错过。今天,也许是你参加的第一次二次元 Rust Meetup 。
噢,我是小明,非常欢迎您参加本次 Rust Developers Meetup !请进来吧!小心别把 Ferris crab 踩到 。。。
今天 Meetup 的讨论主题是:用 Rust 重写项目是否有必要?用 Rust 重写是否有害?
欢迎您参加,并积极展开讨论吧!
“请回复评论 或 你可以另外写文章来参与讨论!
漫画解析
在 Rust 语言 1.0 发布的两三年内,Rust 社区中出现了一批狂热粉丝,他们经常跑去 GitHub 上一些知名开源项目下,逢人就说,请用 Rust 重写,或者,质问为什么项目不用 Rust 重写之类的话,于是社区就形成了一个梗:RIIT,Rewrite it in Rust。
此举当然遭到了很多人的反感,甚至有人吐槽:“这些 Rust 狂热粉丝让我好几年都不敢碰 Rust 语言”。其实这类事情,不仅仅在 Rust 语言社区发生过,任何群体里,都会有狂热粉,会质问别人为什么做的和他们不一样。
之前还有人也作了一幅漫画来讽刺 RIIR,内容大概是俩人在卫生间相遇,其中一人安利另外一个人项目用 Rust 重写。但其实现实中,在洗手间安利或询问 Rust 使用情况的场景,大多数发生在 Rust 线下交流会,如本期漫画第五幅所示。
抛开这个梗,我们今天要严肃地讨论一下 Rust 重写的必要性!
《Rewrite it in Rust 有害?》论文解读
起因是,我最近看到今年上半年有一篇匿名作者写的论文[《"Rewrite it in Rust" Considered Harmful ?》](https://goto.ucsd.edu/~rjhala/hotos-ffi.pdf "《"Rewrite it in Rust" Considered Harmful ?》") 。文章讨论了将 C/C
代码迁移到 Rust 时,需要在 Rust 和 C/C 的接口(FFI)引入的潜在安全问题。因为不可能将全部 C/C
代码都用 Rust 重写,目前主流的方式就是用 Rust 重写一部分在未来还需要持续维护和发展的代码,所以与 C/Cpp 安全交互是目前无法避免的。
文章首先指出,尽管用 Rust 重写代码可以提高内存安全性,但在 Rust 和 C/C 组件交互的界面仍可能引入新的 Bug。Rust 和 C/C 在内存管理、类型系统和控制流方面存在差异,手写的“胶水代码”很容易破坏一些关键的不变量。
比如 C 语言中存在代码:
代码语言:javascript复制void add_twice(int *a, int *b) {
*a = *b;
*a = *b;
}
C 中的这个 add_twice
函数参数会产生别名。
#[no_mangle]
pub extern "C" fn add_twice(a: &mut i32, b: &i32) {
*a = *b;
*a = *b;
}
而 Rust 中 add_twice
函数的两个参数则不会产生别名,但是将改 Rust 函数导出 C-ABI 函数给 C 用,如果 C 那边传入 add_twice(&bar, &bar)
这样的调用,则会破坏 Rust 函数的别名模型,导致未定义行为。
这就是 FFI 边界上的内存安全风险。
文章对 FFI Safety 相关安全问题做了一个归类,我们依次来看看。
时空安全问题
以 rustls 库为例,它需要与 C 代码共享证书验证器对象的所有权。rustls 通过 Rust 的Arc计数引用计数智能指针来管理这些对象,以实现多方共享一个验证器。
但是 rustls 通过 FFI 暴露了对应的原始指针,需要客户端代码调 rustls_client_cert_verifier_free
来释放:
pub extern "C" fn rustls_client_cert_verifier_free(
verifier: *const rustls_client_cert_verifier) {
// 重新构造Arc并drop,减少引用计数
unsafe { drop(Arc::from_raw(verifier)) };
}
“其实包含这个函数的代码还没有合并:https://github.com/rustls/rustls-ffi/pull/341
这样的代码确实会减少引用计数,但客户端可能错误地调用两次 free 释放同一个指针,或在释放后继续使用指针,从而造成双重释放或使用后释放问题。
“这里其实没有什么理想的解决方案,在 Android 里 Rust 给 Cpp 端共用 Arc 的做法就是直接通过 C-ABI 给 Cpp 透出回调函数来增减引用计数,而非这种 drop 方式。但是也需要 C/C 端不要错误调用回调函数。
异常安全问题
Rust 如果发生了跨 FFI 边界的 Panic 会造成未定义行为,但目前处理这类问题主要依赖程序员自己编码。
例如 rustls 库中,rustls_client_cert_verifier_new
函数在克隆证书存储时可能会 Panic:
pub extern "C" fn rustls_client_cert_verifier_new(
store: *const rustls_root_cert_store) {
let store: &RootCertStore = try_ref_from_ptr!(store);
// clone可能会panic
return Arc::into_raw(... store.clone() ...);
}
但这个函数没有做 Panic 处理,如果 clone 发生 OOM 错误,会直接 Panic 回 C 端,造成未定义行为。
理想的解决方案是:在 FFI 边界自动捕获 Panic,并把错误信息传递给 C/C
端。但 Rust 本身没有提供这方面的支持,完全依赖程序员自己实现。
“其实反过来在 C/Cpp 端也是一样,需要自动捕获异常,传给 Rust 错误码。
Rust 类型不变量
Rust 代码往往高度依赖类型系统所保证的不变量,借此确保内存安全和代码正确性。由于 C/C 程序通常不一定遵循这类不变量,因此 C/C 在与 Rust 代码交互时可能引发冲突,这类问题在用 Rust 重写后尤其需要重点考虑。
看下面这个来自 encoding_c
库的 decoder_decode_to_utf8
FFI函数:
pub unsafe extern "C" fn decoder_decode_to_utf8(
src: *const u8, src_len: *mut usize,
dst: *mut u8, dst_len: *mut usize) {
let src_slice = from_raw_parts(src, *src_len);
let dst_slice = from_raw_parts_mut(dst, *dst_len);
// 解码...
}
这个函数使用 from_raw_parts
等不安全函数重建了 Rust 的 slice
类型。但是 Rust 要求src_slice
和 dst_slice
地址不重叠(overlap)以进行编译时优化。
而这个 FFI 函数没有检查指针别名情况,C/C 调用时可能会违反这个不重叠要求,导致未定义行为。
解决方法是对 from_raw_parts
的参数进行安全判断,确保其不为空,且地址没有重叠等安全条件。
pub unsafe fn from_raw_parts_mut_safe<'a, T> (data: *mut T, len: usize) -> &'a mut [T]
{
// todo: requires not_null(data) && valid_cpp_alloc(data, len)
// && not_aliased(current_refs, data, len)
// todo: ensures add_to_current_refs(data, len);
}
其他未定义行为
文章提到的其他未定义行为包括:
- ABI兼容性问题:不同编译器对 ABI 级别的优化处理可能不兼容,导致跨语言调用时 ABI 参数传递出错。例如 C 编译器会将多个 32 位参数打包到 64 位寄存器中,而 Rust 不会进行这样的优化。如果两边不一致就可能出错。
- 空指针访问:FFI 函数中没有充分校验指针参数是否为 null 就直接解引用,可能导致空指针访问错误。
- 缓冲区切片不当 :没有正确检查 bounds 就通过
from_raw_parts
创建缓冲区切片,可能会访问到不属于该缓冲区的内存。 - 移动语义错误:Rust 的移动语义要求在移动后不能再访问变量,但 FFI 代码可能错误地继续使用已经 move 了的变量。
- 粘合代码问题:很多问题源自需要通过 unsafe 代码进行参数转换和重建 Rust 抽象的粘合代码。这些转换做了许多假设,容易被 C/C 端的非法参数破坏。
这一类问题更加隐晦和具体,但也可能导致严重后果。
小结
文章提出了一个 R3 系统来帮助解决这些安全问题,该系统主要包含两部分内容:
- C/C 端的分配追踪器(allocator tracker)
这个组件可以跟踪C/C 应用中的内存分配情况,这样 Rust 端的 FFI 代码可以查询分配的元数据,确保满足Rust的一些安全不变式。例如跟踪已经转换到 Rust REFERENCE的指针,避免C 端释放 Rust 还在使用的内存导致的错误。
- Rust端的细化类型系统(refinement type system)
这个类型系统为 Unsafe 的 FFI 函数添加细化类型注解,确保 Rust 端编写的 FFI 代码进行了必要的安全检查。例如要调用一个 unsafe 函数之前,必须通过分配追踪器验证指针参数的有效性。
细化类型允许在普通类型上添加 Predicate 约束,这样可以表示更严格的类型集合。类型检查器会要求明确验证那些 Predicate 才能通过。
总之,R3系统通过在FFI边界两侧增加自动化的静态和动态检查,可以大幅减少手写FFI胶水代码时引入的安全问题。
“关于 Rust 重写有害论,有人给出一个典故来类比 Rust 重写的重要性。很久之前,外科医生在进行侵入性手术之前并没有洗手的规定,因此许许多多的人因此而丧生。一位医生发现洗手可以避免这种问题,然而几乎每个人都与这位洗手倡导者进行了斗争,试图羞辱和抹黑他,试图制造胡说八道的证据来说洗手没有用,试图说服每个人洗手无关紧要,同时却对自己否认它的有效性。只有在那一代外科医生退休后,他们才一致开始洗手。这位医生叫 [Semmelweis](https://en.wikipedia.org/wiki/Ignaz_Semmelweis[1] 据称,1865年,Semmelweis 变得越来越直言不讳,最终遭遇了精神崩溃,并被同事送进了精神病院。
Rewrite it in Rust 是否有必要
从内存安全角度看,RIIR 是很有必要的
论文中提到的问题,确实是存在的。但是作者给出的 R3 系统也仅仅停留在概念层面(至少我没发现 R3 系统的存在)。
目前企业和社区中 Rust 与 C/Cpp 安全交互主要有三种方法:
- 建立 《Rust 编码规范》 和 《Unsafe 代码安全评审指南》,并加强 Unsafe 代码安全评审
- 建立 Rust 和 C 安全互操的库,比如
cxx
/autocxx
/ crubit[2];采用静态检测工具来发现 Unsafe 中的问题,比如 Miri 和 kani[3] 。 - 建立从 Unsafe 向 Safe 逐步演化的机制,从而让 Unsafe 逐步消失。
关于第一条,各个公司应该有自己的《Rust 编码规范》,比如 Google、facebook和华为等,只不过没有开源出来。我为此很早就创建了开源的《Rust 编码规范》[4] ,最近又新创建了《Rust 代码 Review 指南》[5],欢迎大家一起贡献。
关于第二条,`cxx` [6] 一直是 dtolnay 的个人项目,但是大公司比如 Google ,已经将其应用于 Android 里了。cxx
其实并不是非常通用,它也是基于 C-ABI ,提供了一些自动的安全封装。对于一些遗留的 Cpp 组件,并且不打算以及维护,但是还必须依赖它的时候,cxx
是最适合的。autocxx
是基于 cxx
的一个包装,也是 Google 参与一起搞的,另外 Google 还基于 cxx
搞了另外一个 Crubit 的库,目前应该是实验性,是为了提供更好更安全的 Rust 和 Cpp 交互。
关于第三条,建立从 Unsafe 向 Safe 逐步演化的机制具体是什么意思?因为现阶段 Unsafe 是无法被消除的,所以一个方法是,像 Rust for linux 那样,先创建一个 kernel-rs
crate,这个 crate里面,对 Linux 的 kernel api 进行了封装,充分考虑了 Rust 和 C 的 FFI 边界安全条件,进行了安全抽象,对外只提供 Safe Rust API ,从而形成 kernel-rs
。这样一来,Linux 内核的 Rust 开发者直接拿着 kernel-rs
开发,就不需要接触 Unsafe Rust 了。这个方法蕴含了一个思想,就是将 Unsafe 交给更专业的人来处理,其他人使用 Safe 。
对企业以及开源项目来说,这三种方法是可以同时实施的,以此来保证安全。相比于继续使用 C/Cpp 来说,用 Rust 重写带来的安全价值,更加丰厚。因为 Rust 在语言层面和社区文化都将促使开发者去充分的考虑安全问题,并给出最佳实践。即便无法百分百解决安全问题,那也是向百分百安全无限接近中。
Google 这类巨头已经给出了成效:Android 13 代码中引入了 150 多万行代码,消除了内存安全问题,安全 Bug 为零。话说回来,如果 Google 没有人认为现有的代码库中存在内存安全隐患,他们就不会将 C/Cpp 代码重写为 Rust ;他们之所以重写,是因为他们认为结果将会包含更少的隐患,即使考虑到FFI边界可能存在的问题。
“延伸:sudo-rs[7] 和 ntpd-rs[8] 这两个互联网基础工具也用 Rust 重写了。
从软件工程角度来看,RIIR 是很有必要的
除了避免内存不安全(包括并发)问题之外,事实上 Rust 在其他方面也表现出色,比如避免逻辑错误。
Rust 标准库和生态系统遵循着使正确的事情变得容易,而尽可能让错误的事情变得不可能的哲学。这得益于非常表达力的类型系统。
当然,在任何语言中都可能存在逻辑错误,不建议用 Rust 重写经过实战验证的 C/Cpp/Java 应用程序。但是,如果你已经决定重写(或开始一个全新的项目),那么选择Rust不仅仅是因为内存安全,还有更多的原因。
以下是你除了内存安全之外还值得选择 Rust 的原因:
- 工程性:Rust 的 trait 系统,促使开发者抛开继承去面向接口面向组合设计系统架构,这样可以降低系统耦合,让扩展更加容易。配合其他特性,让 Rust 代码更具可维护性。
- 健壮性:强大的类型系统和优雅的错误处理结合,促使开发者认真思考和设计系统中的错误处理。
我这里就不一一展开了,在未来的文章或者我的书里,会对此进行详细的展开。
后记
是否选择 Rust ,是否用 Rust 重写,选择权在你!但是为你的系统选择合适的语言,决定了你的系统可以走多远,因为语言是一切的基础。
感谢阅读!
参考资料
[1]
Semmelweis: https://en.wikipedia.org/wiki/Ignaz_Semmelweis
[2]
crubit: https://github.com/google/crubit
[3]
kani: https://github.com/model-checking/kani
[4]
《Rust 编码规范》: https://github.com/Rust-Coding-Guidelines/rust-coding-guidelines-zh
[5]
《Rust 代码 Review 指南》: https://github.com/ZhangHanDong/rust-code-review-guidelines
[6]
cxx
: https://github.com/dtolnay/cxx
[7]
sudo-rs: https://github.com/memorysafety/sudo-rs
[8]
ntpd-rs: https://github.com/pendulum-project/ntpd-rs