《C语言高性能编程》样章--结构体内存对齐

时间:2021-04-01 19:39:54

这是一门即将“失传”的艺术。

        80年代到90年代前期,计算机的内存容量一般使用KB来作为单位;90年代后期,内存容量使用MB来作为单位。到了21世纪,开始出现GB级别内存容量的计算机;到了现在,系统基本都运行在x64位操作系统下,内存更是动辄几十GB甚至上TB。这意味着使用内存节衣缩食的时代已经成为过去,所以现在几乎已经没有人还会在乎多用那么几B甚至是几KB有时是几MB的内存了。对于结构体、类的设计也不会再花时间来更精确的定义了。但使用C语言,对于立志要做一个系统级程序员的你来说,这门技艺还是需要了解的,最好是掌握之。别无他求,只为了在大规模数据运算的时候,能给每台服务器省那么一点点的内存,让使用这些服务器搭建成的集群或者分布式计算系统能节省更多的内存。

       上文提到内存对齐。这里我们主要讲讲如何合理的使用内存对齐来更小化结构体占用内存大小。


        首先,内出对齐是什么概念?


        在现代计算机中,至少在概念上,内存全部都是可以使用byte来划分的。从理论上来说,对于任何变量的访问其实都可以从任何的内存地址开始,也就是说变量的存储和内存地址是无关性的。但实际情况却由于各种原因(比如可移植性、性能等等),某些特定的变量必须在特定的内存地址上被存储和访问。这种需求就要求各种数据类型必须按照一定的既有规则在内存空间上进行排列。而不是一个一个的按照位置紧挨着排列。这就是内存对齐。我们可以通过下面的结构体示例和这个结构体的两张内存图来进行说明。 定义结构体people如下示例代码,其对应的内存结构图如下两张图:

 

   struct people {        char first_name[5];        int age;        char last_name[5];    };

     图一:没有内存对齐的内存结构图

《C语言高性能编程》样章--结构体内存对齐

      图二:内存对齐的内存结构图

《C语言高性能编程》样章--结构体内存对齐

其次,为什么要内存对齐?

由于各个硬件平台对存储空间的处理上有很大的不同,而且几乎所有的硬件平台都有一些自定义的不合标准的处理方式,所以对于某些特定类型的数据只能从某些特别规定的地址开始寻址。这样规定的好处是可以在一个统一的规则下简化数据的存储和访问。比如对于某些CPU平台来说,访问一个没有内存对齐的数据并无太大影响,但是对于某些CPU平台来说,随机的访问内存地址会发生错误。在这两种不同的平台架构下,必须要保证在遵守某一规则前提条件下访问内存,这就是内存对齐的根本原因。但内存对齐的最大原因却是为了追求更快的效率。如果数据不按照适合平台要求的数据存放方式对齐,在存取数据的效率上可能会严重的损失性能。比如有些CPU的架构平台每次读取数据都必须从偶数位置开始,同样一个int类型的数据(假设在x32位系统),如果存放在一个偶数开始的地址,那么一个CPU周期就可以完整的读取这个int数值;而如果存放的地址是一个奇数开始的地址,那可能就需要2个CPU的周期才能读取这个int数值,并且2次分别读取出来这个数值的2部分后,还需要对这2部分读取出来的数值进行高低位的运算才能得到真实的int数值。虽然也可以实现数据的存取,但是明显效率上就会下降50%都不止。


接下来,我们来讨论一下内存对齐的原则及其使用示例。 通常,C语言结构体的对齐规则有以下几个:

  1. 普通成员规则:假设第一个数据成员的位置是从内存地址的相对0开始存放的(其实真实的程序世界中是从系统对齐的某个内存开始)。然后每个数据成员挨个排列在内存中,但是对于每个数据成员来说,存储位置都要从其本身的占用内存大小的整数倍开始。比如假设在x32位机器上,int的大小是4,对于int数据的存储必须要从内存地址为4的整数倍开始存放;如果是short数据类型,因为short的大小是2,所以从2的整数倍开始就可以了。

  2. 结构体成员规则:如果结构体内包含有结构体类型成员,则被包含的结构体类型数据必须要从其内部最大内存大小元素的整数倍地址开始存储。例如如下示例:

    
    

    struct B{   char c;   int i;   double d;};struct A {   ...   struct B b;   ....};

    则,对于b来说,它在A中存放的时候应该以8的整数倍开始,因为在struct B中,内存占用最大的类型是double,其大小为8,所以按照上面的原则,其内存对齐大小应该是8的整数倍开始。

  3. 结构体本身大小规则:结构体本身大小的计算规则,也就是sizeof的计算规则。其大小也就是sizeof的结果,必须是其内部成员中最大对齐成员大小的整数倍,如果不足,在末尾进行补齐。比如如下的结构体:

    
    

    struct B{   char c;   double d;   int i;};

    则,sizeof的结果应该是24.因为c为char,对齐1;d为double,对齐大小为8,所以对于c被补齐了7个byte;i为int,大小为4,对齐为4的倍数,前面是16byte,所以不需要补齐,但是结构体总体大小因为需要是d的内存大小的整数倍,所以它要被后面补齐4。所以总大小为:1 + 7 + 8 + 4 + 4 = 24.

  4. 自定义数组类型原则:如果结构体成员是自定义的数组类型,即使用typdef定义的数组类型,则按照其本身的数据类型原则对齐。比如我们使用typedef int MyInt[3];定义了一个自定义类型MyInt,该类型其实是一个int的数组类型,长度为3.按照x64操作系统的规定,MyInt的长度应该为12.但是如果具有如下的结构体定义:

    
    

    struct S{   MyInt mi;};

    则,其对齐的方式与顺序写3个int的方式是等价的,这也意味着其对齐的长度应该是一个sizeof(int),为4;而不是使用MyInt长度为12的对齐方式。同理,如果定义类型typedef char MyChar[3],这其对齐的大小是sizeof(char),为1;而不是sizeof(MyChar),为3.

  5. 共同体union原则:如果结构体的成员是一个共同体union,则其对齐规则为该共同体union中,占用内存最大的元素的大小,存储数据为最大元素内存大小的整数倍。这条原则比较简单,比如如下的共同体:

    
    

    union u{   int i;   long long ll;   char c;   double d;};

    如果对上面的共同体执行sizeof(union u),则得到的结果是8.因为共同体的内存存储是必须要可以容纳最大的元素内存,所以共同体的大小即为最大元素的大小。根据原则1和3,即可很清楚的总结出共同体的对齐规则。

    综上所述:每个数据成员内存对齐都必须具备一个重要的条件,即每个数据成员都会按照自己的方式对齐。具体的对齐规则为,每个数据成员会根据其自身类型占用内存的大小和指定对齐参数(根据系统来决定,x64默认为8)来选择较小的那个数进行内存对齐,而对于结构来说,其长度必须为所用过的所有对齐参数的整数倍,如果出现需要对齐的内存大小大于实际占用的内存大小的情况,使用空子节进行后端补齐。

    我们讲了那么多的内存对齐,内存对齐在我们日常的工作中到底有什么作用呢?来看一下下面的代码示例:

    
    

    struct people_1{   char first_name[10];   long long age;   char last_name[10];   long long sex;};struct people_2{   long long age;   long long sex;   char first_name[10];   char last_name[10];};int main(int argc,char **argv){   printf("struct people_1 size:%d.\n",sizeof(struct people_1));   printf("struct people_2 size:%d.\n",sizeof(struct people_2));   return 0;}

    编译并运行该代码,打印结果:

    
    

    struct people_1 size:48.struct people_2 size:40。

    观察两个结构体的定义,从结构体的成员元素内容来看,这两个结构体表示的内容是一样的,只是因为其成员元素的位置不同,导致了结构体占用内存的大小不一样。所以结构体中,成员元素的位置很重要。 struct people_1相比struct people_2多使用了8byte的内存。这8byte的内存到哪里去了呢?我们看一下如下的图示:

    图一:struct people_1内存对齐结构图 

       

《C语言高性能编程》样章--结构体内存对齐

         图二:内存对齐的内存结构图 

《C语言高性能编程》样章--结构体内存对齐

分析一下为什么会出现上面这样的内存对齐方式。
struct people_1:

  1. first_name是结构体的第一个元素成员,所以位置从相对位的0开始,长度为10;

  2. 第二个成员元素是age,类型是long long,long long在系统中(x64系统)占8字节长,所以它的存放地址应该是8的整数倍,因为第一个元素first_name的长度为10,大于10且为8的整数倍的最小数为16,所以age的起始位置应该为相对位置的16,而多的那6位就需要进行补齐;

  3. 第三个元素为last_name,类型是char,sizeof(char)为1,其存放的位置是1的整数倍,也就是没有特别的讲究,哪里都可以。所以可以紧挨着第二个元素age即可,长度为10,占用10个byte内存;

  4. 第四个元素为sex,和age一样,也是long long类型,所以也必须从8的整数倍位置开始,所以也要对last_name进行补齐;

  5. 整个结构体的成员元素补齐完成后,计算一下整个结构体的大小为:10 + 6 + 8 + 10 + 6 + 8 = 48,正好是系统的对齐(x64系统对8对齐)的整数倍,不需要再进行内存对齐;


struct people_2:

  1. age为结构体的第一个成员元素,其类型为long long,但因为是第一个元素,所以位置从相对位的0开始,长度为8;

  2. 第二个元素成员为sex,和age一样,也是long long类型,长度为8.因为第一个元素age的长度为8,所以sex的存储位置从大于8的最小8的整数倍开始,那就是8了,紧挨着age排放就可以;

  3. 第三个元素为first_name,类型为char,长度为10,因为char类型可以在任何位置存储,所以其只要紧跟着上一个元素排列就可以了;

  4. 第四个元素为last_name,类型也是char,长度也为10,和first_name一样,也可以从任何位置开始存储,所以也只要紧跟着上一个元素first_name即可;

  5. 整个结构体的成员元素排列完成后,计算一下目前的结构体占用内存大小:8 + 8 + 10 + 10 = 36.因为系统为x64,需要对8对齐,而36并不是8的整数倍,所以大于36而又是8整数倍的最小数为40,所以对整个结构体进行内存补齐,最后得到结果为40;


       虽说这8byte的内存看上去不多,但如果是在一个具有100w个struct people_1内存对象的系统中呢?使用struct people_1相比使用struct people_2要多浪费掉6-7mb的内存。并且在真实的系统中,很多结构体的成员元素非常多,其成员元素也是各种类型都有。所以如果不仔细的在考虑内存对齐问题的情况下定义结构体,那么在不经意间浪费掉的内存将会更多更多。


       从上面的示例可以看到,对于开发者来说,除了熟悉内存对齐的原则外,只要简单的遵循同类型成员元素尽量放在一起即可最大限度的避免因为内存对齐的要求而大量浪费内存的现象。


       最后,我们讨论一下会影响内存对齐效果的宏:#pragma pack。
#pragma pack宏是供开发者自定义结构体内存对齐方式的一种方法。在结构体的定义之前,加上该宏,整个结构体的内存布局将会根据#pragma pack宏的定义完全改变。#pragma pack宏通过使用参数的方式来规定结构体内存对齐的基数,比如#pragma pack(2),即告诉编译器,以下定义的结构体使用内存对2对齐的方式来进行内存对齐。如果声明#pragma pack(1),则表示以下结构体对1进行内存对齐,其实就是紧挨着排列各个结构体的成员元素。我们仅对#pragma pack(2)来进行说明,对于#pragma pack中的其余各种参数问题,大家可以自己思考。#pragma pack(2)的代码示例与分析如下:

 

   #pragma pack(2)    struct people_1{        char first_name[10];        long long age;        char last_name[10];        long long sex;    };    struct people_2{        long long age;        long long sex;        char first_name[10];        char last_name[10];    };    int main(int argc,char **argv){        printf("struct people_1 size:%d.\n",sizeof(struct people_1));        printf("struct people_2 size:%d.\n",sizeof(struct people_2));        return 0;    }

编译并运行该代码,打印结果:

 

   struct people_1 size:36.    struct people_2 size:36。

同样的结构体定义,只是在结构体定义上方增加了一行#pragma pack(2)的宏定义,得到的结果却完全不一样。在#pragma pack(2)宏定义的情况下,结构体中元素成员的位置已经没有影响了。之所以出现这种情况,在本机的环境中,操作系统为x64位,所以默认的对齐参数为64位,即8个字节。而因为#pragma pack(2)宏的定义,按照对齐规则,对齐参数变成了2.在对齐参数为2的情况下,结构体不管是struct people_1还是struct people_2,都会变成了一种内存布局,如下图:


图一:struct people_1和struct people_2在#pragma pack(2)宏定义下内存对齐结构图

《C语言高性能编程》样章--结构体内存对齐

具体的分析就不累述,我们只要知道一点,不管是struct people_1还是struct people_2,成员元素的大小都是2的整数倍,所以每个成员元素存放的开头都正好是紧跟着上一个元素的末尾,而整个结构体的内存大小是36,正好也是对齐参数2的整数倍,所以并不存在最后需要对结构体整体进行补齐。 


另请大家思考:在#pragma pack(2)宏定义的情况下,如果first_name和last_name的长度不为10,而都为9,则结构体的sizeof是多少?


PS:写书真的花了很多的时间,公众号当然也受到了一定的影响,希望大家能谅解!



公众号已经开启留言功能,欢迎大家在技术上和我交流!

如果大家喜欢我的文章,请关注我的微信公众号!

《C语言高性能编程》样章--结构体内存对齐


大嘴更多的分享:

《C语言高性能编程》样章--申请内存

情怀初体验--二周写书记

为了情怀,我又想写书了

文件夹负载均衡--使用bit解决跳位问题

mysql连接池不能回避的wait timeout问题

*技术交流见闻与感想

网络编程--read函数漏判返回0导致CPU 100%

使用linux的lsof和pmap解决fd和内存泄漏

解决锁抢占问题--随机式获取抢占锁

PHP“伪司机”遇到reload的bug

为什么这样设计Chaos

Chaos-阅文ID生成器的前世今生

gdb调试线上core--试图节省资源,改小stacksize装X失败

如何更好的做一堂技术topic分享的套路