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