内存对齐之 alignof、alignas 、aligned_storage、align 剖析
关于内存对齐,有诸多好处,因此常常在分配内存时也会将内存对齐这一因素纳入考量。
这一节,来讲下内存对齐以及C++11中关于内存对齐引入的alignof
、alignas
、std::aligned_storage
、std::align
,其中前两个为关键字,后两个分别为类和函数。
alignment
我们知道,C++中的内置的基础类型,比如char
、int
、float
、double
,在内存布局上都是按照其 sizeof
大小进行对齐(alignment)。
什么叫对齐?
比如,sizoef(int)
值为 4,如果满足内存对齐要求,那么int类型变量a
的地址&a
对4取余的结果应该是0。
下面提供一个编译期就能检测内存对齐的宏 CHECK_ALIGN
:
1 |
下面我们来校验内置类型的内存对齐大小确实等于其sizoef(T)
值,demo如下。
1 | int main(int argc, char const *argv[]) { |
上述demo中的 CHECK_ALIGN(&i, sizeof(l));
会导致编译错误,因为int
类型变量的内存对齐大小要求是4,而long
在gcc下是8个字节,即sizoef(l)
为8,故而编译失败。
到此,我相信你应该明白何为「内存对齐」了。
alignof
C++11引入的关键字alignof
,可直接获取类型T
的内存对齐要求。alignof
的返回值类型是size_t
,用法类似于sizeof
。
下面先来看看alignof
的用法。
1 |
|
输出如下,这也是符合前文关于基础类型内存对齐的论述。
1 | $ g++ main.cc -o main && ./main |
好,到此我相信你已经对内存对齐和alignof
有了基本了解。下面我们来看看类的内存对齐。
现在有类Foo
:
1 | struct Foo { |
考虑下alignof(Foo)
和sizeof(Foo)
分别会是多少,即下面的demo会输出???
1 | int main(int argc, char const *argv[]) { |
Think Again~~~~
3
2
1
1 | $ g++ main.cc -o main && ./main |
嗯?怎么会是这个结果?
为了更好地解释这个结果,我准备借助offsetof
函数,来获取成员变量距离类起始地址的偏移量,其函数原型如下:
1 | /* Offset of member MEMBER in a struct of type TYPE. */ |
好,现在看下如下代码,并猜测下输出?
1 | int main(int argc, char const *argv[]) { |
输出如下:
1 | $ g++ main.cc -o main && ./main |
好,到此,我准备基于这个输出来解释alignof
了。
对于Foo
而言,所谓内存对齐,即Foo
中每个字段都要满足内存对齐。而内存对齐最严格(即对齐字节数最大)的字段满足了,其他的字段也就满足了。
假设现在有三个起始地址,分别是 0、1、4,我们来看看是否都能满足Foo
中所有字段的内存对齐要求。
起始地址分别0、1、4,各个字段的地址如下三列。
1 | struct Foo { |
从上面的右侧三列结果可以看出,只有起始地址为0(8的整倍数)的恰好能满足所有字段内存对齐的要求。因此,alignof(Foo)
输出为8。
alignas
上面讲述的内存对齐要求都是默认情况下的,有时候考虑到cacheline、以及向量化操作,可能会需要改变一个类的alignof
值。
怎么办?
在C++11之前,需要依赖靠编译器的扩展指令,C++11之后可以借助alignas
关键字。
比如,在C++11之前,gcc实现
alignas(alignment)
效果的方式为__attribute__((__aligned__((alignment)))
仍然以上述的Foo
为例子,不过此时你希望Foo
对象的起始地址总是32的倍数,C++11之后借助alignas
关键字,可以如下操作:
1 | struct alignas(32) Foo { |
输出如下:
1 | $ g++ main.cc -o main && ./main |
说完alignas
的基础用法,下面说下使用alignas
时的注意事项,即alignas(alignment)
中的alignment
也不是随意写的,对于类型T
,需要满足如下两个条件。
1. alignment >= alignof(T)
仍然以Foo
为例,在没有alignas
修饰时,默认的Foo的内存对齐要求alignof(Foo)
为8,现在尝试使用alignas
让Foo
的对齐要求为4,操作如下:
1 | struct alignas(4) Foo { |
此时 SHOW_SIZEOF_AND_ALIGNOF(Foo);
的输出
1 | $ g++ main.cc -o main && ./main |
可以看出,此时的alignas
是失效的,在其他编译器下也许直接编译失败。
2. alignment == pow(2, N)
即alignas
指定的大小alignment
必须是2的正数幂(N>0
),否则也是失效,在有些编译器下也许直接编译失败。
仍然以Foo
为例子,
1 | struct alignas(9) Foo { |
编译如下:
1 | $ g++ main.cc -o main && ./main |
好,到此,我想你应该大致理解了alignof
和alignas
两个关键字,更多用法可以参`cpprefernece。
std::aligned_storage
在C++11中,也引入了一个满足内存对齐要求的静态内存分配类std::aligned_storage
,其类模板原型如下:
1 | // in <type_traits> |
类 std::aligned_storage
对象构造完成时,即分配了长度为Len
个字节的内存,且该内存满足大小为 Align
的对齐要求。
下面,我们先来看看 cpprefernece 给的一个demo,来熟悉下怎么使用std::aligned_storage
。
类 StaticVector
,是一个满足内存对齐要求的静态数组,模板参数T
是元素类型,N
是数组元素个数。
1 | template<typename T, size_t N> |
类StaticVector
的使用如下:
1 | struct alignas(32) Foo { |
在输出前,我们预测下:
std:::string
的alignof
值是8,那么StaticVector
分配的两个std::string
对象地址,都应该是8的倍数Foo
的alignof
值是32,那么StaticVector
为Foo
分配的两个Foo
对象地址,都是32的倍数,
好,现在我们来看下输出:
1 | $ g++ align_stroe.cc -o as && ./as |
所以,到此,你也许理解了std::aligned_storage
中aligned
的含义,即每个对象都是经过内存对齐的。
熟悉了std::aligned_storage
的用法,现在来看看他的实现叭,毕竟没人愿意只做个调包侠(滑稽脸)。
1 | // in std namespace; |
在 std::aligned_storage
内部,是通过一个union
来实现的:
unsigned char __data[_Len];
:这一行保证了分配的内存大小是_Len
个字节struct alignas(_Align) { } __align;
:这一行保证了分配的内存是按照Align
大小进行对齐的。
其中,第二点很好理解:
1 | int main(int argc, char const *argv[]) { |
输出如下:
1 | unaligned: 1, aligned: 16 |
因此,如果只有unsigned char __data[_Len];
,无法保证内存对齐,需要struct alignas(_Align) { } __align
的辅助。
最后再提下 std::__aligned_storage_msa
的必要性:在构造类std::aligned_storage
对象时,如果没有指定类的第二个模板参数_Align
,即内存对齐大小,由std::__aligned_storage_msa
为你设置默认的内存对齐大小。
可以看出,在 std::__aligned_storage_msa
的实现中,__attribute__((__aligned__))
后面是没有参数的,此时gcc即会根据平台生成默认内存对齐大小。
1 | int main(int argc, char const *argv[]) { |
输出如下:
1 | $ g++ align_stroe.cc -o as && ./as |
这个大小就是gcc编译器默认的内存大小。
std::align
类std::aligned_storage
是一个静态的内存对齐分配器,即在类std::aligned_storage
对象构造完时,就已满足设定内存大小、内存对齐要求,但是如果现在有一块内存,想从中取出一块符合某对齐要求的内存,咋办?
此时就可以使用std::align
函数,其函数原型如下:
1 | /// @param alignment 是想要分配的内存符合的内存对齐大小 |
下面,我们继续先来看看 cpprefernece 中提供的一个demo,熟悉下怎么使用std::align
这个函数。
类Arena
内已有一块缓冲区buffer
,每次调用AlignedAllocate<T>(size_t alignment)
函数时,即需要从buffer
中取出大小为sizeof(T)
的一块内存ptr
,AlignedAllocate
函数的输入参数alignment
指定了获得的内存ptr
满足的内存对齐要求。
现在来看看实现。
1 | template <size_t N> |
下面是测试。
1 | int main(int argc, char const *argv[]) { |
从下面的输出可以看出,AlignedAllocate
函数返回的内存地址都是符合设定的内存对齐要求的。
1 | $ g++ align.cc -o align && ./align |
最后,我们再来看看std::align
函数的实现,稍微简化后如下。
1 | // in <memory> |
std::align
的实现里,最为关键的一步,即计算对齐后的地址:
1 | const auto __aligned = (__intptr - 1u + __align) & -__align; |
对于这一步,本来想写个证明啥的,还是举个例子来解释比较通俗。
按照__align
大小进行内存对齐,即可视为按__align
进制向上取整。
什么意思呢?
比如说,现在按照10进制对齐,有地址12,想让12向上调整到10的倍数,怎么做?
- 先加上一个步长:
12 + 10 - 1 = 21
- 将余数1清掉:
21 & (-10) = 20
。这一步中,-10
的本质就是保证高位不变,将低位全部变为0,取&
之后,取余就全部清理了。
现在的内存对齐,本质上也是向上取整:__intptr - 1u + __align
是为了向前一个步长,再对 -__align
取&
,来清除余数。
关于内存对齐,很多项目里都有涉及,最近在阅读RocksDB也再次遇到,于是乎就找了个契机写下了这篇博客,后续会尝试更新RocksDB。