内存对齐和Redis的sds采用紧凑排列

1,607 阅读7分钟

近来阅读Redis的sds的设计感觉很是巧妙,单从字符串种类设计在我看来就用到了:一个元素表达两种信息(Java的线程池、部分锁也用到了);不同场景区分不同类型字符串,结构体采用紧凑排列等等。

于是我看很多人都觉得Redis使用__attribute__ ((__packed__))取消了内存对齐,是为了节省内存的同时方便buf[-1]取到flags地址,从而导致CPU性能降低(基本类型被隔断,导致cpu多次读取等操作)。下面我来说一下我的理解:

为什么需要内存对齐?

内存存放数据是为了给CPU进行使用,而CPU访问内存数据时会受到地址总线宽度的限制,并且CPU从内存中获取数据时起始地址必须是地址总线宽度的倍数,也就是说CPU是将内存分为一块一块的,块的大小取决于地址总线宽度,并且CPU读取数据也是一块一块读取的,块的大小称为(memory granularity)内存读取粒度。

例如:CPU地址总线是64位(bit,8字节),当一个int(4字节)存储到地址: 0x06 (也就是说首地址:0x06,尾地址:0x10)时,CPU如何获取这个int值?

第一步:读取0x00~0x08 8个字节,然后保存后两个字节(0x06-0x08)到 int 的前两个字节。

第二步:读取0x08~0x0F 8个字节,然后保存前两个字节(0x09-0x10)到int的后两个字节。

那么从内存中读取一个int到CPU中需要从内存中读取两次,要额外花费的时钟周期来处理对齐及运算等,这样大大降低了执行的效率,导致CPU性能降低。

什么是内存对齐

由于在CPU看来内存是按块分布的,那么读取数据的起始地址并不是任意的,不想降低CPU的性能,那么就需要各种类型的数据按照一定的规则(CPU读取内存的规则)在内存空间中进行存放,而不是按照顺序紧接着进行存放(空间换时间,造成了一定的内存浪费,但换取了CPU性能)

内存对齐规则

编译器在编译过程中会进行默认的优化对齐。

每个平台的编译器都会有默认“对齐系数”(也叫对齐模数),预编译命令#pragma pack(n),n=1,2,4,8,16 来改变这一系数,其中的n 就是你要指定的“对齐系数”,一般默认为8。当#pragma pack 的n 值等于或超过所有数据成员长度的时候,这个n值的大小将不产生任何效果。

数据成员对齐规则

结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0 的地方,以后每个数据成员的对齐按照#pragma pack 指定的数值和这个数据成员自身长度中,比较小的那个进行(min(#pragma pack())),相对于结构体首地址的偏移要为min(#pragma pack())的倍数,也可以记为起始地址%N(min(#pragma pack())) = 0,起始地址为大于原起始地址并且是N的最小倍数的值,简记为OFFSET' > OFFSET,OFFSET‘ % N = 0

结构(或联合)的整体对齐规则

在数据成员完成各自对齐之后,结构(或联合)本身也要进行对齐,对齐将按照#pragma pack 指定的数值和结构(或联合)最大数据成员长度中,比较小的那个进行。结构(或联合)对齐后大小必须为min(#pragma pack()) 的倍数,也可以记为结构体大小%N(min(#pragma pack())) = 0,结构体大小为大于原结构体大小并且是N的最小倍数的值,简记为SLEN’ > SLEN,SLEN % N = 0

#include <stdio.h>
struct x1
{
    //长度4 < 8 按4对齐;起始offset=0 0%4=0;存放位置区间[0,3]
    int i;
    //长度1 < 8 按1对齐;起始offset=4 4%1=0;存放位置区间[4]
    char c1;
    //长度1 < 8 按1对齐;起始offset=5 5%1=0;存放位置区间[5]
    char c2;
    //最大数据成员长度4 < 8,结构体x1的大小为6,填充2字节,为8,8为大于6(结构体x1的大小)的并且是4的最小倍数
};

struct x2
{
    //长度1 < 8 按1对齐;起始offset=0 0%1=0;存放位置区间[0]
    char c1;
    //长度4 < 8 按4对齐;起始offset=1 1%4=1,不符合起始地址%n=0,那么所选起始地址需要大于0,并且%4=0;所以存放位置区间[4,7]
    int i;
    //长度1 < 8 按1对齐;起始offset=8 8%1=0;存放位置区间[8]
    char c2;
     //最大数据成员长度4 < 8,结构体x1的大小为9,填充3字节,为12,12大于9的并且是4的最小倍数
};

struct x3
{
     //长度1 < 8 按1对齐;起始offset=0 0%1=0;存放位置区间[0]
    char c1;
    //长度1 < 8 按1对齐;起始offset=1 1%1=0;存放位置区间[1]
    char c2;
    //长度4 < 8 按4对齐;起始offset=2 2%4=2;不符合起始地址%n=0,那么所选起始地址需要大于1,并且%4=0;所以存放位置区间[4,7]
    int i;
    //最大数据成员长度4 < 8,结构体x1的大小为6,填充2字节(填充在两个char之后),为8,8为大于6(结构体x1的大小)的并且是4的最小倍数
};

int main()
{
    printf("%d\\n", sizeof(struct x1)); // 输出8
    printf("%d\\n", sizeof(struct x2)); // 输出12
    printf("%d\\n", sizeof(struct x3)); // 输出8
    return 0;
}

更改对齐系数

代码最顶加#pragma pack(n) /* n = 1, 2, 4, 8, 16 */ ,例如:

#pragma pack(n) /* n = 1, 2, 4, 8, 16 */
struct x{
    int a;
    char b;
    short c;
    char d;
};

SDS的紧凑排列

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

根据上面的代码可以看出Redis采用了__attribute__ ((__packed__))(紧凑排列),好处在于节省内存和方便buf[-1]取到flags地址,获取类型更加方便。

为什么SDS采用紧凑排列

节省内存

例如sdshdr32默认内存对齐后大小为12,使用__attribute__ ((__packed__)) 后大小为9(64位机器情况下

兼容C语言的String函数和方便获取类型

因为兼容C语言的String函数,所以返回的指针只是buf的指针,并且不同系统的填充位数不一样,所以如果通过buf指针获取flags,需要针对不同位数的机器平台做处理,无疑是将问题复杂化。

结构体元素声明顺序合理,采用了自对齐

可以看出上述的结构体中元素的顺序是从大到小排列,这样的排列顺序下采用和不采用__attribute__ ((__packed__)) 区别只有是否有尾随填充,那么采用了紧凑排列也不会降低CPU的性能。

如果有理解错误的或写错的地方,望各位读者评论或者私信指正,不胜感激。

内存对齐部分描述来源:zhuanlan.zhihu.com/p/66441507