资源管理 前言 在熟知了C++智能指针、拷贝与移动、高级面向对象系列知识后,我们也引出了本系列课程最核心的现代C++编程范式,也就是垃圾回收(Garbage Collection)的话题。
说到垃圾回收(GC)的话题,不免让人联想到Java的YoungGC、FullGC,或者Python自动引用计数的内存回收机制。但严格的讲,Java、 Python的内存管理的都是全自动化的,C++所实现RAII并非是此类的内存回收机制,这也是社区对C++较为抨击的一点。但笔者看来,C++的RAII-资源获取即初始化,不得不说,是C++兼容C的生态大体系的下,相对不那么激进的解决方案了。我们知道,即便是在C语言中,局部变量/栈上申请的内存由系统负责回收,C++正式基于这个机制,扩充衍生了我们前期讲到的拷贝与移动、析构与构造的相关机制,从而一方面实现了对C的兼容,另一方面也是在此理论框架下相对较好的内存管理机制了。
下面的课程,将围绕RAII的本质展开,逐步阐述C++的垃圾回收机制,并引出我们现代C++系列课程的最终目标——全面抛弃裸指针。
RAII的本质 不得不说,RAII的全称着实是有点蛋疼,笔者认为“资源获取即初始化”并不能很好的解释,RAII为垃圾回收带来的怎样的便利性。下面将从逆向的角度来阐述RAII的技术本质。首先假定一个我们熟知的案例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 #include <iostream> #include <memory> class Foo {public : Foo () { std::cout << "Foo construct." << std::endl; } Foo (const std::string &data) : m_str (data) { std::cout << "Foo(" << m_str << ") construct." << std::endl; } void DoSomething () { std::cout << "Foo(" << m_str << ") DoSomething." << std::endl; } ~Foo () { std::cout << "Foo(" << m_str << ") desctruct." << std::endl; } Foo (const Foo &other) { std::cout << "Foo construct by lvalue." << std::endl; m_str = other.m_str; } Foo (Foo &&other) { std::cout << "Foo construct by rvalue." << std::endl; m_str = std::move (other.m_str); } Foo &operator =(const Foo &other) { std::cout << "Foo copy assign." << std::endl; if (this != &other) { m_str = other.m_str; } return *this ; } Foo &operator =(Foo &&other) { std::cout << "Foo move assign." << std::endl; if (this != &other) { m_str = std::move (other.m_str); } return *this ; } std::string m_str = "" ; };
在此基础上,我们有如下main函数调用:
1 2 3 4 5 6 int main () { Foo foo; foo.DoSomething (); return 0 ; }
Foo construct.
Foo() DoSomething.
Foo() desctruct.
结果当然是显而易见的,那么我们再从逆向的角度看看,编译器做了什么。
1 2 3 4 5 6 7 8 9 10 int _main() { r31 = r31 - 0x30 ; saved_fp = r29; stack[-8 ] = r30; r29 = &saved_fp; Foo::Foo (r29 - 0x5 ); Foo::DoSomething (r29 - 0x5 ); Foo::~Foo (r29 - 0x5 ); return 0x0 ; }
结合我们上一轮《高级面向对象》课程的阐述,很显然,这里依次进行了Foo的构造,DoSomething的调用,然后是Foo的析构。系统对Foo的所在内存的生命周期进行了全面接管,并为我们隐式调用了Foo的构造和析构,构造与析构的时机与变量的生命周期/作用域一致。
当然,到此为止,RAII的效果并不太明显,我们看一个相对复杂点的分支。testRAII函数拥有多个return返回点:
1 2 3 4 5 6 7 8 9 10 11 12 int testRAII (int i) { Foo foo; if (i > 0 ) { foo.DoSomething (); return i; } if (i < -2 ) { return -2 ; } return -1 ; }
对应逆向代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 int __Z8testRAIIi(int arg0) { r31 = r31 - 0x30 ; saved_fp = r29; stack[-8 ] = r30; r29 = &saved_fp; var_8 = arg0; Foo::Foo (r29 - 0x9 ); r8 = var_8; if (r8 <= 0x0 ) { if (CPU_FLAGS & LE) { r8 = 0x1 ; } } if ((r8 & 0x1 ) == 0x0 ) { Foo::DoSomething (r29 - 0x9 ); var_4 = var_8; } else { r8 = var_8 - 0xfffffffe ; if (r8 < 0x0 ) { var_4 = 0xfffffffe ; } else { var_4 = 0xffffffff ; } } Foo::~Foo (r29 - 0x9 ); r0 = var_4; return r0; }
类似的,如下更加更加复杂的案例,编译器总能保证Foo的构造、析构对应其作用域/生命周期的范围进行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 int testRAII (int i) { Foo foo; try { if (i > 0 ) { foo.DoSomething (); return i; } if (i < -2 ) { return -2 ; } } catch (std::exception &ex) { return -3 ; } return -1 ; } int testRAII (int i) { auto foo = std::make_shared <Foo>(); try { if (i > 0 ) { foo->DoSomething (); return i; } if (i < -2 ) { return -2 ; } } catch (std::exception &ex) { return -3 ; } return -1 ; }
通过上述充分的案例,我们坚信,编译器对变量的作用域和生命周期的强绑定管理是可靠的、完备的,这也是C++编译器对类实现的标准。我们得出以下结论:
编译器总能根据变量作用域对其构造、析构的时间点做完美的控制。
RAII的本质实际上就是针对对象生命周期管理,通过构造、析构函数对资源进行申请、释放,这个申请、释放是相当可靠的。也是C++半自动管理资源的方式。
RAII在涉及跨作用域场景的应用 结合上一节我们得出的结论,我们在实际编码过程中会发现新的问题,上述案例实际上都是对局部变量的生命周期的管理,但实际编码过程中,往往会涉及跨多作用域的对象生命周期管理。
那么这里,需要结合到我们《拷贝与移动》课程的知识,关于所有权流派的理论能很好的解释RAII如何解决跨作用域的场景。这里我们稍微回顾下之前提到的一个简单的场景:
1 2 3 4 void SomeMoveFunction (Foo &&foo) { Foo movedFoo = foo; std::cout << "foo move to temperary scope, and it will destruct." << std::endl; }
1 2 3 4 Foo foo; SomeMoveFunction (std::move (foo));
Foo construct.
Foo construct by lvalue.
foo move to temperary scope, and it will destruct.
Foo() desctruct.
全局变量的foo所有权转移给了SomeMoveFunction中的局部变量movedFoo,从而让原本是全局变量的Foo最终还是被析构了。这里根据上次课程的得出的结论:
被move后的对象,所有权转移给了新的对象,生命周期由新的作用域来管理。
上面的例子实际上就是全局作用域转移给局部作用域,生命周期变短的例子。相反,局部作用域转移给全局作用域也是一样的遵循上面的结论。
当然,所有权转移对象时,要求编码者遵循《拷贝与移动》课程中讲到的“君子契约”,很多时候,开发者由于太懒而放弃了此场景。当然STL,也注意到了这个问题,这也是本系列课程第一章所讲到的《智能指针》的话题。
智能指针与RAII 围绕RAII这套资源管理机制,一句话概括,是基于编译器根据变量作用域精准的调用实例的构造、析构,并通过移动语义实现实例的提前或延迟析构。但要实现移动语义,通常开发者又要自行实现移动构造、移动赋值,比较冗余。因此智能指针就是为了解决此类问题。
智能指针的实现原理这里不会展开,它本质上也是基于变量的作用域的管理,对被管理的裸指针进行精准的new/delete。由于被管理的是裸指针,不会用到实例的任何移动方法,因此对对象就没有了约束。
我们看一个简单的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class FooWithoutMove {public : FooWithoutMove () { std::cout << "FooWithoutMove construct." << std::endl; } ~FooWithoutMove () { std::cout << "FooWithoutMove desctruct." << std::endl; } FooWithoutMove (FooWithoutMove &&other) = delete ; FooWithoutMove &operator =(FooWithoutMove &&other) = delete ; }
1 2 3 4 void SomeMoveFunction (std::unique_ptr<FooWithoutMove> &&foo) { auto movedFoo = std::forward<std::unique_ptr<FooWithoutMove>>(foo); std::cout << "foo move to temperary scope, and it will destruct." << std::endl; }
1 2 std::unique_ptr<FooWithoutMove> foo (new FooWithoutMove) ;SomeMoveFunction (std::move (foo));
FooWithoutMove construct.
foo move to temperary scope, and it will destruct.
FooWithoutMove desctruct.
FooWithoutMove不是一个支持move的对象,但STL的智能指针,默认实现了对裸指针的拷贝、移动相关方法,能够稳妥的实现RAII范式。
RAII在多线程加锁/解锁场景的使用 在涉及对mutex加锁/解锁时,我们的业务代码,特别是以C-Style方式写的时候,会涉及很多代码的退出点。同时每个退出点都要进行解锁,这导致在每个业务分支都有冗余的释放资源的代码。我们应该很熟悉std::lock_guard的使用,我们可以简单看看std::lock_guard是的实现,可以进行理解RAII的资源管理范式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 template <class _Mutex >lock_guard { public : typedef _Mutex mutex_type; private : mutex_type& __m_; public : _LIBCPP_NODISCARD_EXT _LIBCPP_INLINE_VISIBILITY explicit lock_guard (mutex_type& __m) _LIBCPP_THREAD_SAFETY_ANNOTATION (acquire_capability(__m)) : __m_(__m) { __m_.lock (); } _LIBCPP_INLINE_VISIBILITY ~lock_guard () _LIBCPP_THREAD_SAFETY_ANNOTATION(release_capability ()) { __m_.unlock (); } private : lock_guard (lock_guard const &) _LIBCPP_EQUAL_DELETE; lock_guard& operator =(lock_guard const &) _LIBCPP_EQUAL_DELETE; };
可以看到,std::lock_guard本质上也是通过lock_guard本身的作用域,精准而又不遗漏的构造、析构,来控制加锁、解锁,同时保证任何退出点,都能自动释放锁。
回到RAII概念本身 RAII——资源获取即初始化,经过上述的案例,笔者认为字面中文解释并不够完备。这里尝试给出一个较为好理解的定义:
RAII是通过变量的作用域控制构造、析构的时机,间接、自动的管理资源的申请和释放。
结合这个定义,我们也注意到,RAII并不是只限定于内存管理的一种范式,而是可以借鉴、复制到有典型的生命周期管理含义的业务场景中。例如,锁的控制、数据库事务的管理等等。
小结 本次课程我们从垃圾回收开始聊起,重点讲述了编译器通过变量作用域进行构造、析构的机制,借由此衍生的RAII资源管理的机制。并结合STL中智能指针、加锁/解锁的模板库,讲述了RAII的典型运用场景,进一步衍生到RAII是一类通用的资源管理机制,具备推广到业务层面的一种可靠范式。