C++11中新增了alignof和alignas两个关键字,其中alignof运算符可以用于获取类型的对齐字节长度,alignas说明符可以用来改变类型的默认对齐字节长度。这两个关键字的出现解决了长期以来C++标准中无法对数据对齐进行处理的问题。
在详细介绍这两个关键字之前,我们先来看一看下面这段代码:
#include <iostream>
struct A
{
char a1;
int a2;
double a3;
};
struct B
{
short b1;
bool b2;
double b3;
};
int main()
{
std::cout << "sizeof(A::a1) + sizeof(A::a2) + sizeof(A::a3) = "
<< sizeof(A::a1) + sizeof(A::a2) + sizeof(A::a3) << std::endl;
std::cout << "sizeof(B::b1) + sizeof(B::b2) + sizeof(B::b3) = "
<< sizeof(B::b1) + sizeof(B::b2) + sizeof(B::b3) << std::endl;
std::cout << "sizeof(A) = " << sizeof(A) << std::endl;
std::cout << "sizeof(B) = " << sizeof(B) << std::endl;
}编译运行这段代码会得到以下结果:
sizeof(A::a1) + sizeof(A::a2) + sizeof(A::a3) = 13
sizeof(B::b1) + sizeof(B::b2) + sizeof(B::b3) = 11
sizeof(A) = 16
sizeof(B) = 16奇怪的事情发生了,A和B两个类的成员变量的数据长度之和分别为13字节和11字节,与它们本身的数据长度16字节不同。对比这两个类,它们在成员变量数据长度之和上明明不同,却在类整体数据长度上又相同。有经验的程序员应该一眼就能看出其中的原因。实际上,一个类型的属性除了其数据长度,还有一个重要的属性——数据对齐的字节长度。
在上面的代码中,char以1字节对齐,short以2字节对齐,int以4字节对齐,double以8字节对齐,所以它们的实际数据结构应该是这样的:
struct A
{
char a1;
char a1_pad[3];
int a2;
double a3;
};内存布局如表30-1所示。
▼表30-1
偏移量 | 元素 |
|---|---|
0x0000 | a1 |
0x0001 | a1_pad[3] |
0x0004 | a2 |
0x0008 | a3 |
struct B
{
short b1;
bool b2;
char b2_pad[5];
double b3;
};内存布局如表30-2所示。
▼表30-2
偏移量 | 元素 |
|---|---|
0x0000 | b1 |
0x0002 | b2 |
0x0003 | b2_pad[5] |
0x0008 | b3 |
通过上述示例应该能够对数据对齐有比较直观的理解了。但是为什么我们需要数据对齐呢?原因说起来很简单,就是硬件需要。首当其冲的就是CPU了,CPU对数据对齐有着迫切的需求,一个好的对齐字节长度可以让CPU运行起来更加轻松快速。反过来说,不好的对齐字节长度则会让CPU运行速度减慢,甚至抛出错误。通常来说所谓好的对齐长度和CPU访问数据总线的宽度有关系,比如CPU访问32位宽度的数据总线,就会期待数据是按照32位对齐,也就是4字节。这样CPU读取4字节的数据只需要对总线访问一次,但是如果要访问的数据并没有按照4字节对齐,那么CPU需要访问数据总线两次,运算速度自然也就减慢了。另外,对于数据对齐问题引发错误的情况(Alignment Fault),通常会发生在ARM架构的计算机上。当然除了CPU之外,还有其他硬件也需要数据对齐,比如通过DMA访问硬盘,就会要求内存必须是4K对齐的。总的来说,配合现代编译器和CPU架构,可以让程序获得令人难以置信的性能,但这种良好的性能取决于某些编程实践,其中一种编程实践是正确的数据对齐。
在C++11标准之前我们没有一个标准方法来设定数据的对齐字节长度,只能依靠一些编程技巧和各种编译器自身提供的扩展功能来达到这一目的。
首先让我们来看一看如何获得类型的对齐字节长度。在alignof运算符被引入之前,程序员常用offsetof来间接实现alignof的功能,其中一种实现方法如下:
#define ALIGNOF(type, result) \
struct type##_alignof_trick{ char c; type member; }; \
result = offsetof(type##_alignof_trick, member)
int x1 = 0;
ALIGNOF(int, x1);以上代码用宏定义了一个结构体,其中用type定义了成员变量member,然后用offsetof获取member的偏移量,从而获取指定类型的对齐字节长度。该方法运用在大部分类型上没有问题,不过还是有些例外,比如函数指针类型:
int x1 = 0;
ALIGNOF(void(*)(), x1); // 无法编译通过当然了,我们可以用typedef来解决这个问题:
int x1 = 0;
typedef void (*f)();
ALIGNOF(f, x1);实际上我们还有第二种更好的方案:
template<class T> struct alignof_trick { char c; T member; };
#define ALIGNOF(type) offsetof(alignof_trick<type>, member)
auto x1 = ALIGNOF(int);
auto x2 = ALIGNOF(void(*)());上面的代码利用模板来构造结构体,这一点显然优于用宏构造。因为它不仅可以处理函数指针类型,还能够在表达式中构造结构体,从而让ALIGNOF写在表达式当中,这也让它更接近alignof运算符的用法。
除用一些小技巧获取类型对齐字节长度之外,很多编译器还提供了一些扩展方法帮助我们获得类型的对齐字节长度,以MSVC和GCC为例,它们分别可以通过扩展关键字__alignof和__alignof__来获取数据类型的对齐字节长度:
// MSVC
auto x1 = __alignof(int);
auto x2 = __alignof(void(*)());
// GCC
auto x3 = __alignof__(int);
auto x4 = __alignof__(void(*)());相对于获取数据对齐的功能而言,设置数据对齐就没那么幸运了,在C++11之前,我们不得不依赖编译器给我们提供的扩展功能来设置数据对齐。幸好很多编译器也提供了这样的功能,还是以MSVC和GCC为例:
// MSVC
short x1;
__declspec(align(8)) short x2;
std::cout << "x1 = " << __alignof(x1) << std::endl;
std::cout << "x2 = " << __alignof(x2) << std::endl;
// GCC
short x3;
__attribute__((aligned(8))) short x4;
std::cout << "x3 = " << __alignof__(x3) << std::endl;
std::cout << "x4 = " << __alignof__(x4) << std::endl;上面的代码输出结果如下:
x1 = 2
x2 = 8
x3 = 2
x4 = 8__declspec(align(8))和__attribute__((aligned(8)))分别将x2和x4两个short类型的对齐长度从2字节扩展到8字节。
不同的编译器需要采用不同的扩展功能来控制类型的对齐字节长度,这一点对于程序员来说很不友好。所以C++标准委员在C++11标准中新增了alignof和alignas两个关键字。
alignof运算符和我们前面提到的编译器扩展关键字__alignof、__alignof__用法相同,都是获得类型的对齐字节长度,比如:
auto x1 = alignof(int);
auto x2 = alignof(void(*)());
int a = 0;
auto x3 = alignof(a); // *C++标准不支持这种用法请注意上面的第4句代码,alignof的计算对象并不是一个类型,而是一个变量。但是C++标准规定alignof必须是针对类型的。不过GCC扩展了这条规则,alignof除了能接受一个类型外还能接受一个变量,用GCC编译此段代码是可以编译通过的。阅读了第4章的读者可能会想到,我们只需要结合decltype,就能够扩展出类似这样的功能:
int a = 0;
auto x3 = alignof(decltype(a));但实际情况是,这种做法只有在类型使用默认对齐的时候才是正确的,如果用在下面的情况中会产生错误的结果:
alignas(8) int a = 0;
auto x3 = alignof(decltype(a)); // 错误的返回4,而并非设置的8使用MSVC的读者如果想获得变量的对齐,不妨使用编译器的扩展关键字__alignof:
alignas(8) int a = 0;
auto x3 = __alignof(a); // 返回8另外,我们还可以通过alignof获得类型std::max_align_t的对齐字节长度,这是一个非常重要的值。C++11定义了std::max_align_t,它是一个平凡的标准布局类型,其对齐字节长度要求至少与每个标量类型一样严格。也就是说,所有的标量类型都适应std::max_align_t的对齐字节长度。C++标准还规定,诸如new和malloc之类的分配函数返回的指针需要适合于任何对象,也就是说内存地址至少与std::max_align_t严格对齐。由于C++标准并没有定义std::max_ align_t对齐字节长度具体是什么样的,因此不同的平台会有不同的值,通常情况下是8字节和16字节。下面做一个小实验来验证一下刚刚的说法:
for (int i = 0; i < 100; i++) {
auto *p = new char();
auto addr = reinterpret_cast<std::uintptr_t>(p);
std::cout << addr % alignof(std::max_align_t) << std::endl;
delete p;
}编译运行以上代码,会发现输出的都是0,也就是说即使我们分配的是1字节的内存,内存分配器也会将指针定位到与std::max_align_t对齐的地方。如果我们有自定义内存分配器的需要,请务必考虑到这个细节。
接下来看一看alignas说明符的用法,该说明符可以接受类型或者常量表达式。特别需要注意的是,该常量表达式计算的结果必须是一个2的幂值,否则是无法通过编译的。具体用法如下(这里采用GCC编译器,因为其alignof可以查看变量的对齐字节长度):
#include <iostream>
struct X
{
char a1;
int a2;
double a3;
};
struct X1
{
alignas(16) char a1;
alignas(double) int a2;
double a3;
};
struct alignas(16) X2
{
char a1;
int a2;
double a3;
};
struct alignas(16) X3
{
alignas(8) char a1;
alignas(double) int a2;
double a3;
};
struct alignas(4) X4
{
alignas(8) char a1;
alignas(double) int a2;
double a3;
};
#define COUT_ALIGN(s) std::cout << "alignof(" #s ") = " << alignof(s) << std::endl
int main()
{
X x;
X1 x1;
X2 x2;
X3 x3;
X4 x4;
alignas(4) X3 x5;
alignas(16) X4 x6;
COUT_ALIGN(x);
COUT_ALIGN(x1);
COUT_ALIGN(x2);
COUT_ALIGN(x3);
COUT_ALIGN(x4);
COUT_ALIGN(x5);
COUT_ALIGN(x6);
COUT_ALIGN(x5.a1);
COUT_ALIGN(x6.a1);
}输出结果如下:
alignof(x) = 8
alignof(x1) = 16
alignof(x2) = 16
alignof(x3) = 16
alignof(x4) = 8
alignof(x5) = 4
alignof(x6) = 16
alignof(x5.a1) = 8
alignof(x6.a1) = 8从上面的代码可以看出,alignas的使用非常灵活,例子中它既可以用于结构体,也可以用于结构体的成员变量。如果将alignas用于结构体类型,那么该结构体整体就会以alignas声明的对齐字节长度进行对齐,比如在例子中,X的类型对齐字节长度为8字节,而X2在使用了alignas(16)之后,对齐字节长度修改为了16字节。另外,如果修改结构体成员的对齐字节长度,那么结构体本身的对齐字节长度也会发生变化,因为结构体类型的对齐字节长度总是需要大于或者等于其成员变量类型的对齐字节长度。比如X1的成员变量a1类型的对齐字节长度修改为了16字节,所有X1类型也被修改为16字节对齐。同样的规则也适用于结构体X3,X3类型的对齐字节长度被指定为16字节,虽然其成员变量a1的类型对齐字节长度被指定为8字节,但是并不能改变X3类型的对齐字节长度。X4就恰恰相反,由于X4指定的对齐字节长度为4字节,明显小于其成员变量类型需要的对齐字节长度的字节数,因此这里X4的alignas(4)会被忽略。最后要说明的是,结构体类型的对齐字节长度,并不能影响声明变量时变量的对齐字节长度,比如X5、X6。不过在变量声明时指定对齐字节长度,也不影响变量内部成员变量类型的对齐字节长度,比如x5.a1、x6.a1。上面的代码用结构体作为例子,实际上对于类也是一样的。
C++11标准除了提供了关键字alignof和alignas来支持对齐字节长度的控制以外,还提供了std::alignment_of、std::aligned_storage和std::aligned_union类模板型以及std::align函数模板来支持对于对齐字节长度的控制。下面简单地介绍一下它们的用法。
std::alignment_of和alignof的功能差不多,可以获取类型的对齐字节长度,例如:
std::cout << std::alignment_of<int>::value << std::endl; // 输出4
std::cout << std::alignment_of<int>() << std::endl; // 输出4
std::cout << std::alignment_of<double>::value << std::endl; // 输出8
std::cout << std::alignment_of<double>() << std::endl; // 输出8std::aligned_storage可以用来分配一块指定对齐字节长度和大小的内存,例如:
std::aligned_storage<128, 16>::type buffer;
std::cout << sizeof(buffer) << std::endl; // 内存大小指定为128字节
std::cout << alignof(buffer) << std::endl; // 对齐字节长度指定为16字节std::aligned_union接受一个std::size_t作为分配内存的大小,以及不定数量的类型。std::aligned_union会获取这些类型中对齐字节长度最严格的(对齐字节数最大)作为分配内存的对齐字节长度,例如:
std::aligned_union<64, double, int, char>::type buffer;
std::cout << sizeof(buffer) << std::endl; // 内存大小指定为64字节
std::cout << alignof(buffer) << std::endl; // 对齐字节长度自动选择为
// double,8字节对齐最后解释一下std::align函数模板,该函数接受一个指定大小的缓冲区空间的指针和一个对齐字节长度,返回一个该缓冲区中最近的能找到符合指定对齐字节长度的指针。通常来说,我们传入的缓冲区内存大小为预分配的缓冲区大小加上预指定对齐字节长度的字节数。下面会给出一个例子详解这个函数模板的用法,这个例子不仅说明了函数的用法,更重要的是,它证明了在CPU喜爱的对齐字节长度上做计算,CPU的工作效率会更高:
#include <iostream>
#include <memory>
#include <chrono>
static inline void *__movsb(void *d, const void *s, size_t n) {
asm volatile ("rep movsb"
: "=D" (d),
"=S" (s),
"=c" (n)
: "0" (d),
"1" (s),
"2" (n)
: "memory");
return d;
}
int main(int argc, char *argv[])
{
constexpr int align_size = 32;
constexpr int alloc_size = 10001;
constexpr int buff_size = align_size + alloc_size;
char dest[buff_size]{0};
char src[buff_size]{0};
void *dest_ori_ptr = dest;
void *src_ori_ptr = src;
size_t dest_size = sizeof(dest);
size_t src_size = sizeof(src);
char *dest_ptr = static_cast<char *>(std::align(align_size, alloc_size, dest_ori_ptr, dest_size));
char *src_ptr = static_cast<char *>(std::align(align_size, alloc_size, src_ori_ptr, src_size));
if (argc == 2 && argv[1][0] == '1') {
++dest_ptr;
++src_ptr;
}
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 10000000; i++) {
__movsb(dest_ptr, src_ptr, alloc_size - 1);
}
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> diff = end - start;
std::cout << "elapsed time = " << diff.count();
}上面的代码用汇编语言实现了一个memcpy函数以确保复制内存函数都是通过汇编指令movsb完成的。然后我们预先分配了两个10001+32字节大小的内存作为目标缓冲区和源缓冲区。此后通过std::align找到两个缓冲区中按照32字节对齐的指针,该指针指向的内存大小至少为10001字节。最后我们用自己实现的内存复制函数进行内存复制。如果运行的时候不带任何参数,则使用32字节对齐的内存进行复制,否则用1字节对齐的内存进行内存复制,复制动作重复10000000次。在Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz的机器上,两种方法的运行结果很有大差别:
./aligntest
elapsed time = 0.951485
./aligntest 1
elapsed time = 1.36937可以看到,32字节对齐的缓冲区复制时间比1字节对齐的缓冲区复制时间整整少了0.4s有余。在性能优化上来说是非常巨大的提升。
前面曾提到过内存分配器会按照std::max_align_t的对齐字节长度分配对象的内存空间。这一点在C++17标准中发生了改变,new运算符也拥有了根据对齐字节长度分配对象的能力。这个能力是通过让new运算符接受一个std::align_ val_t类型的参数来获得分配对象需要的对齐字节长度来实现的:
void* operator new(std::size_t, std::align_val_t);
void* operator new[](std::size_t, std::align_val_t);编译器会自动从类型对齐字节长度的属性中获取这个参数并且传参,不需要额外的代码介入。例如:
// test_new.cpp
#include <iostream>
union alignas(256) X
{
char a1;
int a2;
double a3;
};
int main(int argc, char *argv[])
{
X *x = new X();
std::cout << "x = " << x << std::endl;
}通过GCC编译器将其编译为C++11和C++17两个版本,可以看到输出结果的区别:
g++ -std=c++11 test_new.cpp -o cpp11
./cpp11
x = 0x1071620
g++ -std=c++17 test_new.cpp -o cpp17
./cpp17
x = 0x1d1700我们发现在使用C++11标准的情况下,new分配的对象指针(0x1071620)并没有按照X指定的对齐字节长度(256字节)对齐,而在使用C++17标准的情况下,new分配的对象指针(0x1d1700)正好为X指定的对齐字节长度。
类型的对齐字节长度是编程中极易忽略的一个属性,这是因为即使我们不关注类型的对齐字节长度,大多数情况下它也不会妨碍我们写出正确的程序。但是就如std::align的代码示例所呈现的,如果掌握了类型对齐的方法,它能让我们写出更加高效的程序,从而充分发挥硬件的最大功效。新的C++标准提供了多种优秀的方案让我们在控制类型对齐方面变得游刃有余,一个好的C++程序员应该借助新标准提供的这些特性让程序运行得更加高效。