Skyline75489 Home About

Rust——内存管理的究极进化?

:因为内存管理这个话题实在过于庞大,本文没有打算对于这个话题进行多么深刻和全面的探讨,只是作者本身的一点思考,记录在此。

C++——江山代有才人出

在 C 语言的世界中,所有的内存管理都是手动的,甚至并没有引用这种东西存在,唯一存在的是指针(pointer)——一个威力和杀伤力都十分巨大的东西。

int *p = (int *)malloc(sizeof(int));
*p = 10;
free(p);
// p 成为了野指针
int b = *p; // 危险操作!

上面的代码可以通过编译,没有任何警告,但是实际上已经跑飞了。C 语言要求程序员手动对内存进行管理,编译器却没有对于程序行为进行足够多的检查,加上指针操作本身十分灵活,如果没有对于程序足够强的掌控能力,程序的可维护性很可能将成为一场灾难。

C 语言的后辈 C++,在内存管理上做出了一些新的尝试,一个比较大的变化就是增加了引用类型。和指针相比,引用类型在安全上的限制更加严格了:

1 引用必须被初始化,必须有初始化绑定,且不能初始化为 nullptr

int &r1; // error: references must be initialized
int &r2 = 0; // error: cannot convert from 'int' to 'int &'
int &r3 = nullptr; // error: cannot convert from 'nullptr' to 'int &'

2 引用不能被重新绑定

int a1 = 5;
int a2 = 6;

int &b = a1; // b 和 a1 绑定在一起,并且再也不能绑定别的值
b = a2; // a1 的值也被改变,a1 = a2 = 6

3 引用没有多层关系,即没有引用的引用

int x = 0;
int y = 0;
int *p = &x;
int *q = &y;
int **pp = &p;
pp = &q; // *pp = q,而 p 不受影响
**pp = 4;
assert(y == 4);
assert(x == 0);

4 引用作为返回值,如果用法不当,会有 warning

int * squarePtr(int number) {
  int localResult = number * number;
  return &localResult; // warning: returning address of local variable or temporary: localResult
}

int & squarePtr(int number) {
  int localResult = number * number;
  return localResult; // warning: returning address of local variable or temporary: localResult
}

可以看到,C++ 的引用某种程度上是想引入更加严格的生命周期管理,不过限于很多地方对于 C 语言兼容的要求,引用类型往往还是被当做一个 const 的指针来使用。C++ 的引用更像是对象的一个 alias,而不是一个可以保证对象存活的 reference。同时 C++ 引用也没有(想)解决更加 common 的内存过早释放问题:

const char * getString() {
  std::string(a);
  return a.c_str(); // a 提前释放,返回值不再有意义
}

C++ 语言在 C++ 11 标准中引入了智能指针类型,让 C++ 内存管理进入了自动时代。智能指针类型本身是基于引用计数的,可以表达出对于对象的“拥有”关系。

1 unique_ptr 唯一拥有

void SmartPointerDemo()
{    
    // 创建对象 unique_ptr
    std::unique_ptr<LargeObject> pLarge(new LargeObject());

    // 调用方法
    pLarge->DoSomething();

    // 传递对象引用
    ProcessLargeObject(*pLarge);

} // 对象出 scope 被自动释放

2 shared_ptr 共享拥有

std::shared_ptr<int> p1(new int(5));
std::shared_ptr<int> p2 = p1; // 两个指针同时拥有对象

p1.reset(); // p2 还在,因此对象不会被释放
p2.reset(); // 释放对象,因为已经没有人拥有它

3 weak_ptr 弱拥有

实际上表达了一种只是使用,而不拥有的语义:

std::shared_ptr<int> p1(new int(5)); // p1 拥有对象
std::weak_ptr<int> wp1 = p1; 

p1.reset(); // 释放对象

std::shared_ptr<int> p3 = wp1.lock(); // 返回一个空指针
if(p3)
{
  // 不会执行
}

同时,由于采用引用计数,不可避免的会存在引用循环的问题:

struct B;
struct A {
  std::shared_ptr<B> b;  
  ~A() { std::cout << "~A()\n"; }
};

struct B {
  std::shared_ptr<A> a;
  ~B() { std::cout << "~B()\n"; }  
};

void useAnB() {
  auto a = std::make_shared<A>();
  auto b = std::make_shared<B>();
  a->b = b;
  b->a = a;
  // A 和 B 互相引用
}

将其中一个指针改为 weak 可以解决引用循环的问题。

Objective-C & Swift ——我辈岂是蓬蒿人

早期的 Objective-C 内存管理是通过手动引用计数(即 MRC)来进行的。这种方式和 C++ 的手动内存管理有类似的地方。Objective-C Runtime 的一些特性,例如 Autorelease 和向 nil 发送消息不崩溃等,让 MRC 比 C++ 的 new & delete 显得友好了那么一些。不过开发者为了管理内存还是要加入大量的 MRC 代码,同时对于 Objective-C 本身的内存机制需要有一定的了解,无形之中提升了开发 Objective-C 的门槛。

苹果自己显然也认识到了这一点。苹果掌握了 Objective-C 的工具链(Clang/LLVM),Runtime,以及 IDE 等整套生态环境,对于 Objective-C 的发展可以说是有绝对的话语权。在 2013 年苹果拿出了自己的解决方案——自动引用计数(ARC)。ARC 通过在编译期间分析对象的生命周期,自动插入内存管理的代码,减轻了开发者的负担,大幅度降低了 Objective-C 的入门门槛。在 ARC 的帮助下,编写 Objective-C 代码的体验接近于编写 GC 环境下的代码,开发者几乎不再需要为内存管理担心,可以专心于业务逻辑。同时 ARC 在 Runtime 上可以说是非常轻量,没有像 GC 机制一样带来很大的 Runtime 负担。如今 ARC 已经成为苹果平台开发的绝对主流。

后面苹果不再满足于 Objective-C 语言本身进行修修补补,推出了自己的编程语言 Swift。Swift 在苹果平台上也继承了 Objective-C 的遗产,使用 ARC 作为内存管理机制。

有关 ARC 的具体内容这里不打算做详细介绍了,可以参考苹果官方文档。这里只提一下 ARC 没有解决的那部分问题。

由于同样采用了引用计数机制,ARC 也没有避免引用循环带来的问题。在 Objective-C 时期,编译器不会对可能产生引用循环的代码产生任何警告,引用循环也是 ARC 环境下最容易犯的错误之一:

MyViewController *myController = [[MyViewController alloc] init…];
// ...

myController.completionHandler =  ^(NSInteger result) {
   [myController dismissViewControllerAnimated:YES completion:nil];
};

上面的代码里,myController 和 Block 互相持有对方,导致两者都不能被正确释放。破除引用循环的办法有两种,以上面的代码为例,一种办法是把 myController 置为 nil:

MyViewController * __block myController = [[MyViewController alloc] init…];
// ...

myController.completionHandler =  ^(NSInteger result) {
    [myController dismissViewControllerAnimated:YES completion:nil];
    myController = nil;
};

另一种是使用 __weak


MyViewController *myController = [[MyViewController alloc] init…];
// ...

MyViewController * __weak weakMyViewController = myController;
myController.completionHandler =  ^(NSInteger result) {
    [weakMyViewController dismissViewControllerAnimated:YES completion:nil];
};

在 Swift 语言中,编译器可以对简单的引用循环问题给出警告,同时 Swift 中也引入了更加简单的 weakunowned 写法:

let closure = { [weak self] in 
    self?.doSomething()
}

Bonus: ARC 相对于 MRC 而言,一个缺点是生成的代码会大一些,因为自动插入的内存管理代码,和开发者手工编写的相比,会有一定程度上的冗余。不过如今存储设备容量已经足够大,ARC 这个缺点也变得不明显了。

GC 环境——别人笑我太疯癫

使用 GC 环境的语言一方面有追求简单好用的脚本语言,例如 Python,Ruby 等,另一方面有 Java(跑在虚拟机上)和 C#(跑在 CLR 上)等追求跨平台的系统语言。GC 可以说是一种非常重的 Runtime 环境。其优点很明显,开发者几乎完全不需要考虑内存管理问题,其缺点同样明显,GC 带来了性能上的下降以及不可预测性。

GC 的实现机制主要有两种,一种是基于引用计数,一种是基于 Tracing。Python 使用的是基于引用计数的 GC,Java 和 C# 采用的是基于 Tracing 的 GC,并且还引入了分代 GC 等技术用于提升 GC 的性能。

GC 本身也是一个很复杂的话题,本文不打算赘述。这里只提其中一点,基于 Tracing 的 GC 不仅解决了前面提到的智能指针,ARC 等技术所解决的问题,同时进一步地还解决了引用循环的问题。基于 Tracing 的 GC 中如果两个对象互相持有对方,但是都没有被 root 持有的话(即形成了所谓的“内存孤岛”),那么两个对象都会被 GC 释放掉。

Bonus: Java 和 C# 当中也提供了弱引用类型(WeakReference),不过前面提到 Java 和 C# 环境都不需要考虑引用循环问题,可能也是因为这个原因,Java 和 C# 代码中,WeakReference 使用的情况并不多。一般用到 WeakReference 大都是性能比较敏感的情况。与之相比,在 iOS 开发中使用 weak 几乎是难以避免的。

Rust——蜀道难,难于上青天

Rust 是 Mozilla 出品的系统级编程语言,可以看做是一个内存安全加强版的 C++,其目标是代替 C/C++ 成为新一代的系统级编程语言。Rust 的主要特性几乎都是在对标 C++,其中主要有:

  1. 编译型语言,无 GC,轻 Runtime
  2. 跨平台,高可用性
  3. 零成本的抽象

同时,Rust 还引入了基于 borrow checker 的内存安全机制,力争在编译阶段解决 99% 的内存问题,补上 C++ 最大的一块儿短板。

为了做到内存安全,Rust 在语言中明确引入了 Ownership,Borrow 和 Lifetime 等概念。程序员需要在编写代码的时候,就对程序内存上的可用性上有着足够的理解和重视,否则编译器通过自己的检查规则发现到错误,直接就报错,不允许通过编译。

Rust 内存管理的具体规则内容很丰富,有兴趣的读者可用查阅官方文档,这里只举几个简单的例子。

首先 Rust 内存管理的基础概念是 Ownership,每个对象都有明确的 Ownership,即拥有者。Ownership 的概念其实和 C++ 当中的智能指针很接近,不同的是 Rust 的规则更加严格:

let s1 = String::from("hello");
let s2 = s1;

println!("{}", s1); // error: use of moved value: `s1`

上面的代码编译会报错,因为 s1 已经把 Ownership 转交给了 s2,再试图使用 s1 是不合理的。

与之对比,下面的 C++ 代码可以正常通过编译,但是运行起来会崩溃:

auto s1 = std::make_unique<std::string>("Hello");
auto s2 = std::move(s1);

std::cout << *s1.get() << std::endl;

可以看到 Rust 通过明确 Ownership 概念以及编译期间的 Ownership 检查,避免了 Ownership 不明确而导致的问题。


在 Ownership 之上, Rust 对于引用的概念也很明确。引用在 Rust 当中表现为 borrowing,即不拥有 Ownership,只是借用。仍然用之前提到的 C++ 引用作为返回值的例子:

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

上面的代码不能通过编译,因为 s 在函数返回之后已经被销毁,所以其引用也就无效了,返回值 &s 是一个引用,对 s 没有 Ownership。Rust 的检查认为代码是存在错误的。可以看到 Rust 这种检查有助于避免野指针问题。

Rust 中的引用还有 mutable 和 immutable 的区别,避免了对一个引用的更改,导致其他引用的更改:

let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s; // error: cannot borrow `s` as mutable more than once at a time

另一个例子:

let mut s = String::from("hello");

let r1 = &s; // immutable
let r2 = &s; // immutable 
let r3 = &mut s; // error: cannot borrow `s` as mutable because it is also borrowed as immutable

在 Ownership 以及 Borrowing 之上,Rust 还引入了基于 Scope 的 Lifetime 概念,即变量的生命周期。举一个最简单的例子:

{
    let r;         // -------+-- 'a
                   //        |
    {              //        |
        let x = 5; // -+-----+-- 'b
        r = &x;    //  |     |
    }              // -+     |
                   //        |
    println!("r: {}", r); // | // error: `x` does not live long enough
                   //        |
                   // -------+
}

上面的代码中用 'a'b 分别标示了 r 和 x 的 lifetime。这段代码不能通过编译,因为 r 的 lifetime 比 x 还要长。Rust 可以在编译期间就检查出 lifetime 不合理的错误,避免错误在运行时发生。


最后,Rust 的变量实际上并不只是代表内存,也代表了和变量有关的非内存资源,结合 Rust 自己的 Ownership 体系,使用 Rust 时程序员不需要手动关闭文件,socket 等非内存资源:

use tempdir::TempDir;

let tmp_path;

{
   let tmp_dir = TempDir::new("example")?;
   tmp_path = tmp_dir.path().to_owned();

   // Check that the temp directory actually exists.
   assert!(tmp_path.exists());

   // End of `tmp_dir` scope, directory will be deleted
}

这里当整个函数执行完毕,tmp_dir 这个目录也会被删除掉!这种自动释放所有有关资源的特性,进一步减轻了程序员的负担。

Bonus:这种 Pattern 在 C++ 语言中被称为 RAII(Resource Acquisition Is Initialization),也是 C++ 语言中被大力提倡使用的。不过 RAII 在一定程度上依赖于 unique_ptr 的使用,并不像 Rust 中那么自然。

Bonus 2:GC 语言号称让程序员不需要关心内存,然而 GC 语言却并不喜欢代替程序员处理非内存资源。相反,GC 语言中往往会引入额外的资源释放机制,作为内存管理的补充。例如 Python 中的 with,Go 中的 defer,以及 C# 中的 IDisposable + using 等。

未来?

通过上面的内容,可以看到,Rust 在内存管理层面上吸取了前人的诸多经验,给出了一套极具特色和开创性的方案。同时 Rust 本身也还在快速发展之中,社区也提出了诸如 Non-Lexical Lifetimes 的设想,给 Rust 带来了更多的想象力。与此同时,Rust 改写的 Firefox 组件也开始逐渐走向前台,经受大众的考验。有理由相信 Rust 在未来的前途是十分广阔的。

总结

不总结了,累得慌,歇了。

参考资料