一次Rust重写基础软件的实践(一)

前言

受到2022年“谷歌使用Rust重写Android系统且所有Rust代码的内存安全漏洞为零” [1] 的启发,最近笔者怀着浓厚的兴趣也顺应Rust 的潮流,尝试着将一款C语言开发的基础软件转化为 Rust 语言。本文的主要目的是通过记录此次转化过程中遇到的比较常见且有意思的问题以及解决此问题的方法与大家一起做相关的技术交流和讨论。

问题描述

在项目转化过程中我遇到了一个与 CAS (Compare and Swap) [2] 操作实现相关的问题,在计算机科学中CAS 是多线程/协程中用于实现同步的原子指令。该软件针对不同的芯片平台,通过在C语言中根据芯片平台的类别进行宏定义并嵌入相应的汇编代码来实现CAS操作。我知道不同芯片平台对应的 CAS 操作的汇编代码是不一样的 [3],例如:

x86-64 (Intel/AMD) 需要类似如下汇编代码块:

lock cmpxchgq [destination], rdx

ARM 需要类似如下汇编代码块:

ldrex r1, [destination]
    cmp r1, r2
    strexeq r1, r2, [destination]

PowerPC 需要类似如下汇编代码块:

lwarx r0, 0, destination
    cmpw r0, r1
    bne retry ; branch if not equal
    stwcx. r2, 0, destination
    bne retry ; branch if store failed

然而如下面的代码片段所示,即使该软件使用相同的Intel x86芯片平台,但是在不同的操作系统平台上其实现的汇编指令也有可能是不一样的。

  • C头文件中 cas_operation.h 的部分代码如下:

#if defined(__i386) || defined(__x86_64__) || defined(__sparcv9) || defined(__sparcv8plus)
typedef unsigned int slock_t;
#else
typedef unsigned char slock_t;
#endif
extern slock_t my_atomic_cas(volatile slock_t *lock, slock_t with, slock_t cmp);
#define TAS(a) (my_atomic_cas((a), 1, 0) != 0)
#endif
  • 对应实现的x86汇编文件 cas_operation.s 的部分代码如下:

my_atomic_cas:
#if defined(__amd64)
 movl       %edx, %eax
 lock
 cmpxchgl   %esi, (%rdi)
#else
 movl    4(%esp), %edx
 movl    8(%esp), %ecx
 movl    12(%esp), %eax
 lock
 cmpxchgl %ecx, (%edx)
#endif
 Ret

众所周知虽然Rust也有宏定义的包 Macros,但是目前也与C语言的有不小的差别。因此,在做转化的过程中如何做到芯片平台和操作系统级别的代码兼容则是我遇到的最大挑战。

解决方案

想到两个解决方案:

  1. 使用asm! 宏去处理不同芯片平台的汇编代码

  2. 使用 Rust代码对特定的操作进行针对性的实现

第一种方案比较简单,只需要在代码中使用std::arch::asm 包,然后使用 asm! 宏(类似 println! 宏)去包裹不同平台的汇编代码即可,这也是最直接最容易想到的解决方案,而且无需考虑具体的汇编操作实现的指令和代码。但是这方法杂糅了很多的不同平台的汇编代码,同时需要Rust做很多额外的平台相关的逻辑控制,对这些控制逻辑部分代码的维护也是一个持久且复杂的工作。比如对新的平台指令 RSIC-V 的支持也要纳入其中。

第二种方案则需要考虑具体的操作逻辑,然后通过Rust代码去实现与汇编指令相同的逻辑,虽然有较大的工作量,但是这种方案可以消除由于芯片和系统平台不同带来的各种汇编代码实现的差异。 关于第一种方案的实现读者可以参照文档 Inline assembly [4] 中去做。针对 CAS 操作的第二种方案的实现则是本文主要提出的一种解决方案,而本文以类似Rust u32类型的 CAS 操作为例子实现其代码,在 my_compare_and_swap.rs 中会有如下代码段实现:

use std::sync::atomic::{AtomicU32, Ordering};

pub type uint32 = libc::c_uint;
pub struct my_atomic_uint32 {
 pub value: uint32,
}

impl my_atomic_uint32 {
    #[inline]
    pub fn compare_and_swap(&self, expected: uint32, newval: uint32) -> bool {
        let atomic_ptr = self as *const my_atomic_uint32 as *const AtomicU32;
        let atomic = unsafe { &*(atomic_ptr) };
        atomic.compare_and_swap(expected, newval, Ordering::SeqCst) == expected
    }
}

pub fn my_compare_and_swap_u32_impl(
    mut ptr: *mut pg_atomic_uint32,
    mut expected: *mut uint32,
    mut newval: uint32,
) -> bool {
 let atomic = &*ptr;
 atomic.compare_and_swap(*expected, newval)
}

下面我来解释一下上面的代码。由于是从 C 转到 Rust,因此我使用了 Rust 的 libc 包来自定义 uint32类型。然后通过自定义struct my_atomic_uint32 来对uint32进行CAS原子操作的包裹,同时对于此 struct实现其 inline 的compare_and_swap 操作函数。在该函数的实现中最关键的是将my_atomic_uint32的实体转化为一个AtomicU32的常量(注意需要在 Rust 代码文件开头使用 std::sync::atomic::{AtomicU32, Ordering} [5]),然后通过调用 AtomicU32 的compare_and_swap 来最终实现 uint32 的 CAS 操作。另外对于Ordering::SeqCst内存顺序 [6] 的选择也是比较考究的一个话题,这里我使用 SeqCst实际上是一个在保证正确的情况下不太考虑效率优化问题的选项。代码的最后my_compare_and_swap_u32_impl 则是对外使用的 u32 的 CAS 操作(事实上该软件主要也是需要实现 uint32 的 CAS 操作)。

结论

在本例中由于刚好有对应AtomicU32的CAS 实现,而且软件中整个原子同步的代码部分都是使用uint32进行的比较交换操作,因此我选择第二种方案则是最佳选择。由此可知上述的两种解决方案其实是各有利弊的,我必须结合实际的应用场景才能去做决定。那么这里有一个问题,如果需要对许多数据类型(比如uint32, int32, uint64, int64, float, float32, float64……)进行比较交换操作,又该做何种选择呢?这也许是仁者见仁智者见智的。

关于作者

张怀龙曾就职于阿尔卡特朗讯,百度,IBM等企业从事云计算研发相关的工作。目前就职于 Intel 中国,担任云原生开发工程师并致力于云原生、服务网格等技术领域研究实践,也是Istio 的maintainer的开发者。曾多次在 KubeCon、ServiceMeshCon、IstioCon、GOTC 和 InfoQ/QCon 等大会上发表演讲。

参考文档:

  • [1] https://security.googleblog.com/2022/12/memory-safe-languages-in-android-13.html

  • [2] https://en.wikipedia.org/wiki/Compare-and-swap

  • [3] https://marabos.nl/atomics/hardware.html

  • [4] https://doc.rust-lang.org/reference/inline-assembly.html

  • [5] https://doc.rust-lang.org/std/sync/atomic

  • [6] https://marabos.nl/atomics/memory-ordering.html

你可能感兴趣的:(rust,开发语言,后端)