C++ 生命周期与内存对齐与小缓冲区优化

Posted by Ciel's Paperplane on September 11, 2024

图片

由于 C++ 生命周期相关的概念非常复杂且一直处于不完善持续更新的阶段,本文只是为了博客之后的话题而尽量简单且无法保证完全严谨地介绍一下相关知识。

1. 对象大小与对齐

对于任何一个类型都存在 size 和 alignment 两个属性,其中对齐是非常重要却容易被新手忽视的属性。

以常见的 64 位平台举例:

static_assert(sizeof(char) == 1);
static_assert(sizeof(int) == 4);
static_assert(sizeof(long long) == 8);
static_assert(sizeof(long double) == 16);

static_assert(alignof(char) == 1);
static_assert(alignof(int) == 4);
static_assert(alignof(long long) == 8);
static_assert(alignof(long double) == 16);

其中 long double 对应的 16 字节为最大的基础对齐,malloc 返回的指针默认要求必须为最大基础对齐。

所以对齐简单来说就是一个对象所在的地址应该是 2^n 的整数倍,比如以 8 字节对齐那么它的地址低三位就一定是 0。这样的好处在于 cpu 从内存中可以更高效地取得数据。如果对象所在位置不满足对齐,会导致效率低甚至程序崩溃。

所以一个结构体如果摆放成员变量比较乱,就会出现很多 padding 损失:

struct Test {
    char a;
    int b;
    long long c;
    long double d;
    char e;
};

本例中 Test 的对齐会与类中最大的对齐持平,即 16 字节,那它的地址低 4 位就是 0。

假设它的地址为 010000,那 char a 的对齐要求是 1,可以摆在 010000;int b 的起始地址从 010001 算起,但是它的对齐为 4,则只能到 010100;之后 long long c 的起始地址从 011000 算起,已经满足对齐 8 的要求;long double d 的起始地址从 100000 算起,依旧没问题;最后 char e 则位于 110000。

最后 Test 会占到 110001,但是由于对齐 padding 实际最后会占到 1000000。

但是尾 padding 实际上可以被重利用:

// Derived can reuse Base's tail padding.
struct Base {
    alignas(8) unsigned char buf[1]{};
};

struct Derived : Base {
    int i{};
};

static_assert(sizeof(Base)    == 8);
static_assert(sizeof(Derived) == 8);

这里 Base 的 size 为 1 而 alignment 为 8,只能多了 7 字节 padding,继承时 Derived 中的 int 重复利用了 padding 的空间。但是这也存在风险,之后空基类优化的文章会说。

注意到这里用 alignas(N) 可以将对象对齐扩展到任意 2^n,可以超过 16 字节的基础最大对齐。这带来了一个新的问题。

之前提到的 malloc 分配的指针为最大基础对齐。所以即使是为了 char 分配内存而连续调用 malloc(1),返回的地址也会相差 16 字节,这显然会造成资源浪费。

std::cout << std::malloc(1) << '\n' << std::malloc(1);
/* Output:
0x63f8053bb2b0
0x63f8053bc2e0
*/

而如果是用 alignas(N) 拓展的过对齐类型,malloc 压根不能满足这种需求。如果是一些向量计算库为了过对齐一般需要自己实现内存分配。到了 C++17 开始标准库才引入了 aligned_malloc 和重载了对齐的 ::operator new

还有就是,由于内存对齐导致指针低位一般都为 0,而这些位可以用来存放一些标志位,被广泛地用于标准库的实现。比如 std::string 需要 1 bit 来区分当前是否为小字符串,谁也不想为了 1 bit 用一个 bool 最后还因为跟其它成员变量对齐而浪费 8 字节。还有红黑树也需要 1 bit 来辨认红黑结点,不过我没见过对此的优化,大家都选择了浪费 8 字节。

2. 生命周期

生命周期是 C++ 标准用来描述相关规定的一个抽象机概念,并不代表真实执行的指令里需要有对应的存在。但它又确实非常必要,因为编译器需要逻辑自洽的标准来决定哪些操作能被允许而哪些可以视为非法,从而进行一些更好的优化。

但是大部分公认的操作,虽然在 C++20 和 C++23 才开始进入标准,但是长久以来作为 ub 也一直是被编译器支持且绝对安全的。

一个对象的生命周期大概可以认为有这五个阶段:分配存储空间,构造或初始化,使用,析构,释放存储空间。

同时,对象必然是下列四种存储期类型之一:静态存储期,线程存储期,自动存储期,动态存储期。除了动态存储期都有着差不多的格式,规则则有细微差别:

void f() {
    int a = 1;
    const b = 2;
    static c = 3;
    static thread_local d = 4;
}

自动存储期变量在声明行分配存储与构造,在离开块作用域时析构与释放存储。

静态存储器变量在第一次运行至所在行时构造,且生命一直持续到程序结束。

线程存储期变量在每个线程分别运行至所在行时构造出独属于当前线程的一份对象,且生命一直持续到线程结束。

动态存储期则为:

int* p = new int{1};
/* 等价于:
void* __p = ::operator new(sizeof(int)); // 分配存储
__try {
    ::new(__p) int{1}; // 构造
} __catch (...) {
    ::operator delete(__p); // 构造时抛出异常则构造失败,直接释放存储
    __throw;
}
int* p = static_cast<int*>(__p);
*/

delete p;
/* 等价于:
using T = int;
p->~T(); // 析构
::operator delete(p); // 释放存储
*/

所以不管是 int a = 1; 还是 int* b = new int{1}; 都是一样的逻辑,对于前者其实依旧可以手动析构并在原位构造新对象:

void f() {
    int a = 1;
    using T = int;
    a.~T();
    ::new(&a) int{2};
    std::cout << a; // 2
}

甚至对于类的赋值:

Test& operator=(const Test& other) {
    this->~Test();
    ::new(this) Test{other};
    return *this;
}

不过这种操作可能存在风险,相关知识可见:std::launder - cppreference.com

本节最后还有一个非常重要的点:隐式生存期类型,它的定义跟平凡类型非常相似,大概就是基本类型和它们的数组乃至聚合体,特别是默认构造和析构函数一定要是默认提供的。

所以在之前提到的生命周期五个阶段,至今为止并没有允许这样的类 C 行为:

int* p = static_cast<int*>(std::malloc(sizeof(int)));
*p = 1;

显然对于任何隐式生存期类型,这种行为都是公认的合法操作,但是在 C++20 之前,这确实是在标准里被认为 ub 的存在。

C++20 对此进行了一个重要的修订:某些操作可以隐式创建和启动隐式生存期类型对象的生存期,以防止未定义行为。用人话说就是(抽象机层面),隐式生存期类型的构造和析构,只要某一时刻需要,那编译器就自动帮你做。这里具体的操作包含一些内存分配函数以及 std::memcpy std::memmove 等。

3. 小缓冲区优化

所以有了前面的铺垫,我们知道了创建对象其实只是在一块内存大小与对齐合适的地址上调用构造函数而已,所以我们完全可以在栈上分配一块空间:

void f() {
    alignas(int) unsigned char buffer[sizeof(int)];
    int* p = ::new(&buffer) int{123};
}

当然要注意 buffer 的存储有效期只停留在函数块内部。

C++11 起提供了 std::aligned_storage 可以被认为是上述 unsigned char 数组的包装,不过由于复杂原因在 C++23 被弃用了。不过我还是会使用。

这样的好处自然是省去了堆内存分配的开销,并且栈区内存永远是最近最热的,所以 cache 友好,如果要去堆区大概率要新加载一页内存。标准库中有大量的工具都用上了小缓冲区优化。简而言之,类内有一块缓冲区,如果需要的内存较小就可以直接分配至此,不够的时候再转为申请堆空间。由于大部分情况下我们需要的内存都很小,所以小缓冲区优化可以 cover 住大部分情况,显著减少堆分配次数。

最后简单以 std::string 的实现举例。std::string 是最需要小缓冲区优化的例子,因为由于 C 的糟粕,空字符串实际上需要一字节 ‘\0’,而绝对没人可以忍受每次默认构造一个空字符串都要申请一块堆内存存这一字节。

各家的 std::string 实现都不尽相同,但是大体思路都一致:在没有小缓冲区优化的时候,内部有一根 CharT 指针 data_ 和两个 size_t 类型分别表示 size_capacity_,总共是 24 字节。而在小字符串时,我们可以对此做一个 union,将 24 字节的内存转为 CharT 数组,注意到其中有 23 字节都可以存储用户数据,最后一字节在存满 23 字节时可以作为 '\0' 使用。其它情况下它可以作为“剩余的空字节数”,所以在空字符串时那个字节为 23,每存一个字节它都自减,直到最后减为 0,刚好 '\0' 的值也是 0,完美兼容。

此外,我们还需要 1 bit 来判断当前是否为小字符串。注意到小字符串最后那个字节其实只用上了 5 位,因为作为“剩余的空字节数”只表示 0 - 23 即可,所以还有 3 位可以作为 flag。而普通的模型中可利用的位则更多,注意上面提到 std::malloc 返回的对齐为 16 字节,所以 data_ 低三位可以利用。capacity_ 低三位也可以利用,不过要注意一下实现细节。而 LLVM libc++ 是蛮横地规定了 std::stringmax_size2^63,所以 size_capacity_ 的最高位也可以利用。

4. 数组 new

学过 C++ 的都肯定被教导过:newdeletenew[]delete[] 分别是对应的关系,如果用 delete 配上 new[] 的话可能会发生资源泄漏,但这其实要看具体情况。我们在第二节已经看过了 newdelete 的实现细节,new[]delete[] 只是在这基础上稍微追加了一些改变。

我们可以通过重载 operator new 来观察到new[] 的实现:

void* operator new[](const size_t size) {
    std::cout << "Allocated " << size << " bytes.\n";
    void* res = std::malloc(size);
    std::cout << res << '\n';
    return res;
}

当我们有一个平凡的类型时:

struct Test {
    int i;
};

int main() {
    auto* arr = new Test[4]{1, 2, 3, 4}; // Allocated 16 bytes.
    std::cout << arr << '\n'; // 两次地址输出一致
}

我们也可以在 struct 上手动 alignas(N) 调整其对齐:

struct alignas(16) Test {
    int i;
};

int main() {
    auto* arr = new Test[4]{1, 2, 3, 4}; // Allocated 64 bytes.
    std::cout << arr << '\n'; // 两次地址输出一致
}

这两种情况下用 delete 去释放 arr 都是没有任何问题的(只是说从实现上来看没什么问题,但这依旧是标准规定的 UB 所以绝对不要这么写!),因为 new[] 的行为比 new 多了几个赋值以外行为并没有其它区别。

但当 Test 的析构函数不平凡以后,情况就不一样了:

struct Test {
    int i;
    ~Test() {}
};

int main() {
    auto* arr = new Test[4]{1, 2, 3, 4}; // Allocated 24 bytes.
    std::cout << arr << '\n'; // arr 地址在 operator new[] 中 res 后 8 字节
}

/* memory layout
04 00 00 00   00 00 00 00   01 00 00 00   02 00 00 00
03 00 00 00   04 00 00 00
*/

不平凡的析构函数就意味着需要实际被执行,否则可能会有资源泄漏等各种副作用。而如果此时用 delete 来释放它,我们只是传回去一根指针,编译器压根不可能知道当初 new[] 是构造了几个对象,也就不可能正确地执行每个对象的析构函数。

所以 new[] 对于不平凡析构类实际上是有一份开销的:我们最后拿到的 arr 并不是 std::malloc 分配的地址,而是在其之后 8 字节,这 8 字节就是正好一个 size_t 类型,用来存放当初 new[N]N。所以当我们 delete[] 时,编译器知道这是不平凡析构类且是当初 new[] 的,就能从传入指针往前偏移 8 字节拿到当初存入的 N,来正确地执行每个对象的析构,然后将这偏移后的地址传回 std::free

所以在这种情况下,如果用 delete 搭配 new[] 实际上会导致 malloc crash,因为传回 std::free 的指针压根不是当初 std::malloc 给的指针。

size_t 的 size 和 alignment 都是 8 字节,如果我们的对象是 16 字节对齐时,std::malloc 本身是保证分配的内存为 16 字节对齐的,而在此基础上要多放一个 size_t,如果直接这么返回的话对齐就只有 8 字节了,所以这里 size_t 也只能被一起对齐成 16 字节:

struct alignas(16) Test {
    int i;
    ~Test() {}
};

int main() {
    auto* arr = new Test[4]{1, 2, 3, 4}; // Allocated 80 bytes.
    std::cout << arr << '\n'; // arr 地址在 operator new[] 中 res 后 16 字节
}
/* memory layout (paddings are seen as "0d f0 ad ba" on debug mode)
0d f0 ad ba   0d f0 ad ba   04 00 00 00   00 00 00 00
01 00 00 00   0d f0 ad ba   0d f0 ad ba   0d f0 ad ba
02 00 00 00   0d f0 ad ba   0d f0 ad ba   0d f0 ad ba
03 00 00 00   0d f0 ad ba   0d f0 ad ba   0d f0 ad ba
04 00 00 00   0d f0 ad ba   0d f0 ad ba   0d f0 ad ba
*/


Ciel's Paperplane

作诗中...