RAII/OBRM

(一)概念

RAII全称是Resource Acquisition Is Initialization,翻译过来是资源获取即初始化,RAII机制用于管理资源的申请和释放。对于资源,我们通常经历三个过程,申请,使用,释放,这里的资源不仅仅是内存,也可以是文件、socket、锁等等。

RAII,它是“Resource Acquisition Is Initialization”的首字母缩写。也称为“资源获取就是初始化”,是c++等编程语言常用的管理资源、避免内存泄露的方法。它保证在任何情况下,使用对象时先构造对象,最后析构对象。

RAII的好处在于它提供了一种资源自动管理的方式,当产生异常、回滚等现象时,RAII可以正确地释放掉资源。

RAII的做法是使用一个对象,在其构造时获取对应的资源,在对象生命期内控制对资源的访问,使之始终保持有效,最后在对象析构的时候,释放构造时获取的资源。

如网络套接字、互斥锁、文件句柄和内存等等,它们属于系统资源。使用RAII对这些资源进行管理。智能指针(std::shared_ptr和std::unique_ptr)即RAII最具代表的实现,使用智能指针,可以实现自动的内存管理,再也不需要担心忘记delete造成的内存泄漏。

The Perils Of Ownership Based Resource Management (OBRM)

OBRM (AKA RAII: Resource Acquisition Is Initialization) is something you'll interact with a lot in Rust. Especially if you use the standard library.

Roughly speaking the pattern is as follows: to acquire a resource, you create an object that manages it. To release the resource, you simply destroy the object, and it cleans up the resource for you. The most common "resource" this pattern manages is simply memoryBoxRc, and basically everything in std::collections is a convenience to enable correctly managing memory. This is particularly important in Rust because we have no pervasive GC to rely on for memory management. Which is the point, really: Rust is about control. However we are not limited to just memory. Pretty much every other system resource like a thread, file, or socket is exposed through this kind of API.

(二)原理

1.堆

英文名称 heap,在内存管理的语境下,指的是动态分配的内存空间,这个和数据结构的堆是两回事。

这里的内存,被分配之后需要手动释放,否则会引发内存泄漏。

那怎么申请一个堆内存空间呢?

C语言中使用void* malloc(size_t size)来申请一块内存空间,size为申请的字节数。使用void free(void* ptr) 来手动释放内存。

C++则使用 new 和 delete 来申请释放内存。

C++标准里有一个相关概念是自由存储区,英文是free store,特指使用 new 和 delete 来分配和释放内存的区域。一般而言,这是堆的一个子集。

为什么有了 malloc, free, C++中还出现了 new, delete 呢?

实际上 new, delete 的底层实现是 malloc, free;malloc 只是单纯地申请一块内存空间,但是new不一样,C++中包含面向对象的设计,当我们在new一个对象时,C++不仅要向系统申请一块内存,还需要构造这个对象,调用构造函数,而delete时,则需要调用类的析构函数,然后归还内存空间。

实际上new的操作类似于这样:

T* p;
void* mem = operator new(sizeof(T));  // 分配内存,其内部调用malloc
try {
    p = static_cast<T*>(mem);  // 类型转换
    p->T::T( ... );  // 调用构造函数
    return p;
}
catch ( ... ){
    operator delete(p);
    throw;
}

如果申请内存成功,并且调用构造函数正常,则对象构造成功,否则释放申请的内存, 抛出 bad_alloc 异常

而 delete 的操作类似于这样:

p->T::~T();  // 调用析构函数
operator delete(p);  // 释放内存,内部调用 free

先调用析构函数,再释放内存,如果反过来,先释放内存就没办法调用析构函数了嘛?是不是?

2.栈

英文名称 stack, 在内存管理的语境下,指的是函数调用过程中产生的本地变量和调用数据的区域。 这个栈和数据结构里的栈高度相似,都满足后进先出(last-in-first-out 或 LIFO)。

void foo(int n)
{

}
void bar(int n){
    int a = n + 1;
    foo(a);
}
int main(){
    bar(42);
}

生长方向: 栈是朝着地址减小的方向生长的,而堆是朝着地址增大的方向生长的。 

当函数调用另外一个函数时,会把参数也压入栈里然后把下一行汇编指令的地址压入栈,并跳转到新的函数。 新的函数进入后,首先做一些必须的保存工作,然后会调整栈指针,分配出本地变量所需的空间,随后执行函数中的代码,并在执行完毕之后,根据调用者压入栈的地址,返回到调用者未执行的代码中继续执行。

本地变量就保存在栈上,当函数执行完成之后,保存本地变量的栈内存就被释放掉了。

上述例子中本地变量是内置的类型,本地变量不光可以是内置的类型,还可以是复杂的类型,比如说类的对象,这时,如果函数调用结束之后或者发生异常时编译器会自动调用类的析构函数,这个过程叫做栈展开(stack unwinding)

例如:

class A
{
public:
    A() { cout << "A" << endl; }
    ~A() { cout << "~A" << endl; }
};

int main()
{
    try {
        A a;
        throw "error";
    }
    catch (const char* s) {
        cout << s << endl;
    }
    return 0;
}

由于函数调用栈的是先进后出的执行过程,在某一个栈空间被弹出时,在它上面后进的空间一定已经被弹出了,不可能出现内存碎片。

另外, 图中每种颜色都表示某个函数占用的栈空间。这部分空间有个特定的术语,叫做栈帧(stack frame)

3.RAII

我们知道在函数内部的一些成员是放置在栈空间上的,当函数返回时,这些栈上的局部变量就会立即释放空间,于是Bjarne Stroustrup就想到确保能运行资源释放代码的地方就是在这个程序段(栈)中放置的对象的析构函数了,因为stack winding会保证它们的析构函数都会被执行。RAII就利用了栈里面的变量的这一特点。

Stack Winding & Unwinding

    • 当程序运行时,每一个函数(包括数据、寄存器、程序计数器,等等)在调用时,都被映射到栈上。这就是 stack winding。
    • Unwinding 是以相反顺序把函数从栈上移除的过程。

正常的 stack unwinding 发生在函数返回时;不正常的情况,比如引发异常,调用setjmplongjmp,也会导致 stack unwinding。

可见 stack unwinding 的过程中,局部对象的析构函数将逐一被调用。这也就是 RAII 工作的原理,它是由语言和编译器来保证的。

RAII 的一般做法是这样的:在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个存放在栈空间上的局部对象

  • 这种做法有两大好处: 
    1. 不需要显式地释放资源。 
    2. 采用这种方式,对象所需的资源在其生命期内始终保持有效。

首先让我们来明确资源的概念,在计算机系统中,资源是数量有限且对系统正常运转具有一定作用的元素。

比如,内存,文件句柄,网络套接字(network sockets),互斥锁(mutex locks)等等,它们都属于系统资源。

由于资源的数量不是无限的,有的资源甚至在整个系统中仅有一份,因此我们在使用资源时必须严格遵循的步骤是:

  1. 获取资源
  2. 使用资源
  3. 释放资源

例子

void Func() 
{ 
  FILE *fp; 
  char* filename = "test.txt"; 
  if((fp=fopen(filename,"r"))==NULL) 
  { 
      printf("not open"); 
      exit(0); 
  } 
  ... // 如果 在使用fp指针时产生异常 并退出 
       // 那么 fp文件就没有正常关闭 
       
  fclose(fp); 
} 

在资源的获取到释放之间,我们往往需要使用资源,但常常一些不可预计的异常是在使用过程中产生,就会使资源的释放环节没有得到执行。

此时,就可以让RAII惯用法大显身手了。

RAII的实现原理很简单,利用stack上的临时对象生命期是程序自动管理的这一特点,将我们的资源释放操作封装在一个临时对象中。

具体示例代码如下:

class Resource{}; 
class RAII{ 
public: 
    RAII(Resource* aResource):r_(aResource){} //获取资源 
    ~RAII() {delete r_;} //释放资源 
    Resource* get()    {return r_ ;} //访问资源 
private: 
    Resource* r_; 
}; 

不难看出资源管理技术的关键在于:要保证资源的释放顺序与获取顺序严格相反。

这自然使我们联想到局部对象的创建和销毁过程。在C++中,定义在栈空间上的局部对象称为自动存储(automatic memory)对象。

管理局部对象的任务非常简单,因为它们的创建和销毁工作是由系统自动完成的。

我们只需在某个作用域(scope)中定义局部对象(这时系统自动调用构造函数以创建对象),然后就可以放心大胆地使用之,而不必担心有关善后工作;当控制流程超出这个作用域的范围时,系统会自动调用析构函数,从而销毁该对象。

读者可能会说:如果系统中的资源也具有如同局部对象一样的特性,自动获取,自动释放,那该有多么美妙啊!。事实上,您的想法已经与RAII不谋而合了。既然类是C++中的主要抽象工具,那么就将资源抽象为类,用局部对象来表示资源,把管理资源的任务转化为管理局部对象的任务。这就是RAII惯用法的真谛!可以毫不夸张地说,RAII有效地实现了C++资源管理的自动化。

综上所述,RAII的本质内容是用对象代表资源,把管理资源的任务转化为管理对象的任务,将资源的获取和释放与对象的构造和析构对应起来,从而确保在对象的生存期内资源始终有效,对象销毁时资源必被释放

  • 换句话说,拥有对象就等于拥有资源,对象存在则资源必定存在。由此可见,RAII惯用法是进行资源管理的有力武器。C++程序员依靠RAII写出的代码不仅简洁优雅,而且做到了异常安全。

(三)在C, Cpp, Rust 中的 RAII 设计模式

RAII设计模式】是将操作系统资源(物理概念)映射为代码内的对象(逻辑概念)。然后,自然而然地,

  • 资源调度的生命周期 映射为 对象的生命周期
  • 资源使用的申请环节 映射为 对象的构造函数 --- 对象生,则资源在。
  • 资源释放的归还环节 映射为 对象的析构函数 --- 对象亡,则资源灭。

将对资源的分配/回收操作代码从散乱的【业务代码】里抽象出来,并隐藏于【变量-生命周期管理】的成熟架构之后。而变量管理的万般规则都服务于一个主旨:超出了作用域,就执行析构函数,给我释放掉。于是,就有

  • 变量所属作用域终结 --- 如何终结不重要。管它是异常终止throw,提前return,还是“寿终正寝”,这都不重要。
  • 变量释放
  • 系统资源回收 --- 只要资源随着变量一起被释放,那就绝对的“异常安全exception safe”。

code review的视角来审视,

  • 业务代码也看着清爽 --- 少了随处的alloc/new,与需要烧脑才能搞明白的dealloc/delete出现位置。
  • 编译器也有更高级的事可做 --- 特别指Rust。那繁杂的所有权规则,开发者都能给绕晕。

  • C不支持RAII。若实现这个设计模式,开发者准备完全自己动手丰衣足食吧。或者寻找第三方库。
  • Cpp98版开始,标准库提供了对RAII的支持。但,需要调用std::scoped***()std::make_unique()std::make_shared()等专用构造器来意图明确地编码。同时,开发者还得随时警惕着遗留代码里的newdelete的非RAII代码(这类代码一般是非异常安全的not exception safe)。
  • Rust里,对【内存/互斥锁/文件句柄】的分配与回收已经和Rust【所有权变量】的生命周期完全耦合在一起了。RAII被实现为语言规范的一部分,也是被强制要求的编码范式,开发者根本就没得选。包括对数据库连接的管理 --- 至少,从第三方库暴露出来的接口来看(源码没有研究过),其调用套路还是RAII风格的。

Rust所有权变量超出了作用域时,

  • 变量自身会被释放
  • 该变量的析构函数也会自动释放被映射给它的资源

而这个过程是没有人工介入的。当然,你也可以借助std::mem::drop()函数来提早终结(实现了Drop Trait的)【所有权变量】的生命周期和释放系统资源。但,这对RAIIRust里的全面落地没有影响,仅能算是对应用灵活性的有益补充

视频推荐:What is RAII (Resource Acquisition Is Initialization)?

 

posted @ 2023-04-23 11:38  ImreW  阅读(24)  评论(0编辑  收藏  举报