RUST Part4
Unsafe Rust
计算机硬件本身是 unsafe 的,比如操作 IO 访问外设,或者使用汇编指令进行特殊操作(操作 GPU或者使用 SSE 指令集)。因为这些操作,编译器是无法保证内存安全的。当 Rust 要访问其它语言比如 C/C++ 的库,因为它们并不满足 Rust 的安全性要求,这种跨语言的 FFI(Foreign Function Interface),也是 unsafe 的。一般使用场景有:
- 实现 unsafe trait
- 调用已有 unsafe 接口
- 裸指针解引用
unsafe trait
1 |
|
绝大多数数据结构都实现了 Send / Sync,但有一些例外,比如 Rc / RefCell / 裸指针 等。Send / Sync 是 auto trait,所以大部分情况下,不需要实现 Send / Sync,然而,在数据结构里使用裸指针时,因为裸指针是没有实现 Send/Sync 的,整个数据结构也就没有实现 Send/Sync。
如果能保证指针可以在线程中安全移动,那么可实现数据结构的 Send trait;如果可以保证指针能在线程中安全地共享,那么可实现 Sync trait。
在实现 Send/Sync 的时候,如果你无法保证数据结构的线程安全,错误实现 Send/Sync之后,会导致程序出现莫名其妙的还不太容易复现的崩溃。
任何 trait,只要声明成 unsafe,它就是一个 unsafe trait。而一个正常的 trait 里也可以包含 unsafe 函数。
1 |
|
unsafe trait 是对 trait 的实现者的约束。unsafe fn 是函数对调用者的约束。
调用已有的 unsafe 函数
比如,memory transmute:
1 |
|
带合法性校验的函数和不带合法性校验的函数:
1 |
|
如果你清楚地知道,&[u8] 你之前已经做过检查,或者它本身就来源于你从 &str 转换成的 &[u8],现在只不过再转换回去,那可以调用不安全的版本,并在注释中注明为什么这里是安全的。
裸指针解引用
裸指针在生成的时候无需 unsafe,因为它并没有内存不安全的操作,但裸指针的解引用操作是不安全的,潜在有风险,它也需要使用 unsafe 来明确告诉编译器。
使用裸指针,可变指针和不可变指针可以共存,不像可变引用和不可变引用无法共存。使用裸指针时,需要注意指针是否来源于合法的内存地址。
可以参照 std::ptr 文档学习。
使用 FFI
当 Rust 要使用其它语言的能力时,Rust 编译器并不能保证那些语言具备内存安全,所以和第三方语言交互的接口,一律要使用 unsafe。
1 |
|
不推荐使用 unsafe 的场景
比如处理未初始化数据、访问可变静态变量、使用 unsafe 提升性能。
Rust 支持可变的 static 变量,可以使用 static mut 来声明。全局变量如果可写,会潜在有线程不安全的风险,所以如果你声明 static mut 变量,在访问时,统统都需要使用 unsafe。使用 Mutex、Atom 作为替代是更好的选择。
在宏定义中视同 unsafe ,一个问题就是不易被使用者察觉,使用者的负担较大,以及潜在问题不容易发现。
使用 unsafe 代码来优化性能,比如
unsafe { BitMask(x86::_mm_movemask_epi8(self.0) as u16) }
SIMD
加速,属于比较细节的点。一般而言不是在性能的关键路径下,没有太大必要,当然也不是不行。如果对自己而言,实现这种代码是一种负担,这种细节问题,还是在它成为一个关键问题时再考虑也不迟。
FFI
Foreign Function Interface 属于比较底层的内容。Python 中的 numpy,Rust 中的 OpenSSL ,都是常用的使用 C 作为底层实现的库。
当然现在也有使用 Rust 作为底层的 C/C++ 库,比如 quiche 和 Rustls。除了用 C/C++ 做底层外,越来越多的库会先用 Rust 实现,再构建出对应 Python(pyo3)、JavaScript(wasm)、Node.js(neon)、Swift(uniffi)、Kotlin(uniffi)等实现。
Rust 使用 C/C++ 库,可以使用 bindgen 生成 Rust FFI 代码。使用 FFI 的示例项目可参考 Rust-RocksDB
注意事项
- C string 是 NULL 结尾,与 Rust String 是不同的结构。Rust 提供了 std::ffi 来处理这样的问题,比如 CStr 和 CString 来处理字符串。
- Rust 的内存分配器和其它语言的可能不一样,所以,Rust 分配的内存在 C 的上下文中释放,可能会导致未定义的行为。
- 使用 thiserror 或者类似的机制来定义所有 C 语言中 error code 对应的错误情况。
Rust 编译为 C
要把 Rust 代码和数据结构提供给 C 使用,我们首先要构造相应的 Rust shim 层,把原有的、正常的 Rust 实现封装一下,便于 C 调用。
- 提供 Rust 方法、trait 方法等公开接口的独立函数。注意 C 是不支持泛型的,所以对于泛型函数,需要提供具体的用于某个类型的 shim 函数。
- 所有要暴露给 C 的独立函数,都要声明成 #[no_mangle],不做函数名称的改写。
- 数据结构需要处理成和 C 兼容的结构。如果是你自己定义的结构体,需要使用 #[repr C],对于要暴露给 C 的函数,不能使用 String / Vec / Result 这些 C 无法正确操作的数据结构。
- 要使用 catch_unwind 把所有可能产生 panic! 的代码包裹起来。其它语言调用 Rust 时,遇到 Rust 的 panic!(),会导致未定义的行为,所以在 FFI 的边界处,要 catch_unwind,阻止 Rust 栈回溯跑出 Rust 的边界。
示例:
1 |
|
在 Cargo.toml 中,crate 类型要设置为 crate-type = [“cdylib”]。
下面的代码存在内存泄漏:
1 |
|
format!("hello {}!\0", s) 生成了一个字符串结构,as_ptr() 取到它堆上的起始位置,我们也保证了堆上的内存以 NULL 结尾,看上去没有问题。然而,在这个函数结束执行时,由于字符串 s 退出作用域,所以它的堆内存会被连带 drop 掉。因此,这个函数返回的是一个悬空的指针,在 C 那侧调用时就会崩溃。
正确的写法应该是:
1 |
|
into_raw() 来让 Rust 侧放弃对内存的所有权。更安全的写法,需要加上 catch_unwind,来防止潜在的 panic!()。
1 |
|
如果是在 Rust 侧申请的字符串,也需要使用 Rust 所有权机制来释放内存:
1 |
|
C 代码必须要调用这个接口安全释放 Rust 创建的 CString。如果不调用,会有内存泄漏;如果使用 C 自己的 free(),会导致未定义的错误。
写好 Rust shim 代码后,接下来就是生成 C 的 FFI 接口了。一般来说,这个环节可以用工具来自动生成。比如,cbindgen。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!