“资源”的定义和所有权

在开始资源管理这个话题之前,我们首先要搞清楚“资源”是个什么概念。实际上,“资源”的定义非常简单:资源就是跟 OS 借的,用完要归还的东西,比如,内存就是非常常见的资源,线程、文件句柄、网络连接也是资源。

这里还涉及到“所有权”的问题。“所有权”这个概念也很好理解,实际上就是资源归属于谁,比如 std::vector 申请的内存就归属于相应的对象,换言之,该对象就是这块内存的所有者,当我们通过 vec.data() 获取到一个指针或者通过 vec[i] 访问其中的元素时,我们就是借用了这块内存(的一部分)。

所有权一定可以表示为树形的结构。例如,std::vector<Point> xs = {{1, 2}, {3, 4}} 可以表示为:

xs
|-- Point
|   |-- 1
|   |-- 2
|-- Point
    |-- 3
    |-- 4

C 语言的资源管理

C 语言中的资源管理要手动完成:

void foo() {
    resource_t r = create_resource();
    int err = use_resource(r);
    if (err)
        goto on_error;
    use_resource_again(r);
    goto cleanup;
on_error:
    report_error(err);
cleanup:
    destroy_resource(r);
}

这种方式有几个问题:

  • 最大的问题是,程序员必须记得写 goto 语句,不然可能会发生一些神奇的事情;

  • 另一个问题是,goto 排列的顺序可能也很有讲究

  • 其三,使用 goto 本身在结构化编程中是不被提倡的

感兴趣的可以参考 EWD 的文章《Goto Statement Considered Harmful》

实际上还有一种类似的方式:

void foo() {
    resource_t r = create_resource();
    int err = foo_internal(r);
    if (err)
        report_error(err);
    destroy_resource(r);
}

int foo_internal(resource_t r) {
    int err = use_resource(r);
    if (err)
        return err;
    use_resource_again(r);
    return 0;
}

这样会比较麻烦,而且如果涉及到合并错误码的问题。

如果仅使用 goto 语句并且函数规模较小,那么问题通常可以在 code review 阶段发现,但是 C 标准库还提供了另外一种跨函数(栈帧)跳转的机制 —— setjmplongjmp,该机制类似于 C++ 中的异常处理,但是原始得多 —— 会导致资源管理进一步变复杂。

内存池案例研究

PostgreSQL 的 MemoryContext

用于管理一个 transaction 的内存

Clang 的 ASTContext

Zig 语言的 allocator 机制

RAII

C++ 中一切资源管理机制的基础是 RAII(Resource Acquisition Is Initialization,也可称为 RRID,Resource Release Is Destruction),一般的模式如下:

class RAIIGuard {
  resource_t *_res:

 public:
  RAIIGuard(resource_t *r): _res{r} /* Set efficiency aside. */ {}
  ~RAIIGuard() { destroy_resource(_res); }
  // Copy- and move-ctors/assignment operators are omitted and will be discussed later.

 public:
  resource_t &operator*() { return *_res; }
  resource_t *operator->() { return _res; }
  resource_t *get() { return _res; }
};

前文的 C 代码就可以改写成:

void foo() {
    RAIIGuard r{create_resource()};
    int err = use_resource(r.get());
    if (err) {
        report_error(err);
        return;
    }
    use_resource_again(r);
}

智能指针

前文展示的 RAIIGuard 实际上是一种 智能指针,但是功能非常简单。C++ 标准库的 <memory> 头文件中为我们提供了几种智能指针。

unique_ptr

std::unique_ptr 是最基本的智能指针,它可以用来表示独占所有权。用法:

void foo() {
  std::unique_ptr<int> x1{new int{42}};
  std::cout << *x1 << '\n';

  auto x2 = std::make_unique<int>(42);
  std::cout << *x2 << '\n';

  std::unique_ptr<int[]> xs{new int[10]{}};
  for (std::size_t i = 0; i < 10; i++) {
    xs[i] = i+1;
  }
  std::copy(std::begin(*xs), std::end(*xs), std::ostream_iterator<int>(std::cout), ' ');
}

在析构时,unique_ptr 默认的行为是调用其所有的对象的析构函数并释放相应的内存,不过我们也可以修改这个行为。

void destroy(int *p) {
  std::cout << std::format("[{}] p={}", __PRETTY_FUNCTION__, p) << '\n';
  delete p;
}

void foo() {
  std::unique_ptr<int, decltype(&destory)> x1{new int{42}, destroy};  (1)

  auto deleter = [](int *p) {
    std::cout << std::format("[{}] p={}", __PRETTY_FUNCTION__, p) << '\n';
    delete p;
  };
  std::unique_ptr<int, decltype(deleter)> x2{new int{42}, deleter};
}

这里需要注意,Deleter 模板参数会影响 std::unique_ptr 的大小:如果传入的是一个函数指针,那么 std::unique_ptr 就会为 16 字节;而如果传入一个没有捕获任何变量的 lambda,std::unique_ptr 的大小仍然为 8 字节,所以可能的情况下更加推荐使用无变量捕获的 lambda 作为 deleter。

shared_ptr

std::shared_ptr 用于表达共享所有权:

void foo() {
  std::shared_ptr<int> x{new int{42}};
  std::cout << x.use_count() << '\n';
  {
    std::shared_ptr<int> x_copy = x;
    std::cout << std::format("{} {}", x.use_count(), x.get() == x_copy.get()) << '\n';
  }
  std::cout << x.use_count() << '\n';
}

std::unique_ptr 类似,std::shared_ptr 也支持定制 Deleter,但是不需要出现在模板参数中:

void destroy(int *p) {
  std::cout << std::format("[{}] p={}", __PRETTY_FUNCTION__, p) << '\n';
  delete p;
}

void foo() {
  std::shared_ptr<int> x{new int{42}, destroy};
}

这是因为 std::shared_ptrstd::unique_ptr 的实现机制不同,std::unique_ptr 的实现类似于:

template <typename T, typename Deleter>
class unique_ptr {
  T _data;
  Deleter _del;
};

C++ 要求类的大小必须是编译期确定的,而在 deleter 可能是函数指针/函数对象(lambda 也是函数对象)的情况下,比较直观的办法就是增加一个模板参数。而 std::shared_ptr 为了在多个对象之间共享引用计数,必须将其放在堆内存上,因而 std::shared_ptr 的实现类似于:

template <typename T>
class shared_ptr {
  Impl<T, Deleter> *_impl;
};

template <typename T, typename Deleter>
class Impl {
  T _data;
  std::size_t _ref_count;
  Deleter _del;
};

这意味着 std::shared_ptr<T> 占用的堆内存可能比 std::unique_ptr<T> 更少,但是由于间接层的存在,std::shared_ptr<T> 的性能稍逊于 std::unique_ptr<T>

weak_ptr

如前文所述,std::shared_ptr 是基于引用计数机制的,但是这种机制无法处理环形引用,如果 A 和 B 之间存在双向的引用,就会由于两个引用计数都无法减到 0 而产生内存泄漏(实际上,互相所有本身就是逻辑缺陷)。这时候不维护引用计数的 std::weak_ptr 就派上了用场。

class Vertex {
  std::vector<std::shared_ptr<Vertex>> _downstream;
  std::vector<std::weak_ptr<Vertex>> _upstream;
};

使用建议

前文只是讲解了相关的 API,并没有说明各种智能指针应该用在什么场景下,下面是一般的准则:

  • 一般情况下,std::unique_ptr 加上裸指针就足够了

  • 对于所有权不明晰的情况,可以使用 std::shared_ptrstd::weak_ptr

用 unique_ptr 实现双向链表

class LinkedList {
  struct Node {
    std::unique_ptr<Node> next;
    Node *prev;
    int data;
  };

  std::unique_ptr<Node> _dummy_head;

 public:
  LinkedList(): _dummy_head{new Node{}} {

  }
  void push_front(int v) {

  }
};