内存对齐之 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。