摘要:關(guān)于結(jié)構(gòu)體內(nèi)存對(duì)齊是什么,請(qǐng)參考源碼學(xué)習(xí)內(nèi)存管理筆記。這說(shuō)明在當(dāng)前情況下,字符串結(jié)構(gòu)中的柔性數(shù)組的起始位置并不受是否加關(guān)鍵字而影響,是緊跟在結(jié)構(gòu)體后面的,所以節(jié)省內(nèi)存這個(gè)說(shuō)法并不成立。
baiyan
全部視頻:https://segmentfault.com/a/11...
今天我們正式進(jìn)入redis5源碼的學(xué)習(xí)。redis是一個(gè)由C語(yǔ)言編寫(xiě)、基于內(nèi)存、單進(jìn)程、可持久化的Key-Value型數(shù)據(jù)庫(kù),解決了磁盤(pán)存取速度慢的問(wèn)題,大幅提升了數(shù)據(jù)訪(fǎng)問(wèn)速度,所以它常常被用作緩存。
那么為什么redis會(huì)如此之快呢?讓我們首先從內(nèi)部存儲(chǔ)的數(shù)據(jù)結(jié)構(gòu)的角度,一步一步揭開(kāi)它神秘的面紗。
在redis的set、get等常用命令中,最嘗試用的就是字符串類(lèi)型。在redis中,存儲(chǔ)字符串的數(shù)據(jù)類(lèi)型,叫做簡(jiǎn)單動(dòng)態(tài)字符串(Simple Dynamic String),即SDS,它在redis中是如何實(shí)現(xiàn)的呢?
引入回顧我們之前在PHP7源碼分析中講到的zend_string結(jié)構(gòu):
struct _zend_string { zend_refcounted_h gc; /*引用計(jì)數(shù),與垃圾回收相關(guān),暫不展開(kāi)*/ zend_ulong h; /* 冗余的hash值,計(jì)算數(shù)組key的哈希值時(shí)避免重復(fù)計(jì)算*/ size_t len; /* 存長(zhǎng)度 */ char val[1]; /* 柔性數(shù)組,真正存放字符串值 */ };
之前在【PHP7源碼學(xué)習(xí)】2019-03-13 PHP字符串筆記文中提到,設(shè)計(jì)一個(gè)存儲(chǔ)字符串的結(jié)構(gòu),最重要的就是存儲(chǔ)其長(zhǎng)度和字符串本身的內(nèi)容。至于為什么存儲(chǔ)長(zhǎng)度,是為了解決二進(jìn)制安全的問(wèn)題,且能夠以常量復(fù)雜度訪(fǎng)問(wèn)到字符串的長(zhǎng)度,詳情可以到上述文章中查看。
SDS新老結(jié)構(gòu)的對(duì)比在redis3.2.x之前,SDS的存儲(chǔ)結(jié)構(gòu)如下:
struct sdshdr { int len; //存長(zhǎng)度 int free; //存字符串內(nèi)容的柔性數(shù)組的剩余空間 char buf[]; //柔性數(shù)組,真正存放字符串值 };
以“Redis”字符串為例,我們看一下它在舊版SDS結(jié)構(gòu)中是如何存儲(chǔ)的:
free字段為0,代表buf字段沒(méi)有剩余存儲(chǔ)空間
len字段為5,代表字符串長(zhǎng)度為5
buf字段存儲(chǔ)真正的字符串內(nèi)容“Redis”
存儲(chǔ)字符串內(nèi)容的柔性數(shù)組占用內(nèi)存大小為6字節(jié),其余字段所占用8個(gè)字節(jié)(4+4+6 = 14字節(jié))
在新版本redis5中,為了進(jìn)一步減少字符串存儲(chǔ)過(guò)程中的內(nèi)存占用,劃分了5種適應(yīng)不同字符串長(zhǎng)度專(zhuān)用的存儲(chǔ)結(jié)構(gòu):
struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; //低三位存儲(chǔ)類(lèi)型,高5位存儲(chǔ)字符串長(zhǎng)度,這種字符串存儲(chǔ)類(lèi)型很少使用 char buf[]; //存儲(chǔ)字符串內(nèi)容的柔性數(shù)組 }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; //字符串長(zhǎng)度 uint8_t alloc; //已分配的總空間 unsigned char flags; //標(biāo)識(shí)是哪種存儲(chǔ)類(lèi)型 char buf[]; //存儲(chǔ)字符串內(nèi)容的柔性數(shù)組 }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; //字符串長(zhǎng)度 uint16_t alloc; //已分配的總空間 unsigned char flags; //標(biāo)識(shí)是哪種存儲(chǔ)類(lèi)型 char buf[]; //存儲(chǔ)字符串內(nèi)容的柔性數(shù)組 }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; //字符串長(zhǎng)度 uint32_t alloc; //已分配的總空間 unsigned char flags; //標(biāo)識(shí)是哪種存儲(chǔ)類(lèi)型 char buf[]; //存儲(chǔ)字符串內(nèi)容的柔性數(shù)組 }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; //字符串長(zhǎng)度 uint64_t alloc; //已分配的總空間 unsigned char flags; //標(biāo)識(shí)是哪種存儲(chǔ)類(lèi)型 char buf[]; //存儲(chǔ)字符串內(nèi)容的柔性數(shù)組 };
我們可以看到,SDS的存儲(chǔ)結(jié)構(gòu)由一種變成了五種,他們之間的不同就在于存儲(chǔ)字符串長(zhǎng)度的len字段和存儲(chǔ)已分配字節(jié)數(shù)的alloc字段的類(lèi)型,分別占用了1、2、4、8字節(jié)(不考慮sdshdr5類(lèi)型),這決定了這種結(jié)構(gòu)能夠最大存儲(chǔ)多長(zhǎng)的字符串(2^8/2^16/2^32/2^64)。
我們注意,這些結(jié)構(gòu)體中都帶有__attribute__ ((__packed__))關(guān)鍵字,它告訴編譯器不進(jìn)行結(jié)構(gòu)體的內(nèi)存對(duì)齊。這個(gè)關(guān)鍵字我們下文會(huì)詳細(xì)講解。關(guān)于結(jié)構(gòu)體內(nèi)存對(duì)齊是什么,請(qǐng)參考【PHP7源碼學(xué)習(xí)】2019-03-08 PHP內(nèi)存管理2筆記。
利用gdb查看SDS的存儲(chǔ)結(jié)構(gòu)接著說(shuō)我們之前存儲(chǔ)“Redis”的例子,我們需要先對(duì)其進(jìn)行g(shù)db,觀察"Redis”字符串使用了哪種結(jié)構(gòu),gdb的步驟如下:
首先到官網(wǎng)下載源碼包,編譯
啟動(dòng)一個(gè)終端,進(jìn)入redis源碼的src目錄下,后臺(tái)啟動(dòng)一個(gè)redis-server:
./redis-server &
然后查看當(dāng)前redis的后臺(tái)進(jìn)程的pid:
ps -aux |grep redis
記錄下這個(gè)pid,然后利用gdb -p命令調(diào)試該端口(如端口號(hào)是11430):
gdb -p 11430
接著在setCommand函數(shù)處打一個(gè)斷點(diǎn),這個(gè)函數(shù)用來(lái)執(zhí)行set命令,然后使用c命令執(zhí)行到斷點(diǎn)處:
(gdb) b setCommand (gdb) c
有了redis服務(wù)端,我們還要啟動(dòng)一個(gè)redis客戶(hù)端,接下來(lái)啟動(dòng)另一個(gè)終端(同樣在src目錄下),啟動(dòng)客戶(hù)端:
./redis-cli
接著我們?cè)趓edis客戶(hù)端中執(zhí)行set命令,我們?cè)O(shè)置了一個(gè)key為Redis,值為1的key-value對(duì):
127.0.0.1:6379> set Redis 1
返回我們之前終端中的服務(wù)端,我們發(fā)現(xiàn)它停在了setCommand處:
接著一直n下去,直到setGenericCommand函數(shù),s進(jìn)去,就可以看到我們的key “Redis”了,它是一個(gè)rObj結(jié)構(gòu)(我們暫時(shí)不看),里面的ptr就指向字符串結(jié)構(gòu)的buf字段,我們強(qiáng)轉(zhuǎn)一下,能夠看到字符串內(nèi)容“Redis”。
我們知道,無(wú)論是這五種結(jié)構(gòu)中的哪一種,其前一位一定是flag字段,我們打印它的值,它的值為1。那么1是什么含義呢,它被用來(lái)標(biāo)識(shí)是這五種字符串結(jié)構(gòu)中的哪一種:
#define SDS_TYPE_5 0 #define SDS_TYPE_8 1 #define SDS_TYPE_16 2 #define SDS_TYPE_32 3 #define SDS_TYPE_64 4
它的值為1,代表是sdshdr8類(lèi)型,我們可以畫(huà)出當(dāng)前字符串的存儲(chǔ)結(jié)構(gòu)圖:
我們可以看到,它總共占用3+6 = 9字節(jié),比之前的14字節(jié)節(jié)省了5字節(jié)。通過(guò)對(duì)之前長(zhǎng)度和alloc字段的細(xì)化(由之前的int轉(zhuǎn)為int8、int16、int32、int64),這樣一來(lái),就會(huì)大大節(jié)省redis存儲(chǔ)字符串所占用的內(nèi)存空間。內(nèi)存空間是非常寶貴的,而且redis中最常用的數(shù)據(jù)類(lèi)型就是字符串類(lèi)型。雖然看起來(lái)節(jié)省的空間很少,但由于它非常常用,所以這樣做的好處是無(wú)窮大的。
關(guān)鍵字__attribute__ ((packed))的作用該關(guān)鍵字用來(lái)告知編譯器不需要進(jìn)行結(jié)構(gòu)體的內(nèi)存對(duì)齊。
為了測(cè)試__attribute__ ((packed))關(guān)鍵字在redis字符串結(jié)構(gòu)中的作用,我們寫(xiě)如下一段測(cè)試代碼:
#include "stdio.h" int main(){ struct __attribute__ ((__packed__)) sdshdr64{ long long len; long long alloc; unsigned char flags; char buf[]; }; struct sdshdr64 s; s.len = 1; s.alloc = 2; printf("sizeof sds64 is %d", sizeof(s)); return 1; }
我們定義一個(gè)結(jié)構(gòu)體,其字段和redis中的字符串結(jié)構(gòu)基本一致。如果加上__attribute__ ((__packed__)) ,應(yīng)該不是內(nèi)存對(duì)齊的。如果去掉它,就應(yīng)該是內(nèi)存對(duì)齊的,會(huì)比前一種情況更加浪費(fèi)內(nèi)存,所以會(huì)對(duì)齊會(huì)節(jié)省內(nèi)存。我們現(xiàn)在猜想的內(nèi)存結(jié)構(gòu)圖應(yīng)該如下所示:
我們首先驗(yàn)證加上__attribute__ ((__packed__)) 的情況,我們預(yù)期應(yīng)該是不對(duì)齊的,在gdb中內(nèi)存地址如下:
我們看到,buf確實(shí)是從0x171地址處開(kāi)始的,并沒(méi)有對(duì)齊。那么我們看另一種情況,去掉__attribute__ ((__packed__)),再進(jìn)行g(shù)db調(diào)試:
大家看這張圖,是不是和上一張圖一摸一樣(我真的去掉了并且重新編譯了!!!)。這說(shuō)明在當(dāng)前情況下,redis字符串結(jié)構(gòu)中的柔性數(shù)組的起始位置并不受是否加__attribute__ ((__packed__))關(guān)鍵字而影響,是緊跟在結(jié)構(gòu)體后面的,所以節(jié)省內(nèi)存這個(gè)說(shuō)法并不成立。(不一定是所有情況下柔性數(shù)組都緊跟在結(jié)構(gòu)體后面,如果把buf的類(lèi)型改為int就不是緊跟在后面,大家感興趣可以自己調(diào)試一下)。
那么,為什么這里要加上__attribute__ ((__packed__)呢?我們換個(gè)思路,既然不能節(jié)省空間,那么能不能節(jié)省時(shí)間呢?會(huì)不會(huì)操作非對(duì)齊的結(jié)構(gòu)體性能更好、效率更高,或者是寫(xiě)代碼更方便、可閱讀性強(qiáng)呢?
筆者在這里的猜想是比較方便工程中的代碼編寫(xiě),可閱讀性更強(qiáng),我的參考如下:
在sizeof運(yùn)算符中,它返回的是結(jié)構(gòu)體占用空間的大小,和是否對(duì)齊有很大關(guān)系。比如上例中的結(jié)構(gòu)體,如果不加上__attribute__ ((__packed__)),說(shuō)明需要內(nèi)存對(duì)齊,sizeof(struct s)的返回結(jié)果應(yīng)該為24(8+8+8);如果加上__attribute__ ((__packed__)),說(shuō)明不需要對(duì)齊,返回的結(jié)果應(yīng)該為17(8+8+1),我們打印一下:
結(jié)果和我們預(yù)期的一致。我們知道,在之前我們gdb的時(shí)候,rObj的指針直接指向柔性數(shù)組buf的地址,即字符串內(nèi)容的起始地址。那么如何知道它的len和alloc的值呢?只需要用buf的地址ptr - sizeof(struct s)即可。在這里,如果加上__attribute__ ((__packed__)),它返回的結(jié)果是17,那么直接做減法,就可以到結(jié)構(gòu)體開(kāi)頭的位置,即可直接讀取len的值。如果不加__attribute__ ((__packed__)),它返回的結(jié)果是24,做減法就會(huì)的到錯(cuò)誤的位置,這就是原因所在,在源碼中我們也可以看到,它確實(shí)是這么找到當(dāng)前字符串結(jié)構(gòu)體的頭部的:
#define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T))); #define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))
那么我們可能會(huì)問(wèn)了,你剛才不是還用buf[-1]也能訪(fǎng)問(wèn)到嗎?或者buf[-17],應(yīng)該也能訪(fǎng)問(wèn)到len吧。這里筆者簡(jiǎn)單猜想可能是上一種寫(xiě)法,在工程的代碼實(shí)現(xiàn)中,更加易讀也更加方便。更加深層的原因仍待討論。
為什么需要alloc字段在之前的講解中,我們一直沒(méi)有提到alloc字段的作用。我們知道,它是目前給存儲(chǔ)字符串的柔性數(shù)組總共分配了多少字節(jié)的空間。那么記錄這個(gè)字段的作用何在呢?那就是空間預(yù)分配和惰性空間釋放的設(shè)計(jì)思想了。
空間預(yù)分配:在需要對(duì) SDS 進(jìn)行空間擴(kuò)展的時(shí)候, 程序不僅會(huì)為 SDS 分配修改所必須要的空間, 還會(huì)為 SDS 分配額外的未使用空間。舉一個(gè)例子,我們將字符串“Redis”擴(kuò)展到“Redis111”,應(yīng)用程序并不僅僅分配3個(gè)字節(jié),僅僅讓它恰好滿(mǎn)足分配的長(zhǎng)度,而是會(huì)額外分配一些空間。具體如何分配,見(jiàn)下述代碼注釋。我們講其中一種分配方式,假設(shè)它會(huì)分配8字節(jié)的內(nèi)存空間。現(xiàn)在總共的內(nèi)存空間為5+8 = 13,而我們只用了前8個(gè)內(nèi)存空間,還剩下5個(gè)內(nèi)存空間未使用。那么我們?yōu)槭裁匆@樣做呢?這是因?yàn)槿绻覀冊(cè)倮^續(xù)對(duì)它進(jìn)行擴(kuò)展,如改成“Redis11111”,在擴(kuò)展 SDS 空間之前,SDS API 會(huì)先檢查未使用空間是否足夠,如果足夠的話(huà),API 就會(huì)直接使用未使用空間那么我們就不用再進(jìn)行系統(tǒng)調(diào)用申請(qǐng)一次空間了,直接把追加的“11”放到之前分配過(guò)的空間處即可。這樣一來(lái),會(huì)大大減少使用內(nèi)存分配系統(tǒng)調(diào)用的次數(shù),提高了性能與效率。空間預(yù)分配的代碼如下:
sds sdsMakeRoomFor(sds s, size_t addlen) { void *sh, *newsh; size_t avail = sdsavail(s); // 獲取當(dāng)前字符串可用剩余空間 size_t len, newlen; char type, oldtype = s[-1] & SDS_TYPE_MASK; int hdrlen; /* 如果可用空間大于追加部分的長(zhǎng)度,說(shuō)明當(dāng)前字符串還有額外的空間,足夠容納擴(kuò)容后的字符串,不用分配額外空間,直接返回 */ if (avail >= addlen) return s; len = sdslen(s); sh = (char*)s-sdsHdrSize(oldtype); newlen = (len+addlen); if (newlen < SDS_MAX_PREALLOC) //SDS_MAX_PREALLOC = 1MB,如果擴(kuò)容后的長(zhǎng)度小于1MB,直接額外分配擴(kuò)容后字符串長(zhǎng)度*2的空間 newlen *= 2; else newlen += SDS_MAX_PREALLOC; //擴(kuò)容后長(zhǎng)度大于等于1MB,額外分配擴(kuò)容后字符串+1MB的空間 ... 真正的去分配空間 ... sdssetalloc(s, newlen); return s; }
上述sdsavail函數(shù)在獲取字符串剩余可用空間的時(shí)候,就會(huì)使用到alloc字段。它記錄了分配的總空間大小,方便我們?cè)谶M(jìn)行字符串追加操作的時(shí)候,判斷是否需要額外分配空間。當(dāng)前剩余的可用空間大小為alloc - len,即已分配總空間大小alloc - 當(dāng)前使用的空間大小len
static inline size_t sdsavail(const sds s) { unsigned char flags = s[-1]; switch(flags&SDS_TYPE_MASK) { case SDS_TYPE_5: { return 0; } case SDS_TYPE_8: { SDS_HDR_VAR(8,s); return sh->alloc - sh->len; } case SDS_TYPE_16: { SDS_HDR_VAR(16,s); return sh->alloc - sh->len; } case SDS_TYPE_32: { SDS_HDR_VAR(32,s); return sh->alloc - sh->len; } case SDS_TYPE_64: { SDS_HDR_VAR(64,s); return sh->alloc - sh->len; } } return 0; }
惰性空間釋放:惰性空間釋放用于優(yōu)化 SDS 的字符串截取或縮短操作。當(dāng) SDS 的 API 需要縮短 SDS 保存的字符串時(shí),程序并不立即回收縮短后多出來(lái)的字節(jié)。這樣一來(lái),如果將來(lái)要對(duì) SDS 進(jìn)行增長(zhǎng)操作的話(huà),這些未使用空間就可能會(huì)派上用場(chǎng)。比如我們將“Redis111”縮短為“Redis”,然后又改成“Redis111”,這樣,如果我們立刻回收縮短后多出來(lái)的字節(jié),然后再重新分配內(nèi)存空間,是非常浪費(fèi)時(shí)間的。如果等待一段時(shí)間之后再回收,可以很好地避免了縮短字符串時(shí)所需的內(nèi)存重分配操作, 并為將來(lái)可能有的增長(zhǎng)操作提供了擴(kuò)展空間。源碼中一個(gè)清空字符串的SDS API如下:
/* Modify an sds string in-place to make it empty (zero length). * However all the existing buffer is not discarded but set as free space * so that next append operations will not require allocations up to the * number of bytes previously available. */ void sdsclear(sds s) { sdssetlen(s, 0); s[0] = "