现代C++之资源管理

资源管理

前言

在熟知了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;
}
1
main();
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 foo;
// 但由于std::move转移所有权
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是一类通用的资源管理机制,具备推广到业务层面的一种可靠范式。