内存对齐
深入了解 | 内存对齐之 alignof、alignas 、aligned_storage、align 深度剖析 - 知乎 (zhihu.com)
什么是内存对齐
计算机中内存空间是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是:在访问特定类型变量
的时候通常在特定的内存地址
访问,这就需要对这些数据在内存中存放的位置有限制,各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。
内存对齐是编译器的管辖范围。表现为:编译器为程序中的每个“数据单元”安排在适当的位置上。
为什么要内存对齐
为了解释这个问题,我们先要了解一下处理器是如何读取内存的?
我们如果把内存看做是简单的字节数组,比如在C语言中,char *就可表示一块内存。那么或许我们会认为,它的内存读取方式可以按照1byte顺序读取,如下图。
然而,尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的,这取决于数据类型和处理器的设置;它一般会以双字节,四字节,8字节,16字节甚至32字节的块
来存取内存,我们将上述这些存取单位称为内存存取粒度
.
现在我们知道,计算机的处理器是以一定大小的块来进行读取的,这作为我们的前提条件,那么为了解释为什么要内存对齐?我们不妨先看一看不对齐的情况会出现什么问题?
对齐跟数据在内存中的位置有关。如果一个变量的内存地址刚好位于它本身长度的整数倍,他就被称做自然对齐。例如一个整型变量(占4字节)的地址为0x00000016,那它就是自然对齐的。
现在假设一个整型变量(4字节)不是自然对齐的,它的起始地址落在0x00000002(图中蓝色区域),处理器想要访问它的值,按照4字节的块进行读取,从图中的0x0起读,读取4字节大小,读到0x3
这样的一次读取之后,我们并不能取到我们要访问的整型数据,紧接着处理器会继续再往下读,偏移4个字节,从0x4开始,读到0x7。
到这里,处理器才能读取到了我们需要访问的内存数据,当然这中间还存在剔除与合并的过程。
所以,在此例中,当整型变量起始地址落在0x2时(不对齐),处理器需要两次读取才能取到我们要访问的内容。
那如果是对齐的呢?
显然,如果是对齐的,对于本例,仅需读取1次,我们便可以读取到目标数据。
可见,对齐与否会影响到我们的读取效率。
同时:各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据
只能从某些特定地址
开始存取,而不是内存中任意地址都是可以读取的。比如有些架构的CPU在访问一个没有进行对齐的变量的时候会发生错误,那么在这种架构下编程必须保证字节对齐.其他平台可能没有这种情况,但是最常见的是如果不按照适合其平台要求对数据存放进行对齐,会在存取效率上带来损失。也正是由于只能在特定的地址处读取数据,所以在访问一些数据时,对于访问未对齐的内存,处理器可能需要进行多次访问;而对于对齐的内存,只需要访问一次就可以。
这就是为什么要内存对齐的原因。内存对齐不仅便于CPU快速访问,同时合理的利用字节对齐可以有效地节省存储空间。我们可以同时对比一下,不同内存存取粒度对同一任务的不同影响。设定一个相同的任务:分别从Address0 和 Address1 中从地址0读取4个字节到处理器的寄存器中。
- 先看单字节粒度情况
两图中左侧表示内存,右侧表示寄存器,中间的箭头表示读取的过程。因为是单字节存取粒度,读取内存是按照1个字节进行访问的,所以对于Address0 来讲,要想从0的位置读取4个字节,需要读取4次,对于 Address1 也是一样的。即使他不是内存对齐的也无妨。
- 再看双字节粒度情况
从Address0 读取4个字节,相比于存取粒度为1字节的处理器,存取次数变成了一半,只需读取2次。由于每个内存访问都需要固定的开销,因此最小化访问次数确实可以提高性能。同时Address0 是内存对齐的(数据的起始位置落在0的位置上),所以第一次读取地址01,第二次读取地址23即可取到目标数据。
但是,从Address1 读取时。由于该地址未均匀地落在处理器的内存访问边界上(Address1 中寄存器黑框区域是该数据的内存地址区域,起始位置为1,不是对齐的),该处理器去取数据时,要先从0地址开始读取第一个2字节块(01),剔除不想要的字节(0地址),然后从地址2开始读取下一个2字节块(23),再从地址4开始读取下一个2字节块(45),剔除不想要的字节(地址5)。这样读取3次之后将最后留下的3块数据合并放入寄存器,才能取到目标数据。
- 四字节粒度情况呢?
具有四字节粒度的处理器从Address0 读取时,可以一次读取地址0123 就从对齐的地址中提取四个字节。
然而从Address1 读取时,因为是不对齐的,读取地址0123,剔除0地址,继而读取地址4567,剔除地址5、地址6、地址7 ,这样读取2次后将留下的2块数据合并放入寄存器,取到目标数据。
从双字节粒度和四字节粒度中可见,对于没有对齐的内存,需要做更多次的读取与剔除和合并的过程。这显然是降低效率的。
同时我们还需要注意到一点:对于对齐的内存,不同的存取粒度也会影响到存取效率。粒度小则存取次数多,粒度大则浪费空间。所以在每个特定平台上的编译器都有自己的默认存取粒度。
了解了内存对齐以及原因,我们继续看一下内存对齐的原则是什么。
内存对齐规则
- 对于标准数据类型:它的地址只要是它的长度的整数倍就行了
- 对于结构体:在结构体中,编译器为结构体的每个成员按其自然边界(alignment)分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构体的地址相同。具体规则如下:
- 第一个成员在结构体变量偏移量为0 的地址处,也就是第一个成员必须从头开始。
- 以后每个成员相对于结构体首地址的 offset 都是该成员大小的整数倍,如有需要编译器会在成员之间加上填充字节。
- 结构体的总大小为 最大对齐数的整数倍(每个成员变量都有自己的对齐数),如有需要编译器会在最末一个成员之后加上填充字节。
- 如果嵌套结构体,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(包含嵌套结构体的对齐数)的整数倍。
举个栗子:
struct test_t {
int a;
long b;
short c;
};
- 第一个成员为int类型,占4字节,内存分布 00 01 02 03 ,用红色表示;
- 第二个成员为long类型,占8字节,此时内存的的偏移量04不是8的整数倍,所以要填充字节(绿色表示填充字节),到0x7的位置,然后将long类型的数据b写入内存(黄色);
- 第三个成员是short类型,占2个字节,此时内存的偏移量16是short类型所占字节数的整数倍,所以直接写入内存(用蓝色表示)
至此,结构体内的数据数据成员已对齐,但是当前结构体的总大小为18,不满足规则3,所以需在最末的成员后面填充6个字节,使其总大小为24。
如果将结构体中的short类型与long类型换一下,
struct test_t {
int a;
short b;
long c;
};
结果为16。可见,对于成员相同的结构体,如果改变成员的顺序,对于结构体所占空间的大小是会产生影响的,所以,我们不但要了解内存对齐,还是正确的利用内存对齐。
对于结构体嵌套结构体,在规则4中已经给出,这里的图就不再画了,聪明的你看到这里一定可以得到正确的答案。
- 各成员变量存放的起始地址, 相对于结构的起始地址的偏移量 ,必须为该变量的类型所占用的字节数的倍数;
- 各成员变量在存放的时候根据在结构中出现的顺序依次申请空间, 同时按照上面的对齐方式调整位置, 空缺的字节自动填充
- 同时为了确保结构的大小为结构的字节边界数(即该结构中占用最大的空间的类型的字节数)的倍数,所以在为最后一个成员变量申请空间后 还会根据需要自动填充空缺的字节
C++的内存对齐api
alignof
alignof
是 C++11 引入的一个关键字,用来获取类型的对齐要求(以字节为单位)。在内存中,不同类型的数据有不同的对齐方式,alignof
可以帮助你知道某个类型需要怎样的对齐。
#include <iostream>
#include <cstddef> // for alignof
struct MyStruct {
char c;
int i;
double d;
};
int main() {
std::cout << "Alignment of char: " << alignof(char) << " bytes\n";
std::cout << "Alignment of int: " << alignof(int) << " bytes\n";
std::cout << "Alignment of double: " << alignof(double) << " bytes\n";
std::cout << "Alignment of MyStruct: " << alignof(MyStruct) << " bytes\n";
return 0;
}
输出:
Alignment of char: 1 bytes
Alignment of int: 4 bytes
Alignment of double: 8 bytes
Alignment of MyStruct: 8 bytes
alignas
alignas
是 C++11 引入的关键字,用于指定变量或类型的对齐方式。与 alignof
不同,alignas
是用来设定对齐,而不是查询对齐。可以用来确保某个变量、结构体或类按照指定的字节对齐方式存储,这在性能优化或硬件交互时非常有用。
#include <iostream>
struct alignas(16) MyStruct {
char c;
int i;
double d;
};
int main() {
MyStruct s;
std::cout << "Alignment of MyStruct: " << alignof(MyStruct) << " bytes\n";
std::cout << "Address of s: " << &s << '\n';
return 0;
}
输出:
Alignment of MyStruct: 16 bytes
Address of s: 0x... (地址将是16的倍数)
说完alignas
的基础用法,下面说下使用alignas
时的注意事项,即alignas(alignment)
中的alignment
也不是随意写的,对于类型T
,需要满足如下两个条件。
alignment
>=alignof(T)
;alignment
==pow(2, N)
;
std::aligned_storage
std::aligned_storage
是一个模板类,定义于 C++11 中,用于创建满足特定大小和对齐要求的未初始化内存块。它主要用于需要手动控制内存布局的场景,特别是在一些低级别的内存管理或者与硬件相关的程序中。
// in <type_traits>
template< std::size_t Len,
std::size_t Align = /*default-alignment*/ >
struct aligned_storage;
std::aligned_storage
提供了一种方便的方式来分配对齐内存,而无需自己管理复杂的对齐逻辑。它常常被用于创建未初始化的对象存储空间,稍后可以使用 placement new
在该内存上构造对象。
template<typename T, size_t N>
class StaticVector {
public:
StaticVector() {
std::cout << alignof(T) << "/" << sizeof(T)<< std::endl;
for (int idx = 0; idx < N; ++idx) {
std::cout << &data[idx] << std::endl;
}
}
~StaticVector() {
for(size_t pos = 0; pos < m_size; ++pos) {
reinterpret_cast<T*>(data+pos)->~T();
}
}
template<typename ...Args>
void emplace_back(Args&&... args) {
if(m_size >= N) {
throw std::bad_alloc{};
}
new(data+m_size) T(std::forward<Args>(args)...);
++m_size;
}
const T& operator[](size_t pos) const {
return *reinterpret_cast<const T*>(data+pos);
}
private:
// std::aligned_storage<sizeof(T), alignof(T)>::type data[N]; // C++11
std::aligned_storage_t<sizeof(T), alignof(T)> data[N]; // c++14
size_t m_size = 0;
};
类StaticVector
的使用如下:
struct alignas(32) Foo {
char c;
int i1;
int i2;
long l;
};
int main(int argc, char const *argv[]) {
StaticVector<std::string, 2> v1;
v1.emplace_back(5, '*');
std::cout << v1[0] << '\n';
StaticVector<Foo, 2>v2;
}
std::align
类std::aligned_storage
是一个静态的内存对齐分配器,即在类std::aligned_storage
对象构造完时,就已满足设定内存大小、内存对齐要求,但是如果现在有一块内存,想从中取出一块符合某对齐要求的内存,咋办?
此时就可以使用std::align
函数,其函数原型如下:
/// @param alignment 是想要分配的内存符合的内存对齐大小
/// @param size 想要分配内存的大小
/// @param ptr 是个输入输出参数,输入时指向待使用的内存,输出时调整为符合alignment对齐要求的内存地址
/// @param space 是ptr指向的内存剩余的空间
/// @return 如果 ptr 经过调整后能满足大小为 alignment 的对齐要求,则返回ptr的值,否则返回 nullptr
void* align( std::size_t alignment,
std::size_t size,
void*& ptr,
std::size_t& space);
类Arena
内已有一块缓冲区buffer
,每次调用AlignedAllocate<T>(size_t alignment)
函数时,即需要从buffer
中取出大小为sizeof(T)
的一块内存ptr
,AlignedAllocate
函数的输入参数alignment
指定了获得的内存ptr
满足的内存对齐要求。
template <size_t N>
struct Arena {
char buffer[N];
void* ptr;
size_t size;
Arena() : ptr(buffer), size(N) { }
/// @return 返回的指针满足大小为 alignment 的内存对齐要求
template <typename T>
T* AlignedAllocate(size_t alignment = alignof(T)) {
std::cout << "ptr: " << reinterpret_cast<void*>(ptr) << ", ";
if (std::align(alignment, sizeof(T), ptr, size)) {
T* result = reinterpret_cast<T*>(ptr);
ptr = (char*)ptr + sizeof(T);
size -= sizeof(T);
return result;
}
// 若无,则返回 nullptr
return nullptr;
}
};
下面是测试:
int main(int argc, char const *argv[]) {
Arena<64> arena;
char* p1 = arena.AlignedAllocate<char>();
if (p1) *p1 = 'a';
std::cout << "allocated a char at " << (void*)p1 << '\n';
int* p2 = arena.AlignedAllocate<int>();
if (p2) *p2 = 1;
std::cout << "allocated an int at " << (void*)p2 << '\n';
int* p3 = arena.AlignedAllocate<int>(32);
if (p3) *p3 = 2;
std::cout << "allocated an int at " << (void*)p3 << '\n';
}
从下面的输出可以看出,AlignedAllocate
函数返回的内存地址都是符合设定的内存对齐要求的。
$ g++ align.cc -o align && ./align
ptr: 0x16fc2b4b8, allocated a char at 0x16fc2b4b8 # 1 byte 内存对齐,指针无须调整
ptr: 0x16fc2b4b9, allocated an int at 0x16fc2b4bc # 4 byte 内存对齐,指针调整了 3 个字节
ptr: 0x16fc2b4c0, allocated an int at 0x16fc2b4c0 # 32 byte 内存对齐,指针无须调整
虚共享
虚共享(false sharing)是多线程编程中的一种性能问题,通常发生在多核处理器上。它指的是多个线程访问不同的变量,但这些变量共享同一个缓存行,导致不必要的缓存同步,从而影响性能。
- 缓存行:CPU 缓存是按 缓存行 的单位进行读写操作的,通常每个缓存行大小为 64 字节。这意味着即使两个线程操作的是不同的变量,只要它们在同一个缓存行中,这两个线程就会不必要地竞争访问相同的缓存行,导致缓存同步和失效(cache invalidation)。
- 虚共享问题:当两个或多个线程各自修改不同的变量时,如果这些变量位于同一个缓存行,线程每次修改变量时,都会导致缓存同步机制(比如通过MESI协议)触发,强制其他处理器上的缓存失效,即便这些线程并没有真正共享数据。这种情况就叫做 虚共享。虽然数据看似没有实际共享,但缓存的共享行为导致了性能下降。
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
constexpr int ITERATIONS = 1'000'000;
struct SharedData {
alignas(64) int data1; // 使用 alignas(64) 来防止虚共享
alignas(64) int data2;
};
void threadFunc1(SharedData &shared) {
for (int i = 0; i < ITERATIONS; ++i) {
++shared.data1;
}
}
void threadFunc2(SharedData &shared) {
for (int i = 0; i < ITERATIONS; ++i) {
++shared.data2;
}
}
int main() {
SharedData shared = {0, 0};
auto start = std::chrono::high_resolution_clock::now();
std::thread t1(threadFunc1, std::ref(shared));
std::thread t2(threadFunc2, std::ref(shared));
t1.join();
t2.join();
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Execution time: " << duration << "ms\n";
return 0;
}
在上面的例子中,data1
和 data2
本质上是两个独立的变量,分别被两个线程操作。然而,如果这两个变量恰好在同一个缓存行中,会导致虚共享。为了解决这个问题,我们通过 alignas(64)
使 data1
和 data2
分别位于不同的缓存行中,避免虚共享带来的性能问题。