Rust-1.RAII

先来提一下RAII吧,RAII是一种资源管理方式,来源于C++,也是Rust使用的资源管理方式,本篇博文会比较RAII和GC之间的优劣。

虽然之前说Rust最重要的设计思想是安全,照理来说我应该把安全相关的特性放开始讲,不过实际上资源释放和安全并没有关系。不过我还是把它放在最前面,因为我觉得RAII和Rust最鲜明的特性所有权系统颇有交叉,而且理解起来更加简单。

可能有人会奇怪,资源管理不当会引起内存泄露的,怎么说和安全无关?

内存泄露是没有对应该释放的内存进行释放,属于没有对合法的数据进行操作;内存不安全是对不合法的数据进行了操作。所以这是两个性质不同的事情,造成的后果也不一样。内存不安全可能会引起预料之外的输出,断错误等各种bug。内存泄露只是一定程度减少可用内存量。如果内存的泄露是单次而不是持续的,泄露量可控,则一般不会有什么影响;但一次微小的内存不安全操作都可以导致灾难性的后果。

当然,内存泄露也不是什么好事,Rust也并不是一不小心就容易造成泄露的语言。只是和100%保证内存安全和线程安全相比,Rust非常耿直地告诉我们,避免内存泄露并不是完全保证的。

目前我知道可能导致内存泄露的大概有以下情况:

  • 析构函数调用前线程崩溃,导致析构函数没有被调用
  • 使用引用计数时,构造了循环引用
  • 调用Rust标准库中的forget函数主动泄露

什么是RAII

RAII是一种基于栈(或作用域,或固定生命周期)的资源管理方式,广义的RAII可以只是一种惯用法,不过没有语言本身支持,单纯作为惯用法用起来还是很不便的,所以本文讨论的是C++,Rust这样的本身语言有较好支持的狭义RAII。

RAII全称是Resource Acquisition Is Initialization,要便于理解的话,或者再加上后半句Resource Reclamation Is Destruction(有人说RAII和RRID是两种相似但不同的方法,对此我不太同意,我觉得就是一个东西)。RAII所作的正如它的全名(包括后半句),资源获取就是初始化,资源释放就是析构;它在变量初始化时,调用构造函数分配资源;在变量析构时,调用析构函数释放其持有的资源。其实在资源管理中,后半句才是关键,因为获取资源是手动的,释放资源是自动的,这里自动的才是有用的特性。

绕来绕去说了一大堆,其实简单来讲,一般我们说的RAII就是,在定义对象的时候实现一个析构函数负责释放资源,在变量作用域结束的时候,编译器会自动帮我们加上对析构函数的调用,我们使用这样的对象时,就不需要手动释放资源,从而实现了资源的自动释放。

下列代码就是一个简单的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::fs::File;
use std::io::Write;
fn write_to_file(message: &[u8]) {
let mut f = File::create("/tmp/test.txt")
.expect("Unable to open!");
f.write_all(message)
.expect("Unable to write!");
}
fn main() {
write_to_file(b"hello world!");
}

在这个例子中,程序打开了一个文件,然后在里面写入了"hello world!",这里说明了RAII的效果,文件/tmp/test.txt在变量f被析构,即在write_to_file函数结束时就被自动关闭了。(expect在创建文件或写入数据失败时使程序直接退出,并输出给定的错误信息,目前可以不用关注)

不过这个例子有个不恰当的地方,就是我们不易从现象上判断RAII是否真的生效了,所以下面是另一个例子(所以我干嘛要举上面这个例子)。

1
2
3
4
5
6
7
8
9
10
11
12
use std::sync::Mutex;
fn locked_func(m: &Mutex<()>) {
let _lock = m.lock().unwrap();
}
fn main() {
let lock = Mutex::new(());
locked_func(&lock);
let _lock = lock.lock().unwrap();
println!("Shouldn't reach here if RAII doesn't work.");
}

这个程序有一个互斥锁,locked_func函数和main函数都要获取它,如果在locked_func结束时互斥锁没有被释放,则main函数会在获取锁那里被阻塞,程序不会结束,也不会有输出。而实际的行为是相反的,说明我讲的RAII自动释放确实没有骗你们。

其实RAII的自动释放不仅作用于函数结束,也可以作用于语句块的结束,上述示例可以简化为如下代码,同样有效。要是删除掉中间那层大括号,程序就会一直卡住,无法结束。

1
2
3
4
5
6
7
8
9
10
use std::sync::Mutex;
fn main() {
let mutex = Mutex::new(());
{
let _lock = mutex.lock().unwrap();
}
let _lock = mutex.lock().unwrap();
println!("Shouldn't reach here if RAII doesn't work.");
}

返回值优化

上面讲的例子中,RAII对资源的自动释放起了很好的效果。但是实际的编程中,我们需要在函数之间传递值。下面,我们来具体谈谈这种情况,RAII这种提倡把内存尽量分配到栈上的内存管理方式,一般这也意味着函数返回时,可能要返回一个很大的结构体。

为了更清晰地表达我的意图,这里的某些代码风格可能不太符合Rust的推荐风格。

1
2
3
4
5
6
7
8
9
10
11
12
struct BigStruct {
elm: [u64; 100]
}
fn rvo_test() -> BigStruct {
let res = BigStruct{elm: [0; 100]};
return res;
}
fn main() {
let _bs = rvo_test();
}

直观理解,在函数rvo_test中,一个大结构BigStruct被创建,保存在局部变量res中,此时BigStructrvo_test的栈帧中,然后返回res,函数退出,rvo_test的栈帧以及上面保存在变量res中的BigStruct将被释放;在被释放之前,返回的值赋给main中的局部变量_bs,这个赋值过程一般来看,会有一次内存复制,因为结构比较大,开销也不小,这个内存复制显然是我们想要避免的。

一般使用C的编程中,我们会把BigStruct分配到堆上,这样需要返回的值只有一个指针,自然没有这样的内存复制;像java这样的引用类型语言,数据默认被分配在堆上,也没有这个问题。

但是我们将BigSturct放在栈里的情况要如何避免这样的复制?考虑到栈后进先出的特性,rvo_test的执行过程中,main的栈帧一定是可用的,不会被释放,这样编译器可以做一个优化,即BigStruct其实只有一份,被分配在main的栈帧里,而在rvo_test函数中对BigStruct的操作,会直接操作main函数栈帧的内存,函数返回自然也无需对其进行复制。这种优化就被称之为返回值优化。

返回值优化类似于我们在C里面的一种用法,只是使用更方便自然,编译到机器语言后,基本就是等价的,唯一的区别可能是返回值优化不需要在函数调用时传入指针地址,而是通过栈帧地址减去编译时确定的偏移量计算得到。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct BigStruct {
unsigned long long elm[100];
};
void rvo_test(struct BigStruct* bs) {
*bs = (struct BigStruct){ { 0 } };
}
int main(void) {
struct BigStruct bs;
rvo_test(&bs);
return 0;
}

如果实际上写类似代码,编译去观测其汇编的话,你可能会发现一些更彻底的优化,如:rvo_test函数被内联了,其函数调用都消失了;甚至由于BigStruct的值根本没有被使用,编译器把它们全部去掉,编译出一个空的程序。如果想真正测试返回值优化的效果,可以自行构造更复杂的例子。

对RAII这种倾向于把内存分配在栈上的风格来说,返回值优化对性能相当重要;不过返回值优化本身是一个通用优化,对采用值类型的语言都有一定作用(主要使用引用类型的语言应该不太需要)。

谈谈GC和RC

GC是garbage collection(垃圾回收)的全称,从广义上来讲包含tracing garbage collection和reference counting。其中tracing garbage collection是我们一般见到的GC,从一些引用根开始遍历,标记遍历到的对象,然后将没有标记的释放;而reference counting每新建一个引用,就将对象的引用计数加1,每有一个引用消失,就将计数见减1,引用计数为0时,对象被释放。

不过一般我们说GC的时候,是狭义地指tracing garbage collection,也是最常见的GC;而对于reference counting,一般称之为RC。没有特殊指出的话,本文就按照一般的习惯用法,按照狭义理解,分别叫它们GC和RC。

GC和RC的对比,其实简直可以另写一篇文章了,所以这里就简单提一下。RC实现非常简单,实时性也好,最后一个引用消失时,立刻就会释放内存,而没有引用遍历的stop the world停顿;但是如果真用最简单直观的方式实现RC,在出现循环引用时会造成内存泄露,多线程时频繁的原子性增减操作会降低运行效率,最后在处理的吞吐量上一般不如GC。

大概就讲完了,其实我只是想讲讲GC和RC分别是什么,因为本文其他地方有涉及到,顺便提一提广义上讲它们都是垃圾回收,下面继续讲RAII。

RAII的优点

RAII相对与GC的优势之一显然是性能和实时性更高,消耗更小,不过这个仅是性能问题,和易用性无关。

易用性正如前面所说,RAII能够管理所有种类的资源,而GC只能管理内存。除了内存以外的资源具有唯一性,如我要访问的文件或者申请的同步锁,都是需要特定的那一个;不像内存,只要够用,申请时并不关心具体申请到哪一段。所以内存可以延后统一释放,而具有唯一性的资源必须在使用完毕后立即释放,比如同步锁没有及时释放对整体处理性能的影响是不可接受的;而GC的释放恰恰需要滞后一些的,等累积起来再一起释放。

另外RAII与特殊情况下和其他内存管理结合一起使用比较自然,而全局GC则更难和它们兼容。

GC对此的变通手段

对于内存以外资源的管理,使用GC的语言往往有如下方式进行管理。

  • 手动释放
  • 加入一些关键词,局部引入RAII(比全局默认的RAII要多写些东西)

手动释放自然不用多讲,我们来看看局部引入RAII是什么样的。

Python的with关键词
1
2
3
with open('/etc/passwd') as f:
for line in f:
print(line)
Go的defer关键词
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main
import "fmt"
import "os"
func main() {
f := createFile("/tmp/defer.txt")
defer f.Close()
fmt.Fprintln(f, "data")
}
func createFile(p string) *os.File {
f, err := os.Create(p)
if err != nil {
panic(err)
}
return f
}
吐槽某本Go语言书籍

值得一提的是,七牛云出品的《Go语言编程》一书中,有一个有趣的片段,为了说明defer的优点,给出了一段C++作为对比:

1
2
3
4
5
6
7
8
9
10
11
class file_closer {
FILE _f;
public:
file_closer(FILE f): _f(f) { }
~file_closer() { if (f) fclose(f); }
};
void f() {
FILE f = open_file("file.txt");
file_closer _closer(f);
}

如此清丽脱俗的例子,C++觉得有一句话它一定要讲。

首先是为什么不使用C++标准库里的文件库,一定要使用古老的C文件接口。

1
2
3
void example() {
std::ofstream file("example.txt");
}

正常风格的C++正是使用本篇介绍的RAII,根本不需要任何多余的语句来关闭文件,文件打开的周期就是file变量的生命周期。

虽然C++的流式输入输出存在一定问题,不推荐在工业项目中使用,这确实是一个使用C的文件接口的理解。但是其实完全可以进行重新包装,也包装成类似于标准库的风格,由文件类自己管理自己的关闭,而不是独立搞出一个文件关闭类来。

其实C++很少有什么功能特性不足,无法写出简短优美的代码这种问题。其最大的问题应该是不能防止某些奇葩程序员写出前面这种奇葩代码来,而这正是Rust在很大程度上可以做到的。

小结

还有一些语言推荐使用异常处理用的try ... catch ... finally语句模拟类似的效果

个人不时很喜欢defer的风格,因为比较而言

  • with对释放资源的时机控制是语句块粒度的,比较精确,可以容易地自己决定资源的有效生命周期,而Go的defer是函数粒度的,必须等到函数结束才自动执行
  • 我们的目的是剥离资源释放,使代码主体逻辑更清晰,那对应功能最好专心做这一件事,defer缺少对不合理使用的限制,可以由defer延后执行的不只是资源释放,看起来比较灵活,但这样的使用几乎是任何时候不该使用的错误风格,会使控制流更难分析,可读性下降,这也是goto语句存在的问题。

另外这种局部引入RAII的风格相比真正的RAII有个问题是,依旧可能忘写没有释放资源。

RAII的缺点

RAII的缺陷在于,有些数据的生命周期更加动态,在运行时才能确定,RAII就不太好对它们进行有效的管理。因为运行时才能确定的动态生命周期,要管理它们,必然需要运行时的开销,而RAII没有运行时开销,什么时候释放内存都在编译时决定了。

RAII对此的变通手段

其实RAII就是无法完全处理这样的情况(蛤?)。所以如果不想手动处理这些内存,那就是需要引入GC(广义的)。不过RAII的一个优势就是,和其他内存管理方式结合使用也非常自然,Rust在这里使用RC进行辅助的内存管理,处理那些生命周期更加动态的资源。(使用需要显式把值包装在具有引用计数的包装类型中)之所以在这里使用RC,我想一是因为RC释放也是立即无延迟的,和RAII比较契合;二是RC比tracing GC的实现简单很多,辅助管理一般不用搞太复杂。不过理论上Rust也可以使用GC,Rust如果有GC,也和Rust的RC使用起来差不多,要显式指明把值放在在通过GC管理的包装类型里。目前已经有人实现了实验性的Rust的GC库,比如rust-gcmo-gc,虽然尚不足以实际用于生产,但是已经证明了其可行性。

总结

虽然RAII和GC在不同场景各有优势,RAII也有用起来容易的时候,其实总的来说,易用性还是不如GC。不过采用RAII运行效率高,实时性好,无需虚拟机或嵌入运行时,语言的设计目标是追求速度和系统级编程语言时,选择它也是很自然的事情了(当然手动管理理论效率上限最高,就是写起来麻烦,←_←)。