相比于传统c++当中的手动管理内存,rust采用了所有权 + RAII的机制,对于一些复杂的情况,rust还有生命周期等约束,从而保证在编译期能够解决大多数的内存安全问题。
栈 && 堆
如果要说内存管理的话,首先需要明确栈和堆的概念,这一点对于所有编程语言都适用,但是在rust当中尤为重要:
- 基本类型和小的复合类型,像整数、浮点数、布尔值、字符以及小的数组、元组和结构体这样的基本类型通常是在栈上分配的。这些类型的大小在编译时是已知的,并且通常是固定的。此外引用同样是分配在栈的上,他指向另外一个数据(可能在栈上也可能在堆上)
- 分配在堆上的主要有两个类型:动态类型和智能指针,智能指针自身是分配在栈上的,但是其管理的数据分配在堆上。而动态类型如Vec、String等需要进行动态扩容,因此分配在堆上进行管理数据。
单一所有权
对于基础类型,rust将其分配在栈上,会随着栈桢的弹出而被回收,和其他语言相同,不需要担心栈上内存安全的问题。
为了能够管理堆上的数据,保证内存安全,rust对于堆上的数据都定义了一个所有权。即堆上的数据在某一事件只能被一个变量所持有(基础情况),数据会随着栈上变量被RAII回收而回收。从而保证了内存安全。
在下面,堆上的String会随着变量s的回收而被回收
|
|
赋值 在cpp当中,如果将一个指针赋值给另外一个指针,两个指针会指向同一个地址,从而两个指针都能够操作这一段地址对应的数据。
但是在Rust中,由于引入了所有权的概念,一段地址只能被一个引用所持有,在基础情况下,不允许两个引用指向堆上同一段地址,因此,如果是像cpp进行赋值操作,则会发生所有权的转移,即原本的引用失去对地址的所有权,将所有权转交给新的引用。
|
|
又或者说,在rust当中,每个引用类型都对应了C++当中的unique_ptr
,如果想让转移给一个新的指针,就需要进行"move"操作。
而如果在某些情况下,只是需要该地址中的数据,那就可以进行深拷贝,只获取数据,根据数据在堆上重新分配一段地址创建变量,并不会影响原本的数据,同样也不会发生所有权的转移,实现深拷贝的话需要实现Clone Trait。
|
|
引用与生命周期
引用
有些情况下,我们并不想去获取所有权,只是想临时借用一下这个变量。这种时候,rust就可以使用引用来处理。
这里的引用其实和c++当中的基本类似,只不过,rust为了保证安全性,对于可变性有着更加严格的设定,在c++当中对于一个const类型的变量,是无法去获取一个非const的引用来修改其中的值的,这一点在rust当中也得到了保留:
|
|
如果想修改的话,在c++当中就需要使用const_cast
来进行转换,而rust中可以通过内部可变性来解决,不过这就是后话了。
在c++ const的基础上,rust又多了一个新的限制,即在同一作用域当中,对同一数据不能同时拥有可变引用和不可变引用,这里主要是为了避免数据竞争的问题,需要注意的是,这里的数据竞争和多线程毫无关系,单线程同样存在数据竞争问题。
通过这种冲突约束,能够保证当获取到一个不可变引用时,在该引用失效之前,能够保证数据确实是不变的。可变引用和可变引用之间的冲突同样如此,任何人都不希望自己在修改过程中,有其他引用来修改数据,达到和预期不一致的结果。
这里有点像一个读写锁的设计,如果将作用域理解为一段时间,而两个引用视为在交替执行的线程,这样整个过程就可以视为并发过程中的数据竞争问题,
|
|
在最新的rust编译器当中,r1对data的引用会持续到最后一次使用r1,而不是整个作用域的结尾,因此这样就可以通过合理的编排,将“并发”的过程转换为串行,从而解决数据竞争的问题,这类的数据竞争通常出现在,先对一个容器进行条件性检索,然后通过检索结果去更新容器。在搜索过程中会获取一个不可变引用,在修改时又会去获取一个可变引用,从而产生了冲突,正确的写法是在检索过程中保存结果,完成检索之后再去更新容器,而不是一边检索一边更新。
上例中正确的写法如下:
|
|
生命周期
rust中生命周期标注,核心目的是解决垂悬引用的问题,通俗来讲,就是谁比谁活得长的一个问题,如果a比b活得长,但是a却引用了b,那么就存在一段时间,b已经被释放了,但是a依旧持有一个b的引用,去操作b,这时候就会出现内存安全的问题。
最经典的违反生命周期约束的例子如下,r的生命周期长于x,此时引用x就会产生垂悬引用:
|
|
函数中的生命周期
和C++相同,rust同样不允许返回局部变量的引用,因为会被RAII在函数执行完之后释放掉,一定会产生垂悬引用问题。因此对于函数中的生命周期,只能来自输入。
一聊到rust函数的生命周期,就会有这样一个经典的例子:
|
|
看起来写的毫无问题,获取两个引用,返回其中之一,但实际上会被rust编译器给无情的拒绝掉,而具体原因是无法确定x和y究竟谁能够活的更久的问题,如果像以下这样,就会出现问题,此时s2的作用域更小,提前释放,从而res产生了垂悬引用的问题,这种情况在不使用函数的情况下不应该发生,而在进行函数调用时同样不应该发生,因此如果不进行生命周期的标注,编译器就会严苛的拒绝掉,以避免风险,正确的标注方法如下:
|
|
结构体中的生命周期
相比于函数的生命周期,结构体中的生命周期可能更为常见,因为结构体当中的字段,并不是都是自己所有的,有些字段需要引用其他的变量,这种写法随处可见,比如说一个容器的迭代器,具体如下:
|
|
作为一个迭代器的Wrapper
,其中封装了skip_map的iter,例如seek等功能,需要根据skip_map去重新生成一个iter,并保存到iter的字段当中,iter本身是对容器的引用,因此这个生成的过程就是去获取一个引用。
而此时就需要去进行一个生命周期的保证,原本的容器的引用skip_map
至少要和iter活得一样久,才能够生成一个引用容器的iter,并复制给结构体中的字段。
在这种生命周期的标注下,表明了:
- 在创建结构体
MemTableIterator
时,设当前结构体的生命周期为a
- 在结构体当中会引用一个至少和当前结构体生命周期
a
一样长的容器 - 保存一个 iter 字段,其生命周期至少 和当前结构体一样长
对于上面的情况,如果结构体当中保存一个Arc<SkipMap>
的话,对于该方法,传入了&self,而在rust当中,&self是独立于结构体中声明的生命周期'a
的,可以这里可以定义为'b
,编译器无法得知’a ‘b 之间的生命周期关系,因此就会拒绝掉。该函数实际的声明如下:
|
|
而至于rust为什么要这样做,主要是为了灵活性。例如,某些方法可能只是临时借用结构体的数据,而不需要持有与整个结构体相同的生命周期。通过允许独立的生命周期,Rust 可以更准确地表示这种借用行为:
|
|
生命周期消除
如果所有的引用都需要手动进行标注,那么编程体验自然是灾难的,因此编译器设置了三条规则,如果满足了就可以自动完成生命周期标注,从而不需要手动标注:
- 每个引用参数都会获得独自的生命周期
- 如果只有一个输入生命周期,那么该生命周期就会赋给所有的输出生命周期
- 如果存在多个输入生命周期,其中一个是
&self
或者&mut self
,那么&self
的生命周期被赋给所有输出生命周期
来几个例子:
|
|
|
|
后续就不满足任何规则了,因此无法自动消除,需要手动进行生命周期的标注。
而对于第三条,带上&self
的就是方法了,对于方法的生命周期,得益于一三条规则,通常不需要进行手动标注。
小结:生命周期的标注不会改变任何引用的实际作用域,他只是为了取悦编译器,让编译器不要难为我们,在进行标注之后,就会按照标注去进行检查,从而保证内存内存安全。
共享所有权
在上述的情况中,数据的所有权永远只属于同一个变量。其他想要访问该数据只能通过引用来获取,这样的问题是,原始数据必须有最长的生命周期,才能够保证其他的引用有效。但是有些情况,需要多个所有者持有同一个数据,并且使用者之间是对等关系,无法确定一个最长的持有者。:
- 在双向链表中,每个节点都会被前一个节点和后一个节点保存(持有)。
- 在多线程编程中,多个线程持有同一个数据,对其进行修改,由于 rust 的单一可变引用的限制,无法使用引用来完成。
在Rust当中,给出的解决方法就是借助引用计数的思想,使用智能指针Rc<T>
与Arc<T>
,其实现的作用类似于C++当中的share_ptr,不过做了更多的限制来保证安全。
正如名字,Arc实现的引用计数是Atomic的,可以用于多线程环境当中,Rc反之。
相比于C++的share_ptr而言,Rc与Arc最大的差别就是实现的是不可变引用,通过该指针无法直接修改指向的数据,只能够进行读取,而如果进行读取,就需要通过内部可变性来实现,即RefCell
和Mutex
内部可变性
关于内部可变性,大概有两个比较重要的概念,一个是“共享”,即通过引用计数来令数据可以在多个持有者之间进行共享,并且允许进行修改。另一个是“内部”,体现了封装的思想。
可变性
对于基础的Rc和Arc,rust只允许对其进行读取,而无法修改数据,通过RefCell和Mutex允许对其进行修改,但是可变引用和不可变引用之间的冲突无法绕过,只是将这个过程从编译器推迟到了运行期,如果检测到违反约束,程序会直接panic。
数据共享
在单线程时大多数情况下,共享数据可以通过引用来解决,只要小心的保证只有一个可变引用的原则就可以实现,但是有些情况就很难处理了,即在逻辑上很难确定一个主从关系,将数据的所有权归于谁,而其他的去进行引用。由于原节点如果被释放的话,其他的引用全部失效,因此需要确定严格的生命周期关系,在有些情况下,各个使用者之间是对等的关系,因此很难确定出这样一种关系和生命周期,比较经典的一个例子就是双端链表,各个节点之间都是对等的,每个节点都可能因为移出链表而被释放,不存在一个明确的生命周期关系,这种时候再使用引用就不太符合逻辑了。 而对于多线程,那么就更随处可见了,全局原子性计数器、消息队列、cache、任务队列都需要进行共享,无法说出数据到底该归属于谁,就拿消息队列来说,队列究竟该属于谁?无论属于哪一方然后另外一方去引用都是不符合逻辑的,二者是一个共享的关系。其实这就是一个设计哲学的问题,在C++98当中,硬把队列归属于某一方,然后让另外一方去引用也没什么问题,只不过rust在设计上强调了共享的这个概念,并且在编译器层面做了限制而已。
内部是什么
在说明内部时,需要对于可变做一些诠释,在C++当中,通常我们去获取一个const引用,这时能够保证的是我们无法通过这个引用来修改原本的数据,但是在rust当中,我们获取了一个不可变引用,这时候我们所期待的是在我持有这个引用的这一段时间内,这个引用指向的数据都不会被改变,通过引用,能够对其进行“可重复读”。二者的出发点是有所不同的,C++的const是保证自身不去进行修改,而rust的非mut是保证没有其他的引用能够修改(同样也保证了自身不去修改)。 所以,这里我对rust中不可变的理解是:我能够获取到数据,并且在我使用数据的过程中,数据都是一直保证不变的。
那么内部究竟该如何理解呢?这里rust有一个比较有意思的实现,就是对于一个可变的方法,如果他所属对象A在结构体B中被Arc<Mutex<T>>
包裹,那么在B中就可以使用不可变的方法来进行调用,这里实际上是进行修改了的,但是可以保证同一时间只有一个可变引用,我们来看一个例子:
这里定义了一个ShardLRUCache,其中有多个LRUCache,而由于LRU的get会刷新缓存,因此他是一个&mut self
的,但是在ShardLRUCache当中,在进行加锁之后,可以使用&self
方法来对其进行调用:
|
|
而这里,rust在一个不可变方法中通过加锁的形式,调用了一个可变的方法,在进行加锁后,就可以保证自身在使用该引用时,不会有其他线程来篡改数据,从而将一个可变方法转换成了不可变方法,这也印证了我上面的说法,rust的不可变所说的是使用过程中不会被其他使用者改变。因此,这里的内部所说明的就是虽然内部实现是可变的,但是通过约束,可以保证在使用过程中的对外看起来是不变的接口,所以称为内部可变性。
那么C++如何实现这种保证呢?在单线程环境中想要实现就需要程序员自身来进行约束,而多线程就需要锁来保证了。而rust同样在多线程时同样是使用mutex来解决的,如果不使用mutex包裹而去修改,就无法通过编译,从而在编译层面上解决了多线程的数据竞争的问题。单线程环境也是同理,在上面已经分析过了。
综上,Rust通过引用计数 + 可变性,很优雅地在编译期就解决了数据共享所有权、并发、以及数据竞争的问题,从而极大地保证了内存安全,在code review时就可以专注于业务逻辑,而不是内存的管理。
小结
在本章中,笔者分析了Rust的内存管理方案,既然能够通过编译器进行约束,那么无非就是定义一些规则,然后按照规则去进行检查,而这些规则对于其他非gc的语言同样是适用的:
- 对于作用域内用完即销毁的:rust使用RAII,当离开变量作用域,结束生命周期时,对于堆上的数据同样进行回收,从而避免了手动进行delete
- 对于需要传递出作用域的:rust定义了所有权的概念,如果需要交给作用域外去继续使用,那么就需要移交所有权,将堆上的数据转移给另外一个变量负责,从而保证不会对该数据丢失管理,后续再按照其他方法继续持有或者gc
- 对于临时借用的引用:rust对引用标注生命周期,被引用者的生命周期至少要和引用者一样长,这样才不会再引用者使用时已经释放掉,产生垂悬引用。
- 对于局部变量,无论是在栈上还是堆上,由于生命周期会随作用域而结束,因此编译器直接拒绝对外传递引用
- 如果想要在多个所有者之间共享数据,那么就通过引用计数的方式来完成共享
- 对于数据竞争:单线程的数据竞争,rust通过一个r-w冲突来约束,即允许同一作用域内存在多个不可变引用或者单一可变引用,保证了获取到的不可变引用在使用过程中一定是不可变的。而对于多线程环境,则使用Mutex强制约束,不使用则无法通过编译,从而保证了在引用时的独占性和不可变性。
- 循环引用:和 c++相同,可以使用 weak_ptr 来处理。
其实这些规则都是一些不成文乃至成文的规定,在modern c++当中部分规则也早就支持,比如RAII、智能指针。但是其他的依旧会给程序员带来较大的心智负担,如果不去认真遵循,就会在运行期产生难以检测的bug,rust通过编译器强制约束,将大多数问题限制在编译器,一旦通过编译,就可以专注于业务逻辑,从而极大的降低程序员的心智负担。
c++和rust之间也并不是什么对立关系,优秀的c++程序员接受起来rust没有什么难度,反过来,学习rust也有助于写出更高质量的c++。