9.5. 标准C++中的非常规内存管理

本章关于非常规内存管理的最后一个示例中,我们将重点讨论:如何用标准C++程序操作需要显式管理的"特殊内存"。

这里的“特殊”是指:

我们选择以(虚构的)共享内存块为例,演示标准C++如何与之交互。

白色的谎言⋯

需要明确的是,我们描述的是一种本应用于进程间共享内存的机制,但进程通信(IPC)实际属于操作系统管辖范畴。由于标准C++仅规范同一进程内线程间的数据共享规则,我们将采用一个“善意的简化” —— 基于多线程(而非多进程)模型来演示共享内存的数据交互。这种简化不会影响核心目标,因为这里关注点在于内存管理机制本身,而非进程间通信协议。

按照本章前面章节中的相同方法,我们将编写一个可移植的示例,展示如何在代码中处理非典型内存的管理,并将这些细节映射到所选平台的服务上。我们的示例代码将具有以下结构:

虚构的真实感?

接下来要讲述的关于 C++ 与“异类”内存的这一整节内容,希望能够引起你的兴趣,并且将编写的代码在内存管理方面会力求贴近现实场景。由于 C++ 标准对多进程系统的概念几乎没有涉及,因此会让多线程代码看起来有点像多进程代码。

本节的用户代码中会包含少量低层次的同步操作,包括一些通过原子变量实现的同步。我尽量让它保持简洁,同时又具备一定的现实合理性。虽然我不会对这些内容做详尽的解释,但希望你能够接受这种方式,毕竟本书的重点是内存管理,而不是并发计算(尽管那也是一门非常精彩的话题)。如果想了解更多关于原子操作等待机制,或使用线程内存屏障的相关知识,可以自由查阅相关的并发编程参考资料。

准备好了吗?我们开始吧!

9.5.1 虚构共享内存API接口

我们将编写一个虚构的 API,其设计灵感来源于大多数操作系统中常见的机制,只不过为了简化用户代码,将通过异常来报告错误。实际操作系统通常通过返回值中的错误码来报告错误,但这会导致用户代码变得更为复杂。

和大多数操作系统一样,会通过某种句柄(handle)或键(key)来抽象实际的资源。创建一段指定大小的“共享内存”区域后,会返回一个键(一个整数标识符);之后,访问该内存区域将需要这个键,销毁该内存区域时同样也需要它。正如可以预料的那样,对于一个用于进程间共享数据的设施来说,销毁内存并不会自动调用其中对象的析构函数,因此用户代码必须确保在释放共享内存段之前,其中的对象已经正确销毁。

我们为这个 API 设计的函数签名和类型如下所示:

// ...
#include <cstddef> // std::size_t
#include <new> // std::bad_alloc
#include <utility> // std::pair

class invalid_shared_mem_key {};

enum shared_mem_id : std::size_t;

shared_mem_id create_shared_mem(std::size_t size);

std::pair<void*, std::size_t> get_shared_mem(shared_mem_id);

void destroy_shared_mem(shared_mem_id);
// ...

这里使用了一个枚举类型(enum)来表示 shared_mem_id。这样做的原因是,在 C++ 中枚举类型是独立的类型,而不仅是像通过 typedef 或 using 所获得的类型别名。使用独立类型在为函数的参数进行重载时非常有用。

这是一个小技巧:如果编写两个同名函数(一个接受 shared_mem_id 类型的参数,另一个接受 std::size_t 类型的参数),即使 shared_mem_id 的底层类型是 std::size_t,这两个函数也会视为不同的函数。

由于正在构建一个“共享内存”的人工实现,以展示内存分配函数如何简化用户代码,因此 API 中的函数实现会尽量写得简单。但我们会编写出行为上,仿佛真的在使用共享内存的使用端代码。

我们将把一块共享内存段定义为一个 shared_mem_block,它由一个字节数组和一个表示字节数大小的数值组成。这里使用一个 std::vector 来保存这些内存块,并将该 vector 中的索引作为 shared_mem_id。当某个 shared_mem_block 销毁后,不会复用其索引(所以,这个 vector 最终会有一些“空洞”,或者说未使用的索引位置)。

我们的实现如下所示。这个实现并非线程安全,但这并不影响我们围绕内存管理展开的讨论:

// ...
#include <vector>
#include <memory>
#include <utility>

struct shared_mem_block {
  std::unique_ptr<char[]> mem;
  std::size_t size;
};

std::vector<shared_mem_block> shared_mems;
std::pair<void*, std::size_t>
get_shared_mem(shared_mem_id id) {
  if (id < std::size(shared_mems))
    return { shared_mems[id].mem.get(),
             shared_mems[id].size };
  return { nullptr, 0 };
}

shared_mem_id create_shared_mem(std::size_t size) {
  auto p = std::make_unique<char[]>(size);
  shared_mems.emplace_back(std::move(p), size);
  // 注意括号
  return shared_mem_id(std::size(shared_mems) - 1);
}

// 功能仅供内部使用
bool is_valid_shared_mem_key(shared_mem_id id) {
  return id < std::size(shared_mems) &&
  shared_mems[id].mem;
}

void destroy_shared_mem(shared_mem_id id) {
  if (!is_valid_shared_mem_key(id))
    throw invalid_shared_mem_key{};
  shared_mems[id].mem.reset();
}

如果希望进行实验,可以将这些函数的实现,替换为调用你所选操作系统相关函数的等效实现,并在必要时调整 API。

有了这个实现之后,就可以将一段“手工编写”的共享内存使用代码与一段利用了 C++ 内存管理设施的代码进行对比。我们将通过如下场景进行比较:在共享内存段中分配一块数据,然后启动两个线程(一个写线程和一个读线程)。写线程向这块共享数据写入内容,之后(通过最简单的同步机制)读线程从中读取内容。如前所述,代码将使用进程内同步机制(C++ 的原子变量),但在真正的多进程环境中,应该使用操作系统提供的进程间同步机制。

关于对象的生命周期

第 1 章中提到过,每个对象都有其对应的生命周期(lifetime),而编译器会在你的程序中跟踪这一信息。我们这个虚构的“多进程”示例实际上是一个单进程多线程的程序,因此仍然适用标准 C++ 的生命周期规则。

如果你打算将本节中的代码用于编写一个真正的多进程系统来进行测试,那么可能需要考虑在那些没有显式构造对象的进程中使用 C++23 中的 std::start_lifetime_as() 函数,以避免编译器基于“这些进程中对象从未被构造”的假设而进行有害的优化。在更早版本的编译器中,通常技巧是:对尚未正式构造的对象调用 std::memcpy(),将其内容复制到自身,从而有效地启动其生命周期。

在“手工编写”的版本和看起来更“标准”的实现中,都将使用一个由 int 类型值和一个布尔标志 ready 组成的数据对象:

struct data {
  bool ready;
  int value;
}

单进程实现中,对于完成标志(completion flag)更好的选择其实是一个 atomic<bool> 类型的对象,希望确保对 ready 标志的写入发生在对 value 的写入之后。但由于这个示例看起来像是在使用进程间共享内存,因此仅使用一个普通的 bool 类型,并通过其他方式来确保同步。

关于同步的说明

现代程序中,优化编译器通常会对看起来相互独立的操作进行重排,以生成更高效的代码;而处理器在代码生成之后也会做类似的重排操作,以最大化其内部流水线的利用率。然而,并发代码中有时包含了一些对编译器和处理器来说不可见的依赖关系。在示例中,希望只有在 value 被写入之后,ready 标志才变为 true。这种顺序之所以重要,是因为写操作是在一个线程中执行的,而另一个线程会通过检查 ready 来判断是否可以安全地读取 value。

如果不通过某种同步机制来强制保证 value 先于 ready 的写入顺序,那么编译器或处理器就可能对这两个(看起来相互独立的)写操作进行重排,从而破坏对 ready 标志的假设。

9.5.2 手工实现的用户代码示例

当然,可以编写使用虚构 API 的用户代码,而无需借助 C++ 中专门的内存管理设施,只需简单地依赖第 7 章中介绍的 placement new 用法即可。你可能会觉得 placement new 是一种特殊的机制,特别是如果是从本书中了解到它的,但如果持这种观点,建议重新思考一下:placement new 是一个基础的内存管理工具,几乎在每一个程序中都会用到,无论用户代码是否意识到这一点。

回顾一下,示例程序将执行以下操作:

因此,最终得到如下代码:

// ...
#include <thread>
#include <atomic>
#include <iostream>

int main() {
  // 我们需要一个N字节的共享内存块
  constexpr std::size_t N = 1'000'000;
  auto key = create_shared_mem(N);

  // 映射共享内存块中的数据对象
  auto [p, sz] = get_shared_mem(key);
  if (!p) return -1;

  // 启动非就绪数据对象的生命周期
  auto p_data = new (p) data{ false };
  std::atomic<bool> go{ false };
  std::atomic<bool> done{ false };

  std::jthread writer{ [key, &go] {
    go.wait(false);
    auto [p, sz] = get_shared_mem(key);

    if (p) {
      auto p_data = static_cast<data*>(p);
      p_data->value = 3;
      std::atomic_thread_fence(
        std::memory_order_release
      );
      p_data->ready = true;
    }
  } };

  std::jthread reader{ [key, &done] {
    auto [p, sz] = get_shared_mem(key);
    if (p) {
      auto p_data = static_cast<data*>(p);
      while (!p_data->ready)
        ; // 忙等待,并不酷
      std::cout << "read value "
                << p_data->value << '\n';
    }
    done = true;
    done.notify_all();
  } };

  if (char c; !std::cin.get(c)) exit(-1);
  go = true;
  go.notify_all();

  // 写入器和读取器运行到完成,然后完成
  done.wait(false);

  p_data->~data();
  destroy_shared_mem(key);
}

我们已经实现了这项功能:构建了一种用于管理共享内存段的基础设施,能够使用这些内存块来共享数据,并且可以编写代码对这些共享数据进行读取和写入。示例中,在每个线程中都将 key 捕获到一个局部变量中,并通过该 key 在每个 lambda 表达式内部获取对应的内存块。当然,另一种合理的方式是直接捕获 p_data 指针并使用。

不过,我们并没有真正地管理这块内存:只是创建了它,并在其开头使用了一个大小为 sizeof(data) 的小块。那么,如果想在同一个内存段中创建多个对象该怎么办呢?又或者,希望编写既能创建又能销毁对象的代码,从而引入了对内存块中,哪些部分正在被使用的管理需求,又该如何应对呢?使用我们刚刚所写的代码,这所有这些管理工作都必须在用户代码中完成,这将是一项相当繁琐的任务。

鉴于此,我们现在将以一种不同的方法来解决同样的问题。

9.5.3 符合标准规范的对等实现

如果希望以更符合 C++ 习惯的方式使用“异类”内存,C++ 又提供了哪些机制呢?一种可行的方式如下:

这样一来,用户代码就可以基本上以“看起来正常”的方式编写,调用 new 和 delete 操作符,只不过这些调用会使用一种扩展形式 —— 就像第 7 章中介绍的 nothrow 或 placement new 那样。

shared_mem_mgr 类将使用本节前面描述的虚构操作系统 API。实际情况下,应该编写一个类来封装程序中所需访问的非常规内存所对应的操作系统服务。

作为一个为了说明功能和用法而设计的简化示例,希望读者们能看到其在性能和设计上的诸多改进空间。确实,这个管理器效率很低且内存开销大:使用了一个 std::vector<bool> 来记录内存块中每个字节是否被占用,并在每次分配请求时进行一次简单的线性查找(而且它不是线程安全的,这是个问题!)。

我们将在第 10 章进一步探讨一些实现质量方面的考量,但在此之前,完全可以自行改进 shared_mem_mgr,使其更加高效。

shared_mem_mgr 是一个符合 RAII 惯用法的类型:构造函数创建共享内存段,析构函数释放该内存段;并且像大多数 RAII 类型一样,它不可复制。

接下来的代码段中,需要重点关注的成员函数是 allocate() 和 deallocate():

#include <algorithm>
#include <iterator>
#include <new>

class shared_mem_mgr {
  shared_mem_id key;
  std::vector<bool> taken;
  void *mem;

  auto find_first_free(std::size_t from = 0) {
    using namespace std;
    auto p = find(begin(taken) + from, end(taken), false);
    return distance(begin(taken), p);
  }

  bool at_least_free_from(std::size_t from, int n) {
    using namespace std;
    return from + n < size(taken) &&
      count(begin(taken) + from,
      begin(taken) + from + n, false) == n;
  }

  void take(std::size_t from, std::size_t to) {
    using namespace std;
    fill(begin(taken) + from, begin(taken) + to,
      begin(taken) + from, true);
  }

  void free(std::size_t from, std::size_t to) {
    using namespace std;
    fill(begin(taken) + from, begin(taken) + to,
      begin(taken) + from, false);
  }

public:
  // 创建共享内存块
  shared_mem_mgr(std::size_t size)
    : key{ create_shared_mem(size) }, taken(size) {
    auto [p, sz] = get_shared_mem(key);
    if (!p) throw invalid_shared_mem_key{};
    mem = p;
  }

  shared_mem_mgr(const shared_mem_mgr&) = delete;
  shared_mem_mgr& operator=(const shared_mem_mgr&) = delete;

  void* allocate(std::size_t n) {
    using namespace std;
    std::size_t i = find_first_free();
    // 效率极低
    while (!at_least_free_from(i, n) && i != size(taken))
      i = find_first_free(i + 1);

    if (i == size(taken)) throw bad_alloc{};
      take(i, i + n);

    return static_cast<char*>(mem) + i;
  }

  void deallocate(void *p, std::size_t n) {
    using namespace std;
    auto i = distance(
      static_cast<char*>(mem), static_cast<char*>(p)
    );
    take(i, i + n);
  }

  ~shared_mem_mgr() {
    destroy_shared_mem(key);
  }
};

shared_mem_mgr 真的只是一个管理一块内存块的类,其中没有任何“魔法”成分。如果有人希望改进内存管理算法,完全可以做到,而且无需修改该类的接口,这正是封装所带来的低耦合优势。

如果想动手试试……

一个有趣的改进 shared_mem_mgr 的方式是:首先,保持该类负责共享内存的分配与释放(它目前已经做到了这一点);然后,编写一个不同的类,专门用于管理这块共享内存内部的内存布局;最后,让这两个类协同工作。

这样一来,就可以将 shared_mem_mgr 与不同的内存管理算法配合使用,根据各个程序或程序各部分的具体需求,灵活选择内存管理策略。这是一个非常值得尝试的有趣练习!

接下来要实现的是重载的 operator new 和 operator delete,它们接受一个 shared_mem_mgr& 类型的参数。这项工作非常简单:这些重载只需将工作委托给内存管理器对象即可。

void* operator new(std::size_t n, shared_mem_mgr& mgr) {
  return mgr.allocate(n);
}

void* operator new[](std::size_t n, shared_mem_mgr& mgr) {
  return mgr.allocate(n);
}

void operator delete(void *p, std::size_t n,
                     shared_mem_mgr& mgr) {
  mgr.deallocate(p, n);
}

void operator delete[](void *p, std::size_t n,
                       shared_mem_mgr& mgr) {
  mgr.deallocate(p, n);
}

有了管理器和这些重载函数之后,就可以编写一个测试程序,完成与上一节中“手工编写”版本相同的任务。然而,这一次的区别为:

在非常规内存中构造对象的过程非常简单:只需在调用 new 或 new[] 操作符时传入额外的参数即可。然而,通过此类管理器管理的对象的最终化(析构)过程则稍微复杂一些:我们不能像平常那样对指针执行 delete p,这会尝试通过“标准方式”来析构对象并释放内存。相反,需要手动析构对象,然后手动调用合适的 operator delete() 版本,以便完成非常规内存的清理工作。

当然,结合我们在第 6 章中所写的代码,可以将这些任务封装在自己实现的智能指针中,从而获得更简洁、更安全的用户代码。

最终,可以得到如下示例程序:

int main() {
  // 我们需要一个N字节的共享内存块
  constexpr std::size_t N = 1'000'000;

  // HERE
  shared_mem_mgr mgr{ N };

  // 启动非就绪数据对象的生命周期
  auto p_data = new (mgr) data{ false };
  std::atomic<bool> go{ false };
  std::atomic<bool> done{ false };

  std::jthread writer{ [p_data, &go] {
    go.wait(false);
    p_data->value = 3;
    std::atomic_thread_fence(std::memory_order_release);
    p_data->ready = true;
  } };

  std::jthread reader{ [p_data, &done] {
    while (!p_data->ready)
      ; // busy waiting, not cool
    std::cout << "read value " << p_data->value << '\n';
    done = true;
    done.notify_all();
  } };

  if (char c; !std::cin.get(c)) exit(-1);

  go = true;
  go.notify_all();

  // 写入器和读取器运行到完成,然后完成
  done.wait(false);
  p_data->~data();
  operator delete(p_data, sizeof(data), mgr);
}

这虽然仍然不是一个简单的例子,但与“手工编写”的版本相比,内存管理方面显然要简单得多,而且任务的模块化也使得优化内存管理方式变得更加容易。

……终于完成了!呼!这一章的内容真是精彩又充实!