Rust漫画 #3 | 二次元 Rust Meetup 讨论会:Rewrite it in Rust 是否有害?

2023-10-25 12:38:54 浏览数 (2)

前言

创意:张汉东 绘画: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 函数参数会产生别名。

代码语言:javascript复制
#[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 来释放:

代码语言:javascript复制
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:

代码语言:javascript复制
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函数:

代码语言:javascript复制
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_slicedst_slice 地址不重叠(overlap)以进行编译时优化。

而这个 FFI 函数没有检查指针别名情况,C/C 调用时可能会违反这个不重叠要求,导致未定义行为。

解决方法是对 from_raw_parts 的参数进行安全判断,确保其不为空,且地址没有重叠等安全条件。

代码语言:javascript复制
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);
}
其他未定义行为

文章提到的其他未定义行为包括:

  1. ABI兼容性问题:不同编译器对 ABI 级别的优化处理可能不兼容,导致跨语言调用时 ABI 参数传递出错。例如 C 编译器会将多个 32 位参数打包到 64 位寄存器中,而 Rust 不会进行这样的优化。如果两边不一致就可能出错。
  2. 空指针访问:FFI 函数中没有充分校验指针参数是否为 null 就直接解引用,可能导致空指针访问错误。
  3. 缓冲区切片不当 :没有正确检查 bounds 就通过 from_raw_parts 创建缓冲区切片,可能会访问到不属于该缓冲区的内存。
  4. 移动语义错误:Rust 的移动语义要求在移动后不能再访问变量,但 FFI 代码可能错误地继续使用已经 move 了的变量。
  5. 粘合代码问题:很多问题源自需要通过 unsafe 代码进行参数转换和重建 Rust 抽象的粘合代码。这些转换做了许多假设,容易被 C/C 端的非法参数破坏。

这一类问题更加隐晦和具体,但也可能导致严重后果。

小结

文章提出了一个 R3 系统来帮助解决这些安全问题,该系统主要包含两部分内容:

  1. C/C 端的分配追踪器(allocator tracker)

这个组件可以跟踪C/C 应用中的内存分配情况,这样 Rust 端的 FFI 代码可以查询分配的元数据,确保满足Rust的一些安全不变式。例如跟踪已经转换到 Rust REFERENCE的指针,避免C 端释放 Rust 还在使用的内存导致的错误。

  1. 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

0 人点赞