我们在探索 C++内存管理方面正取得进展。在第 7 章中,我们探讨了重载 operator new() 和 operator delete()(及其数组版本)的各种语法方式;在第 8 章中,我们编写了一个实际应用示例(内存泄漏检测器),依赖于编写此类重载的能力。 这是一个良好的开端,具体展示了这些知识的实际用途,但您可能(理所当然地)想知道在控制内存管理设施时我们还能做些什么。
本章将与其他章节略有不同。 我们将在此展示一系列非穷尽的方法,说明如何通过掌控 C++的内存分配函数来获益。 更具体地说,我们将展示以下内容:
- 如何通过定位 new 让我们高效驱动内存映射硬件
- 如何通过 nothrow 版本的 operator new() 简化错误管理
- 如何安装并使用 std::new_handler 来更轻松地应对内存不足的情况
- 如何通过标准 C++ 的媒介处理诸如共享内存或持久内存这类"特殊"内存
在本章结束时,我们将更全面地了解 C++基本内存分配机制为我们提供了哪些可能性。 后续章节将回归更专注的主题,如基于内存池的分配( 第 10 章 )、延迟回收( 第 11 章 ),以及后续章节中关于如何通过容器和分配器控制内存分配的内容。
20年工作经验,承接微信小程序,App,网站,网站后端开发。有意向私聊我哈。微信:akluse
placement new 与内存映射硬件
placement new 有多种用途 (new 操作符的一种重要形式),正如你可能记得的, 这个特性在第 7 章讨论过。其中一个特别有趣的用途是,它允许我们将软件对象映射到内存映射硬件,实际上让我们能够像操作软件一样驱动硬件。
要编写这个功能的工作示例会很棘手,因为我们将进入"非可移植代码领域",需要使用操作系统特定的功能来获取特定设备的地址,并讨论如何获取对通常由软件驱动程序访问的内存位置的读写权限。 因此,我们将构建一个人工但具有说明性的示例,并请尊敬的读者想象这个示例中缺失的部分是存在的。
首先,假设我们正在为一块新显卡开发驱动程序,这块显卡性能卓越,其代号为 super_video_card。 为了说明这一点,我们将通过以下类来建模:
#include <cstdint>
class super_video_card {
// ...
public:
// super duper registers
volatile std::uint32_t r0{}, r1{}, r2{}, r3{};
static_assert(sizeof(float) == 4); // sanity check
volatile float f0{}, f1{}, f2{}, f3{};
// etc.
// initialize the video card's state
super_video_card() = default;
super_video_card(const super_video_card&) = delete;
super_video_card&
operator=(const super_video_card&) = delete;
// could be used to reset the video card's state
~super_video_card() = default;
// various services (omitted for brevity)
};
// ...
就我们的目的而言,这个类的重要特性包括以下几点:
- 这是一种不可复制的类型,因为它需要映射到特定的内存区域。 复制该类型的对象不仅无益, 甚至适得其反。
- 它的设计方式使其状态在概念上可以叠加到其硬件等效物上。 例如,给定前面的类声明,从硬件内存布局的起始位置开始,我们预期会有四个 32 位整数寄存器,随后是四个 32 位浮点寄存器。 我们使用 <cstdint> 来获取我们编译器上固定宽度整数类型的别名。
- 在这种情况下,我们尽可能通过 static_assert 来表达我们的预期。 此外,由于硬件寄存器的状态可能通过我们程序之外的其他操作而改变,我们将寄存器等效物限定为 volatile,这样对这些成员变量的访问就相当于 C++ 抽象机中的 I/O 操作。
为什么在这个例子中我们要使用 volatile 变量?
如果您不习惯使用 volatile 变量,可能会好奇为什么我们要在内存映射硬件表示类的数据成员上使用这个限定符。 之所以重要,是因为我们希望避免编译器基于(在本案例中错误的)假设进行代码优化——即如果我们的代码没有访问这些变量,它们的状态就不会改变;或者如果对这些变量的写入操作后没有紧接着读取操作,就可以认为这些写入没有实际效果。 通过 volatile 限定的变量,我们实际上是在告诉编译器:"这些对象上发生的某些变化是你所不知道的,所以请不要过度推断 "。
为简化起见,我们使用了将数据成员清零的构造函数和一个简单的析构函数,但在实际应用中,我们可以通过构造函数(默认或其他形式)来初始化内存映射设备的状态以满足需求,并通过析构函数将该设备状态重置为某种可接受的状态。
通常,程序要访问内存映射硬件时,我们可能需要与操作系统进行通信,通过接收参数的服务来识别所需设备的地址信息。 在本例中,我们将简单地模拟访问一块大小和对齐方式合适的内存区域,并对其进行读写操作。 该内存地址以原始内存形式(类型为 void*)暴露, 这正是我们在类似情况下能实际从操作系统函数获得的结果:
// somewhere in memory where we have read / write
// access privileges is a memory-mapped hardware
// that corresponds to the actual device
alignas(super_video_card) char
mem_mapped_device[sizeof(super_video_card)];
void* get_super_card_address() {
return mem_mapped_device;
}
// ...
接下来我们探讨如何利用定位 new 将对象映射到某些内存映射的硬件位置。 注意需要包含 <new> 头文件,因为定位 new 是在这里定义的。 实现目标的步骤如下:
- 首先,获取我们想要映射精心设计的 super_video_card 对象的地址。
- 然后,通过在该地址处使用定位 new,构造一个 super_video_card 对象,使该对象的数据成员对应其所代表寄存器的地址。
- 在该对象的生命周期内,通过相应的指针使用该对象(以下代码摘录中的the_card变量)。
- 当我们完成时,我们最不希望做的一件事是对the_card应用operator delete(),因为我们从未首先分配相关的内存。但是,我们希望通过~super_video_card()来完成该对象,以确保运行该对象的清理或重置代码(如果有)。
因此,我们最终得到以下结果:
// ...
#include <new>
int main() {
// map our object to the hardware
void* p = get_super_card_address();
auto the_card =
new(p) super_video_card{ /* args */ };
// through pointer the_card, use the actual memory-
// mapped hardware
// ...
the_card->~super_video_card();
}
如果显式调用析构函数存在问题,例如在可能抛出异常的代码中,我们可以使用带有自定义删除器的 std::unique_ptr 对象(参见第 5 章 )来终结 super_video_card 对象:
// ...
#include <new>
#include <memory>
int main() {
// map our object to the hardware
void* p = get_super_card_address();
std::unique_ptr<
super_video_card,
decltype([](super_video_card *p) {
p->~super_video_card(); // do not call delete p!
})
> the_card {
new(p) super_video_card{ /* args */ }
};
// through pointer the_card, use the actual memory-
// mapped hardware
// ...
// implicit call to the_card->~super_video_card()
}
在此情况下,std::unique_ptr 对象会终结指针目标(即 super_video_card 对象),但不会释放其内存存储,从而在 the_card 变量生命周期内出现异常时实现更健壮的代码。
简化 nothrow new 的使用
如第 7 章所述,operator new() 在无法完成内存分配请求时的默认行为是抛出异常。 这种情况可能源于内存耗尽或其他无法满足分配请求的情形,此时通常会抛出 std::bad_alloc;若因数组长度错误(例如负长度或超出实现定义限制的长度),则通常导致抛出 std::bad_array_new_length;或者在 operator new() 完成后对象构造失败时,此时抛出的异常将来自失败的构造函数 。
异常是 C++函数用于表示无法满足函数后置条件的“常规”方式。 在某些情况下,比如构造函数或重载运算符时,这甚至是唯一真正可行的方法:构造函数没有返回值,而重载运算符的函数签名通常没有为额外参数或错误报告返回值留出空间,尽管对于某些类型(如 std::optional 或 std::expected)可以论证它们为某些重载运算符的使用场景提供了替代方案。
当然,某些领域通常不使用异常处理:例如大量电子游戏在编译时禁用了异常支持,许多为嵌入式系统编写的程序亦是如此。 原因既有技术层面的考量(担心在内存空间占用、执行速度或两者兼而有之方面产生不可接受的性能开销),也有理念层面的抵触(反对被视为隐藏控制流的机制),但无论原因如何,事实是存在禁用异常支持编译的 C++代码,而 nothrow 版本的 operator new() 确实客观存在。
这当然意味着,即使是如下看似简单的代码也可能导致未定义行为 (UB):
#include <new>
#include <iostream>
struct X {
int n;
X(int n) : n { n } { }
};
int main() {
auto p = new (std::nothrow) X{ 3 };
std::cout << p->n; // <-- HERE
delete p;
}
这种潜在未定义行为的原因是,如果 nothrow 版本的 operator new() 失败(虽然可能性很小但并非不可能,特别是在内存受限的情况下),那么 p 将为 null,而通过 p 访问 n 数据成员将会是...一个非常糟糕的主意。
当然,解决方案很简单,作为敏锐的读者,您可能已经注意到了:只需在使用指针前进行测试! 这当然有效,如下所示:
#include <new>
#include <iostream>
struct X {
int n;
X(int n) : n { n } { }
};
int main() {
auto p = new (std::nothrow) X{ 3 };
if(p) {
std::cout << p->n; // ...use *p as needed...
}
delete p; // fine even in p is null
}
这种方法的问题在于代码很快就会充斥着各种测试,因为程序中很少只有一个指针,这提醒我们使用异常处理的代码之美在于无需担心这些测试。 通过异常处理,要么 operator new() 及后续的构造都成功完成,可以放心使用返回的指针;要么其中某一步骤失败,代码执行根本不会到达可能引发问题的地方:
#include <new>
#include <iostream>
struct X {
int n;
X(int n) : n { n } { }
};
int main() {
auto p = new X{ 3 }; // throws if operator new() or
// X::X(int) fails
std::cout << p->n; // ...use *p as needed...
delete p;
}
当然,即使使用异常处理, 也可能遇到问题。例如,当存在某些执行路径会导致 p 保持为 null 或未初始化状态,而其他路径则不会出现这种情况时(通常可以通过在声明时初始化对象来避免,但这并非总是可行)。我们暂且将这些代码规范问题搁置一旁,以免偏离当前关注的主题。
面对内存分配失败的情况时,一个重要的考量是当这种情况发生时该如何处理。 无论我们的代码库是否使用异常机制,我们很可能都不希望让程序继续执行,从而避免因诸如空指针的错误使用等问题导致未定义行为。
一种常见的在分配失败时停止执行的方法是将试探性分配和构造操作、对结果指针的后续测试以及指针为空时采取的操作封装在某个代码结构中。 假设我们要分配并构造一个 int 对象,待封装的代码大致如下:
// ...
int *p = new int{ 3 };
if(!p) std::abort(); // for example
return p;
// ...
这段代码使用了 std::abort() 作为终止程序执行的机制;异常本可以提供潜在可恢复的错误处理方式,但在没有异常的情况下,我们可使用的大多数标准机制都会导致程序终止,而 std::abort() 在这种情况下是一个合理的选择。
程序终止执行的方式
C++程序可以通过多种不同方式结束:最明显的是到达 main() 函数的末尾,但也存在其他方式。 例如,std::exit() 用于伴随清理步骤的正常程序终止;std::quick_exit() 则用于无需清理步骤的程序终止。 可以使用 std::atexit() 和 std::at_quick_exit() 来注册一些在退出前调用的函数,而 std::abort() 用于表示无需清理步骤的异常程序终止。 当文档中列出的某些异常情况发生时(该列表包括从 static 变量的构造函数或 noexcept 函数体中抛出异常等情况),就会调用 std::terminate() 函数。 在我们的案例中,唯一真正适用的机制是 std::abort().
解决此问题的一种可能方法是使用宏和立即调用函数表达式 (IIFE),这是指由匿名 lambda 表达式构成的、创建后立即执行并丢弃的表达式。 为了使我们的解决方案具有通用性,我们需要能够实现以下功能:
- 指定要创建的对象类型
- 将宏设为可变参数,因为我们需要能够向对象的构造函数传递任意数量和类型的参数
这种宏的一个可能实现是 TRY_NEW,如下所示:
#include <new>
#include <cstdlib>
#define TRY_NEW(T,...) [&] { \
auto p = new (std::nothrow) T(__VA_ARGS__); \
if(!p) std::abort(); \
return p; \
}()
struct dies_when_newed {
void* operator new(std::size_t, std::nothrow_t) {
return {};
}
};
int main() {
// p0 is int*, points to an int{ 0 }
auto p0 = TRY_NEW(int);
// p1 is int*, points to an int{ 3 }
auto p1 = TRY_NEW(int, 3);
auto q = TRY_NEW(dies_when_newed); // calls abort()
}
并非所有人都熟悉可变参数宏,因此让我们逐步讲解:
- 我们宏的"签名"是 TRY_NEW(T,...),表示 T 是必需的,而 ... 可以是任意数量的用逗号分隔的标记(包括完全没有)。 不出所料,我们将使用 T 作为要构造的类型,... 作为传递给将被调用的构造函数的参数。
- 由于我们将宏编写在多行上(为了可读性),除最后一行外,每行都以空格加反斜杠结尾,以此告知预处理器应继续在下一行进行解析。
- 位于 ... 上的符号会通过名为 __VA_ARGS__ 的特殊宏进行传递,该宏会展开为 ... 所包含的内容;若 ... 本身为空,则该宏也可为空。 此机制在 C 和 C++中均适用。 请注意,在构造函数调用中我们使用圆括号而非花括号,这是为了避免当 __VA_ARGS__ 的所有元素都是相同类型时意外构建初始化列表。
- 我们测试调用 p 指针的结果,该指针来自 std::nothrow 版本的 operator new(),并在 std::abort() 时调用,如果 p 为空。
- 正如所宣布的,整个操作序列被包装在一个立即调用的函数表达式(IIFE)中,并返回新分配的指针。 注意,如果我们愿意,也可以从该 lambda 返回一个 std::unique_ptr<T> 对象。 另外,请注意这个 lambda 表达式使用了 [&] 捕获块,以确保在 __VA_ARGS__ 中的标记在 lambda 的作用域内可用。
一个虽小但有趣的副作用
请注意,由于我们使用了圆括号(大括号同理),当 __VAR_ARGS__ 为空时,该宏会导致 int 等基本类型被零初始化,而非保持未初始化状态。 可以对比:在 C++23 中,new int; 会返回指向未初始化 int 对象的指针,而 new int(); 和 new int{}; 都会将分配的内存块初始化为零值。 这样做的好处是,使用该宏时我们不会得到指向未初始化对象的指针,即使是简单类型也是如此。 但同时也存在缺点,因为在某些本可能不需要初始化的情况下,我们仍需付出初始化的代价。
另一种方法是使用可变参数函数模板,这在实际应用中可能会带来更好的调试体验。 虽然客户端代码看起来略有不同,但在使用方式上和效果上基本相似:
#include <new>
#include <cstdlib>
#include <utility>
template <class T, class ... Args>
auto try_new(Args &&... args) {
auto p =
new (std::nothrow) T(std::forward<Args>(args)...);
if(!p) std::abort();
return p;
}
struct dies_when_newed {
void* operator new(std::size_t, std::nothrow_t) {
return {};
}
};
int main() {
// p0 is int*, points to an int{ 0 }
auto p0 = try_new<int>();
// p1 is int*, points to an int{ 3 }
auto p1 = try_new<int>(3);
auto q = try_new<dies_when_newed>(); // calls abort()
}
可变参数函数版本的调用语法看起来像类型转换, 传递给 try_new() 的参数会被完美转发到 T 的构造函数,以确保最终调用的是预期的构造函数。 与宏的情况类似, 我们本可以选择返回一个 std::unique_ptr<T> 对象而非 T* 对象, 通过此函数实现。
内存不足情况与 new_handler
在本书中,包括本章在内,我们一直强调当内存分配失败时,operator new() 和 operator new[]() 通常会抛出 std::bad_alloc 异常。 这在很大程度上是正确的,但有一个我们至今回避的微妙之处,现在我们将花些时间来详细探讨。
设想这样一种情况:用户代码专门定制了内存分配函数,从一个具有特殊性能特征的预分配数据结构中获取内存块。 假设该数据结构最初只为少量内存块分配空间,当用户代码耗尽初始分配的内存块后,该结构会继续分配更多空间。 换句话说:在这种情况下,我们有一个初始的快速配置(可称之为"乐观"状态)和一个次级配置(可称之为"二次机会"状态),当"乐观"状态的资源被耗尽后,该状态允许用户代码继续分配内存。
要使此类场景能够无缝衔接,在无需用户代码显式干预的情况下透明地更改分配策略,仅抛出 std::bad_alloc 是不够的。 抛出异常会终止 operator new() 的执行,客户端代码当然可以捕获该异常并采取行动,但在这种(合理的)场景中,我们希望分配失败能触发某些操作,并让 operator new() 根据更新后的状态( 如果有的话 )再次尝试。
在 C++中,这类情况通过 std::new_handler 处理,它是 void(*)() 类型的函数指针别名。 需要了解的是以下几点:
- 程序中存在一个全局的 std::new_handler,默认情况下其值为 nullptr.
- 可以通过 std::set_new_handler() 函数设置当前的 std::new_handler,并通过 std::get_new_handler() 函数获取当前的 std::new_handler。需要注意的是,std::set_new_handler() 会返回 std::new_handler 的原有值 ,这是一个便利特性。
- 当诸如 operator new() 这样的分配函数失败时,它首先应该获取当前活动的 std::new_handler。 如果该指针为空,则分配函数应像我们目前所做的那样抛出 std::bad_alloc;否则,它应该调用该 std::new_handler,并在该调用所设置的新条件下再次尝试分配。
正如预期的那样,标准库应该已经实现了这一算法,但我们自己重载的 operator new() 和 operator new[]() 尚未实现该功能,至少目前如此。 为了展示如何利用 std::new_handler,我们现在将实现一个模拟上述两步场景的人工版本。
这个玩具实现将使用 X 类型的成员版本分配操作符,并表现得好像我们最初有足够内存来容纳 limit 个该类型的对象(通常我们会实际管理这些内存,您可以在第 10 章看到一个更实际的示例)。 我们将安装一个 std::new_handler,当被调用时会将 limit 改为更大的数值,然后将活动处理器重置为 nullptr,这样后续分配 X 对象失败时将导致抛出 std::bad_alloc:
#include <new>
#include <vector>
#include <iostream>
struct X {
// toy example, not thread-safe
static inline int limit = 5;
void* operator new(std::size_t n) {
std::cout << "X::operator new() called with "
<< limit << " blocks left\n";
while (limit <= 0) {
if (auto hdl = std::get_new_handler(); hdl)
hdl();
else
throw std::bad_alloc{};
}
--limit;
return ::operator new(n);
}
void operator delete(void* p) {
std::cout << "X::operator delete()\n";
::operator delete(p);
}
// same for the array versions
};
int main() {
std::set_new_handler([]() noexcept {
std::cout << "allocation failure, "
"fetching more memory\n";
X::limit = 10;
std::set_new_handler(nullptr); // as per default
});
std::vector<X*> v;
v.reserve(100);
try {
for (int i = 0; i != 10; ++i)
v.emplace_back(new X);
} catch(...) {
// this will never be reached with this program
std::cerr << "out of memory\n";
}
for (auto p : v) delete p;
}
注意 X::operator new() 处理失败的方式:当它发现无法满足后置条件时,会获取当前活动的 std::new_handler,若非空则调用该处理程序后再次尝试。 这意味着被调用的 std::new_handler 必须要么改变内存状态使后续分配可能成功,要么将 std::new_handler 设为 nullptr 以触发异常抛出。 违反这些规则将导致无限循环并引发严重后果。
这个示例程序中安装在 main() 中的处理函数实现如下功能:当被调用时,它会改变内存分配的执行条件(提高 X::limit 的值)。 随后它调用 std::set_new_handler() 并传入 nullptr,因为我们没有为"乐观尝试"和"二次机会"之后的情况准备其他方案,所以如果耗尽了这些二次机会资源,我们(用他们的话说) 就完蛋了。
将 LAMBDA 作为 NEW_HANDLER?
你可能已经注意到,我们将 std::new_handler 类型描述为 void(*)() 类型的函数指针别名,但在示例中却安装了一个 lambda 表达式。 为什么这样做可行? 实际上,无状态的 lambda——即捕获块为空的 lambda 表达式——可以隐式转换为具有相同调用签名的函数指针。 这个特性在很多场景下都很有用,比如编写需要与 C 代码或操作系统 API 交互的 C++代码时。
接下来我们将进入本章一个颇为奇特且技术性较强的部分,探讨如何利用 C++来操作非典型内存。
标准 C++与非常规内存
本章内容略显奇特,展示了非常规内存管理用法的示例,而我们的最后一个例子关注的是如何编写标准 C++程序来处理“异质”内存。 所谓“异质”,指的是需要显式操作(分配、读取、写入、释放等)才能“触及”的内存, 不同于程序控制下的“常规”内存块——比如本章前文展示的通过定位 new 实现内存映射的示例所用的内存。 这类内存包括持久性(非易失性)内存或共享内存,但任何非同寻常的内存类型其实都适用。
既然需要选取示例,我们将编写一个使用(虚构的)共享内存块的例子。
一个小小的善意谎言……
需要理解的是,我们描述的是一种通常会在进程间共享内存的机制,但进程间通信属于操作系统范畴。 标准 C++仅规定了同一进程内线程间共享数据的规则;因此我们将采用一个善意的简化表述:编写多线程(而非多进程)系统,利用该内存区域实现数据共享。 我们的关注点在于内存管理设施而非进程间通信,因此这不会构成问题 。
沿用本章前几节的方法,我们将编写一个可移植的代码示例来演示如何处理非常规内存,并让您将具体细节映射到所选平台的服务上。 示例代码将采用以下形式:
- 将分配一个共享内存块。 我们会让这块内存看起来具有特殊性——需要特殊的操作系统函数来创建、分配或释放它,但我们会刻意避免使用实际的操作系统函数。 这意味着如果你想在实际应用中使用本节代码,需要根据所选平台的 API 进行调整。
- 我们将手工打造一个使用这个虚构共享内存 API 的示例程序,以此展示这种场景下的用户代码形态。
- 随后,我们将演示如何利用 C++内存管理机制,编写出比手工版本更优雅、"看起来更正常"且实现相同功能的用户代码... 甚至更出色。
虚构现实主义?
接下来我们将探讨的关于 C++与非常规内存的整个章节内容应该会很有趣,而我们编写的代码也将力求在内存管理方面保持真实场景的还原度。 如前所述,由于 C++标准对多进程系统的概念基本保持沉默,我们将尝试让多线程代码看起来有点像多进程代码。 希望您——敏锐的读者——能够接受这一命题。
请注意,本节用户代码中会涉及少量底层同步操作,包括通过原子变量实现的部分。 我尽量保持代码简洁但又不失真实性,希望您能理解——尽管本书重点在于内存管理而非并发计算(当然这也是个重要主题),我不会对此进行详细解释。 如需了解更多关于原子变量等待或使用线程栅栏等概念,请随时参考您喜欢的并发编程资料。
准备好了吗? 让我们开始吧!
虚构的共享内存 API
我们将编写一个虚构但灵感来源于大多数操作系统的 API,不同的是我们会通过异常来报告错误以简化用户代码。 操作系统主要通过返回值表达的错误代码来报告错误,但这会导致用户代码更加复杂。 希望这对您来说是个可接受的折衷方案, 亲爱的读者。
与大多数操作系统一样,我们将通过某种形式的句柄或键来抽象实际资源;创建特定大小的"共享内存"段将生成一个键(整型标识符),之后访问该内存需要该键,销毁该内存同样需要。 正如这种用于进程间共享数据的设施所预期的,销毁内存不会终结其中的对象,因此用户代码需要确保在释放共享内存段之前销毁其中的对象。
我们的 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);
// ...
您可能会注意到我们正在为 shared_mem_id 使用枚举类型。 这样做的原因是枚举类型在 C++ 中是不同的类型,而不仅仅是使用 or typedef 获得的别名。 当基于参数类型重载函数时,具有不同的类型会很有用。 这是一个有用的技巧:如果我们编写两个具有相同名称的函数(一个接受 shared_mem_id 类型的参数,另一个接受 std::size_t 类型的参数),即使 shared_mem_id 的底层类型是 std::size_t,这些也将是不同的函数。
由于我们正在构建一个"共享内存"的人工实现来展示内存分配函数如何简化用户代码,我们的 API 函数实现将保持简洁,但让我们编写表现得如同使用真实共享内存的客户端代码。 我们将共享内存段定义为 shared_mem_block,它由一个字节数组和字节大小组成的数对建模。 我们将维护一个该类型的 std::vector 对象,使用该数组中的索引作为 shared_mem_id。 这意味着当 shared_mem_block 对象被销毁时,我们不会重用它在 std::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);
// note the parentheses
return shared_mem_id(std::size(shared_mems) - 1);
}
// function for internal purposes only
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 章中提到的每个对象都有其关联的生命周期,编译器会在程序中跟踪这一事实。 我们虚构的多进程示例实际上是一个单进程、多线程的示例,因此常规的 C++生命周期规则仍然适用。
如果你想将本节代码用于编写一个真正的多进程系统来运行测试,可能需要考虑在那些未显式创建 data 对象的进程中使用 C++23 的 std::start_lifetime_as(),以避免编译器基于"这些进程中的对象从未被构造"的推理进行有害优化。 在早期编译器中,一个通常有效的技巧是对非正式构造的对象调用 std::memcpy() 将其复制到自身,这实际上会开始其生命周期。
在我们“手工制作”和标准外观的实现中,都将使用一个由数据对象构成,该对象包含一个整型值和一个布尔型就绪标志:
struct data {
bool ready;
int value;
};
在单进程实现中,完成标志的更好选择是 atomic<bool> 对象,因为我们需要确保对就绪标志的写入先于对值的写入。但为了使本示例看起来像是使用进程间共享内存,我们将限制自己使用简单的布尔型 ,并通过其他方式确保这种同步。
关于同步的说明
在现代程序中,优化编译器常会对看似独立的操作进行重排序以生成更高效的代码,而处理器在代码生成后同样会进行类似优化以最大化利用其内部流水线。 并发代码有时包含编译器与处理器都不可见的依赖关系。 在我们的示例中,需要确保 ready 完成标志仅在 value 写入操作完成后才变为 true;这种顺序之所以重要,是因为写入操作在一个线程中执行,而另一个线程将通过检查 ready 标志来判断是否可以读取 value。
如果不通过某种形式的同步来强制 value-然后-ready 的写入顺序,编译器或处理器可能会重新排序这些(看似独立的)写入操作, 从而破坏我们对 ready 含义的假设.
手工编写的用户代码示例
当然,我们可以编写用户代码来使用虚构的 API,而无需借助 C++的专用内存管理功能,只需依赖如第 7 章所示的定位 new 用法。 人们可能会倾向于将定位 new 视为特殊功能——特别是通过本书才了解到这一概念时——但若持此观点,建议重新思考:定位 new 机制是几乎所有程序都在使用的基础内存管理工具,无论用户代码是否意识到这一点。
提醒一下,我们的示例程序将执行以下操作:
- 创建指定大小的共享内存段(在本例中我们会分配远超过实际需要的空间)。
- 在该内存段起始位置构造一个数据对象,显然是通过定位 new 操作实现的.
- 启动一个线程,该线程将等待 go 变量(类型为 atomic<bool>)的信号,然后获取共享内存段的访问权限,向 value 数据成员写入数据,最后仅通过 ready 数据成员发出写入完成信号。
- 启动另一个线程,该线程将获取共享内存段的访问权限,获取指向其中数据对象的指针,然后对 ready 标志进行一些(效率极低的)忙等待直到状态改变,之后将读取并使用 value 值。 完成此操作后,将通过 done 标志( 类型为 atomic<bool>)发出完成信号。.
- 我们的程序随后会从键盘读取一个按键,向线程(实际上是写入线程)发出开始工作的信号,并等待它们完成工作后释放共享内存段, 最终结束自身工作。
我们最终得到以下结果:
// ...
#include <thread>
#include <atomic>
#include <iostream>
int main() {
// we need a N-bytes shared memory block
constexpr std::size_t N = 1'000'000;
auto key = create_shared_mem(N);
// map a data object in the shared memory block
auto [p, sz] = get_shared_mem(key);
if (!p) return -1;
// start the lifetime of a non-ready data object
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)
; // 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();
// writer and reader run to completion, then complete
done.wait(false);
p_data->~data();
destroy_shared_mem(key);
}
我们实现了这样的功能:我们构建了一套管理共享内存段的基础设施,可以利用这些内存块共享数据,并能编写代码来读取和写入这些共享数据。 请注意,我们在每个线程中用 key 变量捕获键值,然后通过该键值在每个 lambda 函数中获取内存块,但直接捕获 p_data 指针并使用它也是合理的做法。
但请注意,我们并未真正管理那个内存块:我们创建了它,却只使用了开头大小为 sizeof(data) 的一小部分。 那么,如果我们想在该区域创建多个对象呢? 如果我们想编写既能创建又能销毁对象的代码,需要管理该内存块在特定时刻哪些部分被使用呢? 按照我们当前的写法,这意味着所有工作都需在用户代码中完成,这是相当繁重的任务。
牢记这一点,我们现在将采用不同的方法来解决同一个问题。
标准形式的等效用户代码
那么,C++为我们提供了什么机制来以更符合语言习惯的方式使用"非常规"内存? 一种实现方式是如下所示:
- 为"异质"内存编写一个管理器类,封装与操作系统相关的非可移植接口,并提供更接近 C++用户代码预期的服务
- 编写内存分配操作符(operator new()、operator delete() 等)的重载版本,这些操作符将此类管理器对象的引用作为额外参数
- 通过这些重载的内存分配运算符,通过委托给内存管理器对象来弥合可移植代码与非可移植代码之间的鸿沟
这样,用户代码基本上可以写成调用 new 和 delete 运算符的"常规形式"代码,只不过这些调用将使用与第 7 章中类似的扩展表示法,例如 nothrow 或放置版本的 operator new().
我们的 shared_mem_mgr 类将使用本节前面描述的虚构操作系统 API,但通常来说,人们会编写一个类来封装访问非常规内存所需的所有操作系统服务,这些内存是程序中计划使用的。
作为简化示例,主要用于展示该功能的工作原理和使用方法,相信敏锐的您能发现许多可改进和优化的空间...确实,这个管理器效率低下且内存消耗大,它维护了一个 std::vector<bool> 对象,其中每个 bool 值表示内存块中字节是否被占用,并在每次分配请求时对该容器执行简单的线性搜索(此外,它还不是线程安全的,这很糟糕!)。 我们将在第 10 章探讨一些实现质量的问题,但您完全可以先对 shared_mem_mgr 进行大幅改进。
您会注意到 shared_mem_mgr 被实现为 RAII 类型:其构造函数创建共享内存段,析构函数释放该内存段,且 shared_mem_mgr 类型被设为不可复制——这是 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:
// create shared memory block
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();
// insanely inefficient
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 与不同的内存管理算法结合使用,并根据单个程序或其部分模块的需求选择管理策略。 如果你想找点乐子 ,不妨试试这个方法。
下一步是实现以 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);
}
配备我们的管理器和这些重载后,我们可以编写测试程序,执行与上一节中"手工制作"程序相同的任务。 不过在这种情况下存在一些差异:
- 我们不需要管理共享内存段的创建和销毁。 这些任务由 shared_mem_mgr 对象处理,作为其 RAII 惯用法实现的一部分。
- 我们完全不需要管理共享内存块,因为这项任务已交由 shared_mem_mgr 对象处理。 在内存块中为对象分配位置、跟踪内存块的对象使用情况、确保能够区分已用区域和未用区域等,这些都是该类的职责范围。
- 作为推论,在"手工制作"版本中,我们在共享内存块的开头构造了一个对象,并指出如果要构造更多对象或管理共享内存段以考虑对 new 和 delete 操作符的多次调用,这将成为用户代码的负担。但在当前实现中,我们可以自由地调用 new 和 delete,因为这些内存管理对客户端代码变得透明。
对象在非典型内存中的构造部分相当简单:只需在调用 new 和 new[] 运算符时传入额外参数即可。 然而通过此类管理器管理的对象终结部分则稍显复杂:我们不能像常规操作那样对指针执行 delete p,因为这会尝试终结对象同时通过"常规"方式释放内存。 相反,我们需要手动终结对象,然后手动调用相应版本的 operator delete() 函数来完成特殊的内存清理任务。 当然,考虑到我们在第 6 章中已阐述的内容,你可以将这些任务封装到自定义的智能指针中,从而获得更简洁安全的用户代码。
最终我们得到以下示例程序:
int main() {
// we need a N-bytes shared memory block
constexpr std::size_t N = 1'000'000;
// HERE
shared_mem_mgr mgr{ N };
// start the lifetime of a non-ready data object
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();
// writer and reader run to completion, then complete
done.wait(false);
p_data->~data();
operator delete(p_data, sizeof(data), mgr);
}
这仍然不是一个简单的例子,但其内存管理方面显然比"手工制作"版本更简单,而且任务的分隔使得更容易优化内存管理方式。
终于…我们完成了。 呼! 这真是一段不寻常的旅程, 再来一次!
概述
本章探讨了多种非常规使用 C++内存管理功能的方法:将对象映射到内存映射硬件上,将基本错误处理形式与 nothrow 版本的 operator new() 集成,通过 std::exception_handler 应对内存不足的情况,以及通过"常规"分配运算符的特化和管理器对象访问非标准内存。 这让我们对 C++中的内存管理功能有了更全面的认识,并了解如何利用它们来发挥优势。
我们曾提到但尚未深入讨论的一个主题是优化:如何在满足某些条件时,使内存分配和释放变得极快,甚至快到极致,同时在执行速度上具有确定性。 这将是第 10 章要讲解的内容,届时会阐述如何编写基于内存池的分配代码。
哦,作为额外奖励,我们还会消灭兽人。
兽人?你在说什么?
兽人是奇幻作品中常见的虚构生物,通常被塑造成凶残的敌人,且与精灵族(另一种声誉通常较好的虚构生物)关系紧张。 由于本书作者过去几十年与游戏程序员合作密切,兽人经常出现在他的示例中,并将成为我们第 10 章所编写代码的核心元素。.
听起来不错? 那么,让我们进入下一章!