Skip to content

13.3 内存不安全示例:迭代器失效

如果在遍历一个数据结构的过程中修改这个数据结构,会导致迭代器失效。比如在 C++ 里面,我们可能写出这样的代码:

c++
#include <vector>

using namespace std;
int main() {
    vector<int> v(10,10);

    for (vector<int>::iterator i = v.begin(); i != v.end(); i++) {
        if (*i % 2 == 0) { // when arbitrary condition satisfied
            v.clear();
        }
    }
    return 0;
}

编译,执行,发现程序崩溃了。原因就在于我们在迭代的过程中,数组v直接被清空了,而迭代器并不知道这个信息,它还在继续进行迭代,于是出现了“野指针”现象,此时迭代器实际上指向了已经被释放的内存。迭代器失效这样的问题在 C++ 中是“未定义行为”,也就是说可能发生什么后果都是未知的。这是一种典型的内存不安全行为。

然而,在 Rust 里面,下面这样的代码是不允许编译通过的:

rust
fn main() {
    let mut arr = vec!["ABC", "DEF", "GHI"];
    for item in &arr {
        arr.clear();
    }
}

为什么 Rust 可以避免这个问题呢?因为 Rust 里面的 for 循环实质上是生成了一个迭代器,它一直持有一个指向容器的引用,在迭代器的生命周期内,任何对容器的修改都是无法编译通过的。类似这样:

rust
{   //以下是伪代码
    // 在 iter 变量的生命周期内,它都持有一个指向 arr 的引用
    let iter<'a> = into_iter(&'a arr);
    loop  {
        match iter.next() {
// 如果需要使用 arr 的 &mut 指针,则会发生冲突

// &mut arr 和 &arr 不能同时存在,它违反了 Rust 内存安全的原则
            Some(i) => { arr.clear(); }
            None => break ,
        }
    }
}

在整个 for 循环的范围内,这个迭代器的生命周期都一直存在。而它持有一个指向容器的引用,&型或者&mut型,根据情况而定。迭代器的 API 设计是可以修改当前指向的元素,没办法修改容器本身的。当我们想在这里对容器进行修改的时候,必然需要产生一个新的针对容器的&mut 型引用,(clear 方法的签名是Vec::clear(&mut self),调用clear必然产生对原Vec&mut型引用)。这是与 Rust 的“alias+mutation”规则相冲突的,所以编译不通过。

为什么在 Rust 中永远不会出现迭代器失效这样的错误?因为通过“mutation+alias”规则,就可以完全杜绝这样的现象,这个规则是 Rust 内存安全的根,是解决内存安全问题的灵魂。 Rust 不是针对各式各样的场景,用 case by case 的方式来解决内存安全问题。而是通过一种统一的机制,高屋建瓴地解决这一类问题,快刀斩乱麻,直击要害。

面对类似迭代器失效这一类的、指针指向非法地址的内存安全问题,许多语言都无法做到静态检查出来。比如在 Java 中出现这样的问题的时候,编译器是没法检查出来的,在执行阶段,程序会抛出一个异常“Exception in thread“main”java.util.ConcurrentModificationException”。因为我们在 for 循环内部对容器本身做了修改,Java 容器探测到了这种修改,然后就阻止了逻辑的继续执行,抛出了异常。Java 的这个设计相比 C++ 要好很多,因为即便出现了迭代器失效,最多引发异常,而并不会有“野指针”这样的内存安全问题,因为迭代器没有机会访问已经被释放的非法内存。然而“抛出异常”并不是一个完美的设计,只是不得已而为之罢了。因为异常本来的设计目的是为了处理外部环境难以预计的错误的,而现在的这个错误实际上是程序的逻辑错误,即便抛出了异常,外部逻辑捕获了这个异常,也没什么好办法来处理。唯一合理的修复方案是,发现这样的异常之后,回过头来修复代码错误。这样的问题如果在编译阶段就能得到发现和解决,才是最合适的解决方案。在遍历容器的时候同时对容器做修改,可能出现在多线程场景,也可能出现在单线程场景。

类似这样的问题依靠 GC 也没办法处理。GC 只关心内存的分配和释放,对于变量的读写权限是不关心的。GC 在此处发挥不了什么作用。

而 Rust 依靠我们前面强调的“alias+mutation”规则就可以很好地解决该问题。这个思路的核心就是:如果存在多个只读的引用,是允许的;如果存在可写的引用,那么就一定不能同时存在其他的只读或者可写的引用。大家看到这个逻辑,是不是马上联想到多线程环境下的“ReadWriteLocker”?事实也确实如此。Rust 检查内存安全的核心逻辑可以理解为一个在编译阶段执行的读写锁。多个读同时存在是可以的,存在一个写的时候,其他的读写都不能同时存在。

大家还记不记得,Rust 设计者总结的 Rust 的三大特点:一是快,二是内存安全,三是免除数据竞争。 由上面的分析可见,Rust 所说的“免除数据竞争”,实际上和“内存安全”是一回事。“免除数据竞争”可以看作多线程环境下的“内存安全”。

单线程环境下的“内存安全”靠的是编译阶段的类似读写锁的机制,与多线程环境下其他语言常用的读写锁机制并无太大区别。 也正因为 Rust 编译器在设计上打下的良好基础,“内存安全”才能轻松地扩展到多线程环境下的“免除数据竞争”。这两个概念其实只有一箭之隔。所以我们可以理解 Java 将此异常命名为“Concurrent”的真实含义——这里的“Concurrent”并不是单指多线程并发。

Released under the MIT License