近来阅读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