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

2024-01-30 12:23:35 浏览数 (1)

前言

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

问题描述

本篇博客继续此次重写实践过程中遇到的新问题:panic 错误处理的问题。大家都知道 Rust 的错误处理机制 [2] 本质上可以分为 Unrecoverable ErrorsRecoverable Errors。对于前者,当非常糟糕的情况出现时用户可以选择通过 panic! 宏来创建不可恢复的错误(当然也有可能是由于代码运行时出现的隐式错误,例如除零,数组越界等)。对于后者,一般会通过 Rust 的 Result(其本质是一个特别的枚举类型,只含有 OKErr 两个枚举成员)来处理可能出现的错误,如文件打开错误,文件读写错误等。关于 除零 的 panic 错误有一点需要解释一下。得益于 Rust 强大的编译器,与其他编程语言如 C 和 Golang 不太一样,如下的 Rust 代码其实在编译阶段就会报错:

代码语言:javascript复制
fn main() {
    let numerator = 42;
    let denominator = 0;

    // This line will result in a compilation error
    let result = numerator / denominator;

    println!("Result: {}", result);
}

对于上面的代码编译器会报错如下(我环境中的 Rust 版本为:rustc 1.75.0 (82e1608df 2023-12-21)):

代码语言:javascript复制
error: this operation will panic at runtime
 --> src/main.rs:6:18
  |
6 |     let result = numerator / denominator;
  |                  ^^^^^^^^^^^^^^^^^^^^^^^ attempt to divide `42_i32` by zero
  |
  = note: `#[deny(unconditional_panic)]` on by default

额外说明一下:我指出这个问题并不是说 除零 的错误不会在 Rust 中发生,而是想说明 Rust 语言本身是尽可能在编译阶段就帮助工程师找出代码错误,使其代码更健壮可靠。

言归正传,我遇到的问题就是需要处理 Rust 代码中出现的运行时 Unrecoverable Errors,不能让程序由于这种不可恢复的错误停下来。有读者可能会问:既然 Rust 定义了 Unrecoverable Errors, 那就是不可恢复的错误,我为什么还固执的需要处理这种错误呢?回答这个问题还是需要结合我的场景来讨论。首先既然我的场景是把 C 语言编写的一个基础软件转化为 Rust (暂时还不能实现 100% 的转化),因此就会有些情况与完全用 Rust 编写的项目不太一样。并且我认为一个项目中既有 C 代码又有 Rust 代码的情形在未来很长的时间里将会是一个常态(比如目前 Linux 已经有 Rust 实现的 patch,未来相信还会有其他的 Rust patch)。关于上面提到的 “不一样” 的情形,在此我可以举一个例子。大家知道,在 C 语言中将一个数组作为参数传递给一个函数有如下三种方式 [3] :

  1. 将数组作为指针变量传递给函数
代码语言:javascript复制
void foo(int* array)
  1. 将数组作为一个引用传递给函数
代码语言:javascript复制
void foo(int array[])
  1. 将数组以一个指定 size 大小的数组传递给函数
代码语言:javascript复制
void foo(int array[SIZE])

在 C 语言中有多种方式把一个数组传递给函数,不仅如此,大家知道在 C 语言中出现数组越界访问时,其行为是不可预测的,即有可能出错,也有可能不出错。那么针对这种情形,当我们需要把浩瀚的 C 代码转化为 Rust 代码的时候,原来 C 代码可能不会报错,但是 Rust 代码中却会出现数组访问越界的 panic 错误,当然这只是一个个例。在 Rust 中,大家习惯性的使用 unwrap() 去处理可能出现的 panic 错误,在纯 Rust 的项目中也许大家有足够的信心进退自如,去决定该怎样处理这样的问题。但是在混合状态下,比如 C 和 Rust 相互混合的项目中,在某些情况下由于类似的问题会导致整个程序终止,这些行为也许并不是我们预期的。因此在处理混合项目中出现隐式 panic 错误时,使其在隐式的 panic 错误发生后依然能够被正确处理而不会使整个程序终止,则是我在此次实践中需要解决的问题。

解决方案

在解决这个问题时,我首先考虑到的是在 Rust 中寻找类似 Golang 的 panic 恢复机制 [4]。遗憾的是,虽然 Rust 提供了 panic hook [5] 的机制,允许用户在 panic 错误发生时自定义一些行为,但是 panic hook 并不能解决程序终止的问题,所以目前看来,Rust 中并没有类似的 panic 恢复机制,并且不十分坚定的认为:不可恢复的错误就不应该恢复。我之所以说是“不十分坚定”是因为 Rust 在 std::panic::catch_unwind [6] 中给我解决这个问题留下了一定的空间。std::panic::catch_unwind 主要是通过调用一个闭包来捕获在其中可能发生的 panic 错误。而我也基于这个办法,在做了相应的试验后,将其运用到了转化的项目中,同时我把试验的样本代码放到了我的 github [7] 里,欢迎大家一起交流。在样本代码中,主要有两个文件夹分别对应两种情况:

  1. rust-panic-without-handling 是没有处理 panic 错误的二进制程序代码文件夹。
  2. rust-panic-with-handling 是通过 std::panic::catch_unwind 处理了许多 panic 错误的二进制程序代码文件夹。这些 panic 错误包括:divided by zeroInvalidDigitout of index range panic

上面两个 Rust 程序试验的逻辑主要是用户通过标准 IO 输入做 3 次循环输入,每次输入计算所需的 分子分母,然后通过 Rust 代码做 分子/分母 的操作计算,再之后将计算结果存储到固定长度为 3i32 数组中,最后遍历该数组,并输出数组中的值。试验场景如下:

  1. 可以在任意的输入循环中,将 分母 输入为 0 引发 divided by zero panic 错误
  2. 可以在任意的输入循环中输入非数字的值,如输入 56x 引发 InvalidDigit panic 错误
  3. 也是肯定要发生的错误。通过访问从 03 的元素索引固定长度为 3 的数组来引发 out of index range panic 错误

对于不处理 panic 错误的样本代码如下:

代码语言:javascript复制
use std::io;
use std::io::Write;

fn main() {
    let mut try_times: i32 = 0;
    let mut int_array: [i32; 3] = [0; 3];
    println!("n ###### Divide by zero ###### n");
    while try_times < 3 {
        let current_time = try_times as usize;
        
        // Get numerator from user input
        let mut numerator = String::new();
        print!("Please input the numerator: ");
        io::stdout().flush().unwrap();
        io::stdin().read_line(&mut numerator).expect("Failed to read line");
        let numerator: i32 = numerator.trim().parse().expect("Invalid input");
        
        // Get denominator from user input
        let mut denominator = String::new();
        print!("Please input the denominator: ");
        io::stdout().flush().unwrap();
        io::stdin().read_line(&mut denominator).expect("Failed to read line");
        let denominator: i32 = denominator.trim().parse().expect("Invalid input");
        
        // Perform division without validation
        int_array[current_time] = numerator / denominator;
        println!("Result is: {:?}", int_array[current_time]);
        try_times  = 1;
        println!("##########################################");
    }

    println!("n @@@@@@ Iteration @@@@@@ n");
    for i in 0..=3 {
        println!("Iterate Element: {}", int_array[i]);
        println!("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@");
    }
    
    println!("Complete the panic handle examples!");
}

由上面的 Rust 代码可知,无论任何 panic 错误被触发,整个程序立即终止,而且对于最后一行代码 println!("Complete the panic handle examples!"); 的输出是永远也看不到的。

对于通过 std::panic::catch_unwind 处理 panic 错误的样本代码如下:

代码语言:javascript复制
use std::io;
use std::io::Write;
use std::panic;

fn main() {
    let mut try_times: i32 = 0;
    let mut int_array: [i32; 3] = [0; 3];
    println!("n ###### Divide by zero ###### n");
    while try_times < 3 {
        let current_time = try_times as usize;

        // Handle divide by zero panic
        let result_value = panic::catch_unwind(|| {
            println!("This is the {}th to handle panic.", current_time);
            // Get numerator from user input
            let mut numerator = String::new();
            print!("Please input the numerator: ");
            io::stdout().flush().unwrap();
            io::stdin().read_line(&mut numerator).expect("Failed to read line");
            let numerator: i32 = numerator.trim().parse().expect("Invalid input");
        
            // Get denominator from user input
            let mut denominator = String::new();
            print!("Please input the denominator: ");
            io::stdout().flush().unwrap();
            io::stdin().read_line(&mut denominator).expect("Failed to read line");
            let denominator: i32 = denominator.trim().parse().expect("Invalid input");
        
            // Perform division without validation
            numerator / denominator
        });

        match result_value {
            Ok(result) => {
                println!("No panic occur and the result is: {:?}", result);
                int_array[current_time] = result;
            },
            Err(e) => {
                if let Some(err) = e.downcast_ref::<&str>() {
                    println!("Caught panic: {}", err);
                } else {
                    println!("Caught panic of unknown type");
                }
            },
        };

        try_times  = 1;
        println!("##########################################");
    }

    println!("n @@@@@@ Iteration @@@@@@ n");

    for i in 0..=3 {
        // Handle out of index range panic
        let num_result = panic::catch_unwind(|| {
            println!("Iterate Element: {}", int_array[i]);
        });

        match num_result {
            Ok(()) => {
                println!("No panic occur for this iteration");
            },
            Err(e) => {
                if let Some(err) = e.downcast_ref::<&str>() {
                    println!("Caught panic: {}", err);
                } else {
                    println!("Caught panic of unknown type");
                }
            },
        };
        println!("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@");
    }
    
    println!("Complete the panic handle examples!");
}

由上面的 Rust 代码可知,无论任何一个或多个 panic 错误被触发,整个程序始终会一直执行,直到 println!("Complete the panic handle examples!"); 的输出。

对于处理了 panic 错误的代码,我需要做出一些说明和解释。首先 std::panic::catch_unwind 是一个闭包调用,所以对于变量的处理需要谨慎一些。如上所示,在闭包调用中,需要使用到 current_time 来处理数组对应索引元素的更新,该变量不能是可变的 (不能定义为 mut ),所以我做了 let current_time = try_times as usize; 的处理。为什么该闭包中必须是不可变的变量,原因与该闭包传入的数据类型可能实现的 UnwindSafe trait 相关,读者可以去了解需要实现该 trait 的数据类型,本例中是 &i32。读者亦可以删除我处理的相关代码以后看一下报错信息。其次,如果该闭包调用需要返回信息给外部使用,那么可以将返回信息放入调用的返回值中,如上代码所示第一个闭包调用返回的 result_value 会被紧接的 match 代码所使用。最后是一个建议,当使用该闭包的时候请包含尽量少的逻辑代码来实现 panic 错误的捕获,这样可以控制传入的数据类型(受闭包调用的数据类型的限制),同时也能使得 panic 错误的捕获更加精准。当然,std::panic::catch_unwind 是有许多限制的。如文档中所说:它并不能捕获所有的 panic 错误,该函数只捕获展开式 panic,而不捕获终止进程的情况。如果用户已设置了自定义 panic hook,它将在捕获 panic 错误之前被调用,即在展开之前,所以这时候使用 catch_unwind 去捕获 panic 错误可能没有用。另外,使用外部异常(例如从 C 代码抛出的异常)展开进入 Rust 代码是未定义行为。

总结

本文主要是实现了项目场景中在遇到某些 panic 错误后,对错误进行程序恢复,使得运行程序不会被动终止的方案。在经过调研后发现,Rust 并没有提供整体的 panic 错误恢复机制,但是在综合考虑项目需求和 Rust 提供 std::panic::catch_unwind 后,测验并解决了恢复程序运行的基本功能 且基本满足当前的实践需求。但是需要指出的是,std::panic::catch_unwind 是有一些限制的,并不能完全捕获所有的 panic 错误,因此希望读者在各自项目使用过程中对该方案仍然需要保持谨慎态度。

0 人点赞