RUST Part4

Unsafe Rust

计算机硬件本身是 unsafe 的,比如操作 IO 访问外设,或者使用汇编指令进行特殊操作(操作 GPU或者使用 SSE 指令集)。因为这些操作,编译器是无法保证内存安全的。当 Rust 要访问其它语言比如 C/C++ 的库,因为它们并不满足 Rust 的安全性要求,这种跨语言的 FFI(Foreign Function Interface),也是 unsafe 的。一般使用场景有:

  • 实现 unsafe trait
  • 调用已有 unsafe 接口
  • 裸指针解引用

unsafe trait

1
2
pub unsafe auto trait Send {}
pub unsafe auto trait Sync {}

绝大多数数据结构都实现了 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 实现这个 trait 的开发者要保证实现是内存安全的
unsafe trait Foo {
fn foo(&self);
}

trait Bar {
// 调用这个函数的人要保证调用是安全的
unsafe fn bar(&self);
}

struct Nonsense;

unsafe impl Foo for Nonsense {
fn foo(&self) {
println!("foo!");
}
}

impl Bar for Nonsense {
unsafe fn bar(&self) {
println!("bar!");
}
}

fn main() {
let nonsense = Nonsense;
// 调用者无需关心 safety
nonsense.foo();

// 调用者需要为 safety 负责
unsafe { nonsense.bar() };
}

unsafe trait 是对 trait 的实现者的约束。unsafe fn 是函数对调用者的约束。

调用已有的 unsafe 函数

比如,memory transmute:

1
2
3
4
5
6
7
fn explain<K, V>(name: &str, map: HashMap<K, V>) -> HashMap<K, V> {
let arr: [usize; 6] = unsafe { std::mem::transmute(map) };
// show as arr
show_arr(&arr);
// transmute to map
unsafe { std::mem::transmute(arr) }
}

带合法性校验的函数和不带合法性校验的函数:

1
2
3
4
5
6
7
8
9
10
11
pub fn from_utf8(v: &[u8]) -> Result<&str, Utf8Error> {
run_utf8_validation(v)?;
// SAFETY: Just ran validation.
Ok(unsafe { from_utf8_unchecked(v) })
}

pub const unsafe fn from_utf8_unchecked(v: &[u8]) -> &str {
// SAFETY: the caller must guarantee that the bytes `v` are valid UTF-8.
// Also relies on `&str` and `&[u8]` having the same layout.
unsafe { mem::transmute(v) }
}

如果你清楚地知道,&[u8] 你之前已经做过检查,或者它本身就来源于你从 &str 转换成的 &[u8],现在只不过再转换回去,那可以调用不安全的版本,并在注释中注明为什么这里是安全的。

裸指针解引用

裸指针在生成的时候无需 unsafe,因为它并没有内存不安全的操作,但裸指针的解引用操作是不安全的,潜在有风险,它也需要使用 unsafe 来明确告诉编译器。

使用裸指针,可变指针和不可变指针可以共存,不像可变引用和不可变引用无法共存。使用裸指针时,需要注意指针是否来源于合法的内存地址。

可以参照 std::ptr 文档学习。

使用 FFI

当 Rust 要使用其它语言的能力时,Rust 编译器并不能保证那些语言具备内存安全,所以和第三方语言交互的接口,一律要使用 unsafe。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::mem::transmute;

fn main() {
let data = unsafe {
let p = libc::malloc(8);
let arr: &mut [u8; 8] = transmute(p);
arr
};

data.copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);

println!("data: {:?}", data);

unsafe { libc::free(transmute(data)) };
}

不推荐使用 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
2
3
4
5
#[no_mangle]
pub extern "C" fn hello_world() -> *const c_char {
// C String 以 "\0" 结尾
"hello world!\0".as_ptr() as *const c_char
}

在 Cargo.toml 中,crate 类型要设置为 crate-type = [“cdylib”]。

下面的代码存在内存泄漏:

1
2
3
4
5
6
#[no_mangle]
pub extern "C" fn hello_bad(name: *const c_char) -> *const c_char {
let s = unsafe { CStr::from_ptr(name).to_str().unwrap() };

format!("hello {}!\\0", s).as_ptr() as *const c_char
}

format!("hello {}!\0", s) 生成了一个字符串结构,as_ptr() 取到它堆上的起始位置,我们也保证了堆上的内存以 NULL 结尾,看上去没有问题。然而,在这个函数结束执行时,由于字符串 s 退出作用域,所以它的堆内存会被连带 drop 掉。因此,这个函数返回的是一个悬空的指针,在 C 那侧调用时就会崩溃。

正确的写法应该是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[no_mangle]
pub extern "C" fn hello(name: *const c_char) -> *const c_char {
if name.is_null() {
return ptr::null();
}

if let Ok(s) = unsafe { CStr::from_ptr(name).to_str() } {
let result = format!("hello {}!", s);
// 可以使用 unwrap,因为 result 不包含 \0
let s = CString::new(result).unwrap();

s.into_raw()
// 相当于:
// let p = s.as_ptr();
// std::mem::forget(s);
// p
} else {
ptr::null()
}
}

into_raw() 来让 Rust 侧放弃对内存的所有权。更安全的写法,需要加上 catch_unwind,来防止潜在的 panic!()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#[no_mangle]
pub extern "C" fn hello(name: *const c_char) -> *const c_char {
if name.is_null() {
return ptr::null();
}

let result = catch_unwind(|| {
if let Ok(s) = unsafe { CStr::from_ptr(name).to_str() } {
let result = format!("hello {}!", s);
// 可以使用 unwrap,因为 result 不包含 \\0
let s = CString::new(result).unwrap();

s.into_raw()
} else {
ptr::null()
}
});

match result {
Ok(s) => s,
Err(_) => ptr::null(),
}
}

如果是在 Rust 侧申请的字符串,也需要使用 Rust 所有权机制来释放内存:

1
2
3
4
5
6
#[no_mangle]
pub extern "C" fn free_str(s: *mut c_char) {
if !s.is_null() {
unsafe { CString::from_raw(s) };
}
}

C 代码必须要调用这个接口安全释放 Rust 创建的 CString。如果不调用,会有内存泄漏;如果使用 C 自己的 free(),会导致未定义的错误。

写好 Rust shim 代码后,接下来就是生成 C 的 FFI 接口了。一般来说,这个环节可以用工具来自动生成。比如,cbindgen


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!