C++ 中的内存对齐——实践篇

简介: > 本文为《C++ 中的内存对齐》系列之下篇,[上篇](https://ata.alibaba-inc.com/articles/243681)介绍内存对齐的理论基础,建议优先食用~### TL;DR- 编译器可能会在结构体中填充字节,以满足所有成员的对齐要求;- 可以通过预处理指令 `#pragma pack` 及 `alignas` 标识符自定义内存对齐;- 对于栈上及静态变量,编

TL;DR

  • 编译器可能会在结构体中填充字节,以满足所有成员的对齐要求;
  • 可以通过预处理指令 #pragma packalignas 标识符自定义内存对齐;
  • 对于栈上及静态变量,编译器保证遵循其类型的对齐要求;
  • 对于堆上构造的对象,只有在 C++17 后才能保证任何情况下动态申请的内存都满足对齐要求。

通过上一篇文章我们已经了解到,访问未对齐的内存轻则导致性能损失,重则引发 CPU 异常,甚至静默地访问错误的地址,导致数据错误。虽然近几年来有些 CPU 已经支持访问未对齐内存且不会带来性能影响,但考虑到程序的可移植性,在实践中还是应当尽量避免访问未对齐内存的行为。

所幸,C++ 的内存对齐机制已经自动为我们屏蔽了这些底层细节,在绝大部分情况下不必担心访问到未对齐的内存。然而在开发过程中,有时依然需要手动控制内存对齐的细节,或是为了追求更高的性能,抑或是源于某些外部硬件或嵌入式系统的特殊要求。因此作为程序员,有必要了解 C++ 究竟默默在背后为我们做了什么,以及在特定情况下我们能做什么来改变其默认的行为。

栈上及静态变量对齐

对齐要求

每一个完整的对象类型都有一个叫做对齐要求(alignment requirement)的属性,也称为对齐值。它是一个 size_t 类型的整数值,表示可以分配此类对象的连续地址之间的字节数。换句话说,对象的起始地址必须是其类型的对齐值的整数倍。有效的对齐值是 2 的非负整数幂。我们可以通过 _Alignofalignof 获得某个类型的对齐值。

然而编译器是如何计算对齐值的呢?这里先介绍几个概念:

  • 基本类型的自身对齐值:基本类型自身所占的空间大小,即 sizeof()
  • 结构体或类的自身对齐值:其所有成员中的最大对齐值
  • 指定对齐值:使用 #pragma pack(n) 时指定的对齐值 n (1,2,4,8,16...)
  • 有效对齐值自身对其值指定对其值中的较小者,实际对齐时取该值

结构体的对齐规则

为了让结构体(或类,下同)满足所有成员的对齐要求,编译器需要在某些成员之后插入填充(Padding)。

基于前述对齐值的概念,结构体的对齐规则如下:

  1. 结构体变量的起始地址能够被其有效对齐值整除;
  2. 每个成员相对于结构体首地址的偏移都能被有效对齐值整除,如不能则在前一个成员后填充字节;
  3. 结构体的总大小为有效对齐值的整数倍,如不能则在最后面填充字节。

例如,对于如下结构体 struct x_

struct x_
{
   
    char a;    // 1 byte
    int b;     // 4 bytes
    short c;   // 2 bytes
    char d;    // 1 byte
} bar[3];

编译器通过 padding 使得该结构体的实际内存布局如下:

struct x_
{
   
    char a;           // 1 byte
    char _pad0[3];    // padding to put 'b' on 4-byte boundary
    int b;            // 4 bytes
    short c;          // 2 bytes
    char d;           // 1 byte
    char _pad1[1];    // padding to make sizeof(x_) multiple of 4
} bar[3];

两种声明得到的 sizeof(x_) 值均为 12 bytes,同时 alignof(x_) 值均为 4 bytes。

自定义内存对齐

前文提到,我们可以使用 #pragma pack (n) 设定一个全局的对齐值上限,该功能经常被用于告诉编译器不要对结构体成员进行对齐。例如当 n = 1 时,编译器不做任何 Padding,结构体中的成员紧密地排列在一起,此时整个结构体的大小等于所有成员大小之和。当你在使用硬件内存映射接口、需要准确控制各个成员所在位置时,很可能会用到它,但这往往以牺牲访存速度作为代价。

然而,一枚硬币有两面。从另一个角度来看,自定义内存对齐有时也能起到提升性能的作用。

alignas 类型说明符是一种可移植的 C++ 标准方法,用于指定变量和自定义类型的对齐方式,可以在定义 class、struct、union 或声明变量时使用。如果遇到多个 alignas 说明符,编译器会选择最严格的那个(最大对齐值)。

struct alignas(16) Bar
{
   
    int i;      // 4 bytes
    int n;      // 4 bytes
    alignas(4) char arr[3];
    short s;    // 2 bytes
};

int main()
{
   
    std::cout << alignof(Bar) << std::endl; // output: 16
}

内存对齐可以使处理器更好地利用 cache,包括减少 cache line 访问,以及避免多核一致性问题引发的 cache miss。具体来说,在多线程程序中,一种常用的优化手段是将需要高频并发访问的数据按 cache line 大小(通常为 64 字节)对齐。一方面,对于小于 64 字节的数据可以做到只触及一个 cache line,减少访存次数;另一方面,相当于独占了整个 cache line,避免其他数据可能修改同一 cache line 导致其他核 cache miss 的开销。

当然,在更多情况下,不需要追求那样极致的性能。你可能只是想让结构的内存布局尽量紧凑一些,但又不至于干涉编译器的默认对齐行为。那么你可以在声明结构时,按照大小的递增/递减顺序排列成员,这样可以最小化需要填充的字节,提高内存利用率。

alignas 的局限

alignas 并不是万能的,我们要清楚它不能做什么。

首先,对数组使用 alignas,对齐的是数组的首地址,而不是每个数组元素。也就是说,下面这个数组并不是每个 int 都占 64 字节。

alignas(64) int array[128];

如果一定要让每个元素都对齐,可以这样实现:

struct alignas(64) S {
    int a; };
S array[10];

其次,编译器不保证拷贝后的数据依然保留原来的对齐属性。例如,memcpy 可以拷贝一个带有 alignas 的结构到任何地址。

同样,你也不能对函数参数指定对齐。当你在堆栈上按值传递具有对齐属性的数据时,其对齐方式由调用过程控制。如果数据对齐在被调用函数中很重要,可以在使用前将参数复制到正确对齐的内存中。

最后,对一个类型指定对齐属性,仅意味着其所有的静态和栈上对象按指定值进行对齐,对于堆上构造的对象则未必。因此,一般的分配器(如 mallocoperator new 等)返回的内存地址可能不满足 alignas 的要求。

堆内存的对齐

申请对齐的内存

malloc 返回的指针与原始数据类型的最大大小对齐,该值由编译器定义,通常在 32 位机器上为 8 字节,在 64 机器上为 16 字节。如果要求更大的对齐值,则需要用到特殊的内存分配函数。

C++11 提供了一个标准函数,但笔者使用 g++ 和 clang 实测均在 C++17 及以后才支持:

void *aligned_alloc(size_t alignment, size_t size);

如果你的编译器暂时还不支持,也可以考虑平台特定的方案。

在 Window 上,可以使用 MSVC 提供的:

void *_aligned_malloc(size_t size, size_t alignment);

在 Linux 上,可以使用 glibc 的:

void *memalign(size_t alignment, size_t size);

或者

int posix_memalign(void **memptr, size_t alignment, size_t size);

使用 new 构造对齐的对象

我们来看这样一段代码:

class alignas(32) Vec3d {
    
    double x, y, z;
};

int main() {
   
    std::cout << "sizeof(Vec3d) is " << sizeof(Vec3d) << '\n';
    std::cout << "alignof(Vec3d) is " << alignof(Vec3d) << '\n';

    auto Vec = Vec3d{
   };
    auto pVec = new Vec3d[10];

    if (reinterpret_cast<uintptr_t>(&Vec) % alignof(Vec3d) == 0)
        std::cout << "Vec is aligned to alignof(Vec3d)!\n";
    else
        std::cout << "Vec is not aligned to alignof(Vec3d)!\n";

    if (reinterpret_cast<uintptr_t>(pVec) % alignof(Vec3d) == 0)
        std::cout << "pVec is aligned to alignof(Vec3d)!\n";
    else
        std::cout << "pVec is not aligned to alignof(Vec3d)!\n";

    delete[] pVec;
}

在 C++11/14 下,结果为:

sizeof(Vec3d) is 32
alignof(Vec3d) is 32
Vec is aligned to alignof(Vec3d)!
pVec is not aligned to alignof(Vec3d)!

在 C++17 下,结果为:

sizeof(Vec3d) is 32
alignof(Vec3d) is 32
Vec is aligned to alignof(Vec3d)!
pVec is aligned to alignof(Vec3d)!

在两个结果中,栈上对象的地址均为 32 字节对齐,符合预期。然而,对于堆上对象,C++11/14 不保证其内存地址一定遵循类型的对齐要求。

但在 C++17 中,新标准保证了 new 出来的那个对象也是对齐的。这是如何做到的呢?

原来,C++17 增加了 operator new 的重载函数,带有一个新的 std::align_val_t 类型的入参:

void* operator new(std::size_t count, std::align_val_t al);

那么假设现在有如下两条语句,编译器如何决定选择哪个重载函数呢?

auto p = new int{
   };
auto pVec = new Vec3{
   };

如前所述,编译器默认采用 16 字节对齐(64 位机器)。在 C++17 中,我们可以通过新增的预定义宏来查看默认值:

__STDCPP_DEFAULT_NEW_ALIGNMENT__

当你申请内存的对齐要求大于此默认值时,编译器就会使用带有对齐参数的 operator new 函数,其内部实际调用的正是前文介绍的 aligned_alloc 之类的内存分配函数。

上面举的例子都是按类型的 alignas 标识符所指定的值进行对齐。事实上,你甚至还可以在调用 new 的时候动态指定对齐,只不过在释放内存时需要手动析构,然后显式调用对应的 operator delete 函数。

auto pAlignedType = new (std::align_val_t{
   32}) MyType;
pAlignedType->~MyType();
::operator delete(pAlignedType, std::align_val_t{
   32});

将一切交给编译器吧

动态内存分配并不仅限于上述手动调用 mallocnew 的情况,很多时候还存在于我们看不到的地方,例如 STL、库函数等。能否确保在所有这些情况下,内存分配都能按照我们指定的要求自动对齐呢?

答案是,只要在 C++17 下使用足够高版本的编译器(如 GCC>=7, clang>=5, MSVC>=19.12),那么所有内存对齐相关的细节都可以放心地交给编译器。

举个例子,考虑这段使用了前文定义的 Vec3d(32 字节对齐)的代码, vector 内部申请的内存也能确保是正确对齐的。

std::vector<Vec3d> vec;
vec.push_back({
   });
vec.push_back({
   });
vec.push_back({
   });
assert(reinterpret_cast<uintptr_t>(vec.data()) % alignof(Vec3d) == 0);

此外,现在你也可以直接使用 vector 来存放 SIMD 类型而不必手动分配对齐的内存:

std::vector<__m256> vec(10);
vec.push_back(_mm256_set_ps(0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f));
assert(reinterpret_cast<uintptr_t>(vec.data()) % alignof(__m256) == 0);

是时候拥抱 C++17 了!

目录
相关文章
|
24天前
|
存储 算法 C++
【C++数据结构——查找】二分查找(头歌实践教学平台习题)【合集】
二分查找的基本思想是:每次比较中间元素与目标元素的大小,如果中间元素等于目标元素,则查找成功;顺序表是线性表的一种存储方式,它用一组地址连续的存储单元依次存储线性表中的数据元素,使得逻辑上相邻的元素在物理存储位置上也相邻。第1次比较:查找范围R[0...10],比较元素R[5]:25。第1次比较:查找范围R[0...10],比较元素R[5]:25。第2次比较:查找范围R[0..4],比较元素R[2]:10。第3次比较:查找范围R[3...4],比较元素R[3]:15。,其中是顺序表中元素的个数。
127 68
【C++数据结构——查找】二分查找(头歌实践教学平台习题)【合集】
|
24天前
|
存储 C语言 C++
【C++数据结构——栈与队列】顺序栈的基本运算(头歌实践教学平台习题)【合集】
本关任务:编写一个程序实现顺序栈的基本运算。开始你的任务吧,祝你成功!​ 相关知识 初始化栈 销毁栈 判断栈是否为空 进栈 出栈 取栈顶元素 1.初始化栈 概念:初始化栈是为栈的使用做准备,包括分配内存空间(如果是动态分配)和设置栈的初始状态。栈有顺序栈和链式栈两种常见形式。对于顺序栈,通常需要定义一个数组来存储栈元素,并设置一个变量来记录栈顶位置;对于链式栈,需要定义节点结构,包含数据域和指针域,同时初始化栈顶指针。 示例(顺序栈): 以下是一个简单的顺序栈初始化示例,假设用C语言实现,栈中存储
134 77
|
24天前
|
存储 C++
【C++数据结构——树】哈夫曼树(头歌实践教学平台习题) 【合集】
【数据结构——树】哈夫曼树(头歌实践教学平台习题)【合集】目录 任务描述 相关知识 测试说明 我的通关代码: 测试结果:任务描述 本关任务:编写一个程序构建哈夫曼树和生成哈夫曼编码。 相关知识 为了完成本关任务,你需要掌握: 1.如何构建哈夫曼树, 2.如何生成哈夫曼编码。 测试说明 平台会对你编写的代码进行测试: 测试输入: 1192677541518462450242195190181174157138124123 (用户分别输入所列单词的频度) 预
57 14
【C++数据结构——树】哈夫曼树(头歌实践教学平台习题) 【合集】
|
24天前
|
存储 C++ 索引
【C++数据结构——栈与队列】环形队列的基本运算(头歌实践教学平台习题)【合集】
【数据结构——栈与队列】环形队列的基本运算(头歌实践教学平台习题)【合集】初始化队列、销毁队列、判断队列是否为空、进队列、出队列等。本关任务:编写一个程序实现环形队列的基本运算。(6)出队列序列:yzopq2*(5)依次进队列元素:opq2*(6)出队列序列:bcdef。(2)依次进队列元素:abc。(5)依次进队列元素:def。(2)依次进队列元素:xyz。开始你的任务吧,祝你成功!(4)出队一个元素a。(4)出队一个元素x。
40 13
【C++数据结构——栈与队列】环形队列的基本运算(头歌实践教学平台习题)【合集】
|
24天前
|
算法 C++
【C++数据结构——查找】二叉排序树(头歌实践教学平台习题)【合集】
【数据结构——查找】二叉排序树(头歌实践教学平台习题)【合集】 目录 任务描述 相关知识 测试说明 我的通关代码: 测试结果: 任务描述 本关任务:实现二叉排序树的基本算法。 相关知识 为了完成本关任务,你需要掌握:二叉树的创建、查找和删除算法。具体如下: (1)由关键字序列(4,9,0,1,8,6,3,5,2,7)创建一棵二叉排序树bt并以括号表示法输出。 (2)判断bt是否为一棵二叉排序树。 (3)采用递归方法查找关键字为6的结点,并输出其查找路径。 (4)分别删除bt中关键
49 11
【C++数据结构——查找】二叉排序树(头歌实践教学平台习题)【合集】
|
24天前
|
C++ 芯片
【C++面向对象——类与对象】Computer类(头歌实践教学平台习题)【合集】
声明一个简单的Computer类,含有数据成员芯片(cpu)、内存(ram)、光驱(cdrom)等等,以及两个公有成员函数run、stop。只能在类的内部访问。这是一种数据隐藏的机制,用于保护类的数据不被外部随意修改。根据提示,在右侧编辑器补充代码,平台会对你编写的代码进行测试。成员可以在派生类(继承该类的子类)中访问。成员,在类的外部不能直接访问。可以在类的外部直接访问。为了完成本关任务,你需要掌握。
64 19
|
24天前
|
存储 编译器 数据安全/隐私保护
【C++面向对象——类与对象】CPU类(头歌实践教学平台习题)【合集】
声明一个CPU类,包含等级(rank)、频率(frequency)、电压(voltage)等属性,以及两个公有成员函数run、stop。根据提示,在右侧编辑器补充代码,平台会对你编写的代码进行测试。​ 相关知识 类的声明和使用。 类的声明和对象的声明。 构造函数和析构函数的执行。 一、类的声明和使用 1.类的声明基础 在C++中,类是创建对象的蓝图。类的声明定义了类的成员,包括数据成员(变量)和成员函数(方法)。一个简单的类声明示例如下: classMyClass{ public: int
45 13
|
24天前
|
Java C++
【C++数据结构——树】二叉树的基本运算(头歌实践教学平台习题)【合集】
本关任务:编写一个程序实现二叉树的基本运算。​ 相关知识 创建二叉树 销毁二叉树 查找结点 求二叉树的高度 输出二叉树 //二叉树节点结构体定义 structTreeNode{ intval; TreeNode*left; TreeNode*right; TreeNode(intx):val(x),left(NULL),right(NULL){} }; 创建二叉树 //创建二叉树函数(简单示例,手动构建) TreeNode*create
41 12
|
24天前
|
C++
【C++数据结构——树】二叉树的性质(头歌实践教学平台习题)【合集】
本文档介绍了如何根据二叉树的括号表示串创建二叉树,并计算其结点个数、叶子结点个数、某结点的层次和二叉树的宽度。主要内容包括: 1. **定义二叉树节点结构体**:定义了包含节点值、左子节点指针和右子节点指针的结构体。 2. **实现构建二叉树的函数**:通过解析括号表示串,递归地构建二叉树的各个节点及其子树。 3. **使用示例**:展示了如何调用 `buildTree` 函数构建二叉树并进行简单验证。 4. **计算二叉树属性**: - 计算二叉树节点个数。 - 计算二叉树叶子节点个数。 - 计算某节点的层次。 - 计算二叉树的宽度。 最后,提供了测试说明及通关代
40 10
|
24天前
|
算法 C++
【C++数据结构——图】最小生成树(头歌实践教学平台习题) 【合集】
【数据结构——图】最小生成树(头歌实践教学平台习题)目录 任务描述 相关知识 测试说明 我的通关代码: 测试结果:【合集】任务描述 本关任务:编写一个程序求图的最小生成树。相关知识 为了完成本关任务,你需要掌握:1.建立邻接矩阵,2.Prim算法。建立邻接矩阵 上述带权无向图对应的二维数组,根据它建立邻接矩阵,如图1建立下列邻接矩阵。注意:INF表示无穷大,表示整数:32767 intA[MAXV][MAXV];Prim算法 普里姆(Prim)算法是一种构造性算法,从候选边中挑
40 10