Memcached記憶體管理原始碼分析

welchang發表於2021-09-09

版本

1.4.20

1 模型分析

memcached記憶體管理的模型與作業本“畫格子給我們往格子裡面寫字”的邏輯很像,一個個作業本就是我們的記憶體空間,而我們往裡寫的字就是我們要存下來的資料,所以分析的時候可以想像一下用方格作業本寫字的情景

1.1 重要的概念

1.1.1 slab、chunk

slab是一塊記憶體空間,預設大小為1M,而memcached會把一個slab分割成一個個chunk
比如說1M的slab分成兩個0.5M的chunk,所以說slabchunk其實都是代表實質的記憶體空間,chunk只是把slab分割後的更小的單元而已。
slab就相當於作業本中的“頁”,而chunk則是把一頁畫成一個個格子中的“格”

1.1.2 item

item是我們要儲存的資料,例如


圖片描述


代表我們把一個key,value鍵值對儲存在記憶體中0秒,那麼上述中的”key”, “value”, 0這些資料實質都是我們要Memcached儲存下來的資料, Memcached會把這些資料打包成一個item,這個item其實是Memcached中的一個結構體(當然結構遠不止上面提到的三個欄位這麼簡單),把打包好的item儲存起來,完成工作。而item儲存在哪裡?其實就是上面提到的”chunk”,一個item儲存在一個chunk中。

chunk是實質的記憶體空間,item是要儲存的東西,所以關係是:item是往chunk中塞的。

item就是相當於我們要寫的“字”,把它寫到作業本某一“頁(slab)”中的“格子(chunk)”裡。

1.1.3 slabclass

我們要把這個1M的slab割成多少個chunk?就是一頁紙,要畫多少個格子?

我們往chunk中塞item的時候,item總不可能會與chunk的大小完全匹配吧,chunk太小塞不下或者chunk太大浪費了怎麼辦?就是我們寫字的時候,格子太小,字出界了,或者我們的字很小寫在一個大格子裡面好浪費。

所以Memcached的設計是,我們會準備“幾種slab”,而不同一種的slab分割的chunk的大小不一樣,也就是說根據“slab分割的chunk的大小不一樣”來分成“不同的種類的slab”,而 slabclass就是“slab的種類”

假設我們現在有很多張A4紙,有些我們畫成100個格子,有些我們畫成200個格子,有些300…。
我們把畫了相同個格子(也相同大小)的紙釘在一起,成為一本本“作業本”,每本“作業本”的格子大小都是一樣的,不同的“作業本”也代表著“畫了不同的大小格子的A4紙的集合”,而這個作業本就是slabclass啦!

所以當你要寫字(item)的時候,你估一下你的字有多“大”,然後挑一本作業本(slabclass),在某一頁(slab)空白的格子(chunk)上寫。

每個slabclass在memcached中都表現為一個結構體,裡面會有個指標,指向它的那一堆slab。

2)對上面的概念有了個感性的認識了,我們來解剖memcached中比較重要的結構體和變數:

a)slabclass_t(即上面說到的slabclass型別)

typedef struct {
    //chunk的大小 或者說item的大小
    unsigned int size;  

    //每個slab有多少個item,slab又稱“頁”
    unsigned int perslab;    /**
    當前slabclass的空閒item連結串列,也是可用item連結串列,當前slabclass一切可以用的記憶體空間都在此,
    這裡是記憶體分配的入口,分配記憶體的時候都是在這個連結串列上擠一個出去。
    ps:memcached的新版本才開始把slots作為“所有空閒的item連結”的用途,以前的版本slots連結串列儲存的是“回收的item”的意思,
    而舊版本新分配的slab,是用end_page_ptr指標及end_page_free來控制,此版本已不用。
    */
    void *slots;    //當前slabclass還剩多少空閒的item,即上面的slots數
    unsigned int sl_curr; 
    
    //這個slabclass分配了多少個slab了
    unsigned int slabs;  
    /**
    下面slab_list和lisa_size的解析:
    slab_list是這個slabclass下的slabs列表,邏輯上是一個陣列,每個元素是一個slab指標。
    list_size是slab_list的元素個數。
    注意這個list_size和上面的slabs的不同:
        由於slab_list是一個空間大小固定的陣列,是陣列!而list_size是這個陣列元素的個數,代表slab_list的空間大小。
        slabs代表已經分配出去的slabs數,list_size則代表可以有多少個slabs數
        所以當slabs等於list_size的時候代表這個slab_list已經滿了,得增大空間。
    */
    void **slab_list;    unsigned int list_size;    unsigned int killing; /* index+1 of dying slab, or zero if none */
    size_t requested; /* The number of requested bytes */} slabclass_t;

再重點解析一個欄位:slot

回想我們的作業本,寫字的時候只需要知道作業本中的下一個空白格子在哪裡然後寫上去即可,因為用作業本寫字是有規律的,總是從第一頁第一行左邊開始往右寫,所以已經用的格子總是連續的。

舊版本的memcached也是用這種思路,每個slabclass儲存著一個指向下一個空白chunk的指標的變數(end_page_ptr),但memcached記憶體管理和寫作業很不一樣的地方在於,memcached裡面儲存的item是會過期的,而且每個item的過期時間都很可能不一樣,也就是說作業本里面有些字會過期,過期之後相應的空格可以回收並再次利用,由於這些回收的item是不連續的,所以舊版本的memcached把每個slabclass中過期的item串成一個連結串列,而每個slabclass中的slot就是它相應的被回收的item連結串列。所以舊版本的memcached在分配記憶體空間的時候,先去slot找有沒有回收的item,沒有的話再去end_page_ptr找到下一個新的可用的空白的chunk。

新版本的memcached包括現在分析的1.4.20版本,memcached把舊版本end_page_ptr去掉,把新的可用的chunk也整合到slot中,也就是說slot的定義由“回收的item”變為“空閒可用的item”,每當我們新開闢一個slab並把它分割成一個個chunk的時候,同時馬上把這些chunk先初始化成有結構的item(item是一個結構體),只是這個item的使用者資料欄位為空,待填充狀態,稱這些item為”free的item”,並把這些free的item串成連結串列儲存在slot中。而舊的item過期了,回收了,也變成”free的item”,也同樣插入到這個slot連結串列中。所以在新版本memcached中slabclass的slot的概念是指“空閒的item連結串列”!雖然這時記憶體分配的邏輯沒有舊版本那樣像作業本的思路那麼形象,但程式碼和邏輯都變得更純粹了,每次要分配記憶體,只需要直接從slot連結串列中拿一個出去即可。

memcached在啟動的時候會例項化幾本“作業本”:

static slabclass_t slabclass[MAX_NUMBER_OF_SLAB_CLASSES];

slabclass[i]就是一本”作業本”,i理解為作業本的id。

b)item結構體

typedef struct _stritem {
    struct _stritem *next; //連結串列中下一個,這個連結串列有可能是slots連結串列,也有可能是LRU連結串列,但一個item不可能同時這兩個連結串列中,所以複用一個指標。
    struct _stritem *prev; //連結串列中上一個。
    struct _stritem *h_next;  //相同hash值中連結串列的下一個。
    rel_time_t time;   //最近訪問時間
    rel_time_t exptime;  //過期時間
    int nbytes;  //value的位元組數
    unsigned short refcount; //引用計數
    uint8_t nsuffix;  //字尾長度
    uint8_t it_flags;  //標記
    uint8_t slabs_clsid;  //item所在的slabclass的id值
    uint8_t nkey; //鍵長
    /* this odd type prevents type-punning issues when we do
     * the little shuffle to save space when not using CAS. */
    union {        uint64_t cas;        char end;
    } data[]; //資料,這個資料不僅僅包括key對應的value,還有key、CAS、字尾等等資料也存在此,所以它有4部分“拼”成:CAS(可選),KEY,字尾,VALUE。
    /* if it_flags & ITEM_CAS we have 8 bytes CAS */
    /* then null-terminated key */
    /* then " flags lengthrn" (no terminating null) */
    /* then data with terminating rn (no terminating null; it's binary!) */} item;

head變數和tail變數

使用記憶體儲存資料總會有滿的情況,滿就得淘汰,而memcached中的淘汰機制是LRU,所以每個slabclass都儲存著一個LRU佇列
head[i]和tail[i]則就是id = i 的slabclass LRU佇列的頭尾,尾部的item是最應該淘汰的項,也就是最近最少使用的項

圖片描述


3)下面結合下面的結構圖對memcached記憶體分配的模型進行解說:


  • 初始化slabclass陣列,每個元素slabclass[i]都是不同size的slabclass

  • 每開闢一個新的slab,都會根據所在的slabclass的size來分割chunk,分割完chunk之後,把chunk空間初始化成一個個free item,並插入到slot連結串列中

  • 每使用一個free item都會從slot連結串列中刪除掉並插入到LRU連結串列相應的位置

  • 每當一個used item被訪問的時候都會更新它在LRU連結串列中的位置,以保證LRU連結串列從尾到頭淘汰的權重是由高到低的

  • 會有另一個叫“item爬蟲”的執行緒慢慢地從LRU連結串列中去爬,把過期的item淘汰掉然後重新插入到slot連結串列中(但這種方式並不實時,並不會一過期就回收)

  • 當我們要進行記憶體分配時,例如一個set命令,它的一般步驟是

    • 計算出要儲存的資料的大小,然後選擇相應的slabclass進入下面處理:
      首先,從相應的slabclass LRU連結串列的尾部開始,嘗試找幾次(預設5次),看看有沒有過期的item(雖然有item爬蟲執行緒在幫忙查詢,但這裡分配的時候,程式還是會嘗試一下自己找,自己臨時充當牛爬蟲的角色),如果有就利用這個過期的item空間。
      如果沒找到過期的,則嘗試去slot連結串列中拿空閒的free item。
      如果slot連結串列中沒有空閒的free item了,嘗試申請記憶體,開闢一塊新的slab,開闢成功後,slot連結串列就又有可用的free item了。
      如果開不了新的slab那說明記憶體都已經滿了,用完了,只能淘汰,所以用LRU連結串列尾部找出一個item淘汰之,並作為free item返回

2 程式碼實現

從函式item_alloc說起,上一篇狀態機文中也提到,如果是SET命令最終會來到item_alloc函式執行記憶體分配的工作,我們看下它的程式碼:

item *item_alloc(char *key, size_t nkey, int flags, rel_time_t exptime, int nbytes) {
    item *it;
    it = do_item_alloc(key, nkey, flags, exptime, nbytes, 0); //呼叫do_item_alloc
    return it;
}/**
item分配
把這個函式弄清楚,基本就把memcached記憶體管理機制大體弄清楚了。
*/item *do_item_alloc(char *key, const size_t nkey, const int flags,                    const rel_time_t exptime, const int nbytes,                    const uint32_t cur_hv) {
    uint8_t nsuffix;
    item *it = NULL;
    char suffix[40];
    size_t ntotal = item_make_header(nkey + 1, flags, nbytes, suffix, &nsuffix); //item總大小
    if (settings.use_cas) {
        ntotal += sizeof(uint64_t); //如果有用到cas 那麼item大小還要加上unit64_t的size
    }
    unsigned int id = slabs_clsid(ntotal); //根據item大小,找到適合的slabclass
    if (id == 0)        return 0;
    mutex_lock(&cache_lock); //cache鎖
    /* do a quick check if we have any expired items in the tail.. */
    /* 準備分配新的item了,隨便快速瞄一下lru連結串列末尾有沒有過期item,有的話就用過期的空間 */
    int tries = 5;
    int tried_alloc = 0;
    item *search;
    void *hold_lock = NULL;
    rel_time_t oldest_live = settings.oldest_live;
    search = tails[id]; //這個tails是一個全域性變數,tails[xx]是id為xx的slabclass lru連結串列的尾部
    //從LRU連結串列尾部(就是最久沒使用過的item)開始往前找
    for (; tries > 0 && search != NULL; tries--, search=search->prev) {        if (search->nbytes == 0 && search->nkey == 0 && search->it_flags == 1) {            /* We are a crawler, ignore it. */
            /*
                這裡註釋意思是說我們現在是以爬蟲的身份來爬出過期的空間,
                像爬到這種異常的item,就別管了,不是爬蟲要做的事,不要就行了。
             */
            tries++;            continue;
        }        /**
        你會看到很多地方有下面這個hv,在這先簡單說下,也可先略過,其實它是對item的一個hash,得到hv值,這個hv主要有兩個
        作用:
        1)用於hash表儲存item,透過hv計算出雜湊表中的桶號
        2)用於item lock表中鎖住item,透過hv計算出應該用item lock表中哪個鎖對當前item進行加鎖
        這兩者都涉及到一個粒度問題,不可能保證每個不一樣的key的hv不會相同,所有hash方法都可能
        出現衝突。
        所以hash表中用連結串列的方式處理衝突的item,而item lock表中會多個item共享一個鎖,或者說
        多個桶共享一個鎖。
        */
        uint32_t hv = hash(ITEM_key(search), search->nkey);        /* Attempt to hash item lock the "search" item. If locked, no
         * other callers can incr the refcount
         */
        /* Don't accidentally grab ourselves, or bail if we can't quicklock */
         /**
         嘗試去鎖住當前item。
         */
        if (hv == cur_hv || (hold_lock = item_trylock(hv)) == NULL)            continue;        if (refcount_incr(&search->refcount) != 2) {
            refcount_decr(&search->refcount);            /* Old rare bug could cause a refcount leak. We haven't seen
             * it in years, but we leave this code in to prevent failures
             * just in case
            沒看懂這裡的意思.....
             */
            if (settings.tail_repair_time &&
                    search->time + settings.tail_repair_time < current_time) {
                itemstats[id].tailrepairs++;
                search->refcount = 1;
                do_item_unlink_nolock(search, hv);
            }            if (hold_lock)
                item_trylock_unlock(hold_lock);            continue;
        }        /* Expired or flushed */
        //超時了...
        if ((search->exptime != 0 && search->exptime < current_time)
            || (search->time <= oldest_live && oldest_live <= current_time)) {
            itemstats[id].reclaimed++;            if ((search->it_flags & ITEM_FETCHED) == 0) {
                itemstats[id].expired_unfetched++;
            }
            it = search; //拿下空間
            slabs_adjust_mem_requested(it->slabs_clsid, ITEM_ntotal(it), ntotal); //更新統計資料
            /**
            什麼是link,在這簡單說下,就是把item加到雜湊表和LRU連結串列的過程。詳見items::do_item_link函式 這裡把item舊的link取消掉,當前函式do_item_alloc的工作只是拿空間,而往後可知道拿到item空間後會對這塊item進行“link”工作,而這裡這塊item空間是舊的item超時然後拿來用的,所以先把它unlink掉
            */
            do_item_unlink_nolock(it, hv);            /* Initialize the item block: */
            it->slabs_clsid = 0;
        } else if ((it = slabs_alloc(ntotal, id)) == NULL) {/*如果沒有找到超時的item,則
                呼叫slabs_alloc分配空間,詳見slabs_alloc
                如果slabs_alloc分配空間失敗,即返回NULL,則往下走,下面的程式碼是
                把LRU列表最後一個給淘汰,即使item沒有過期。
                這裡一般是可用記憶體已經滿了,需要按LRU進行淘汰的時候。
            */
            tried_alloc = 1; //標記一下,表示有進入此分支,表示有嘗試過呼叫slabs_alloc去分配新的空間。
            //記下被淘汰item的資訊,像我們使用memcached經常會檢視的evicted_time就是在這裡賦值啦!
            if (settings.evict_to_free == 0) {
                itemstats[id].outofmemory++;
            } else {
                itemstats[id].evicted++;
                itemstats[id].evicted_time = current_time - search->time; //被淘汰的item距離上次使用多長時間了
                if (search->exptime != 0)
                    itemstats[id].evicted_nonzero++;                if ((search->it_flags & ITEM_FETCHED) == 0) {
                    itemstats[id].evicted_unfetched++;
                }
                it = search;
                slabs_adjust_mem_requested(it->slabs_clsid, ITEM_ntotal(it), ntotal);//更新統計資料
                do_item_unlink_nolock(it, hv); //從雜湊表和LRU連結串列中刪掉
                /* Initialize the item block: */
                it->slabs_clsid = 0;                /*
                 在這也可以先略過下面的邏輯
                 如果當前slabclass有item被淘汰掉了,說明可用記憶體都滿了,再也沒有
                 slab可分配了,
                 而如果 slab_automove=2 (預設是1),這樣會導致angry模式,
                 就是隻要分配失敗了,就馬上進行slab重分配:把別的slabclass空間犧牲
                 掉一些,馬上給現在的slabclass分配空間,而不會合理地根據淘汰統計
                 資料來分析要怎麼重分配(slab_automove = 1則會)。
                 */
                if (settings.slab_automove == 2)
                    slabs_reassign(-1, id);
            }
        }
        refcount_decr(&search->refcount);        /* If hash values were equal, we don't grab a second lock */
        if (hold_lock)
            item_trylock_unlock(hold_lock);        break;
    }    /**
    如果上面的for迴圈裡面沒有找到空間,並且沒有進入過else if ((it = slabs_alloc(ntotal, id)) == NULL)這個分支沒有 嘗試調slabs_alloc分配空間(有這種可能性),那麼,下面這行程式碼就是再嘗試分配。
    你會覺得上面那個迴圈寫得特糾結,邏輯不清,估計你也看醉了。其實整個分配原則是這樣子:
    1)先從LRU連結串列找下看看有沒有恰好過期的空間,有的話就用這個空間。
    2)如果沒有過期的空間,就分配新的空間。
    3)如果分配新的空間失敗,那麼往往是記憶體都用光了,則從LRU連結串列中把最舊的即使沒過期的item淘汰掉,空間分給新的item用。
    問題是:這個從“LRU連結串列找到的item”是一個不確定的東西,有可能這個item資料異常,有可能這個item由於與別的item共用鎖的桶號
    這個桶被鎖住了,所以總之各種原因這個item此刻不一定可用,因此用了一個迴圈嘗試找幾次(上面是5)。
    所以邏輯是:
    1)我先找5次LRU看看有沒有可用的過期的item,有就用它。(for迴圈5次)
    2)5次沒有找到可用的過期的item,那我分配新的。
    3)分配新的不成功,那我再找5次看看有沒有可用的雖然沒過期的item,淘汰它,把空間給新的item用。(for迴圈5次)
    那麼這裡有個問題,如果程式碼要寫得邏輯清晰一點,我得寫兩個for迴圈,一個是為了第2)步前“找可用的過期的”item,
    一個是第2)步不成功後“找可用的用來淘汰的”空間。而且有重複的邏輯“找到可用的”,所以memcached作者就合在一起了,
    然後只能把第2)步也塞到for迴圈裡面,確實挺尷尬的。。。估計memcached作者也寫得很糾結。。。
    所以就很有可能出現5次都沒找到可用的空間,都沒進入過elseif那個分支就被continue掉了,為了記下有沒有進過elseif
    分支就挫挫地用一個tried_alloc變數來做記號。。
    */
    if (!tried_alloc && (tries == 0 || search == NULL))
        it = slabs_alloc(ntotal, id);    if (it == NULL) {
        itemstats[id].outofmemory++;
        mutex_unlock(&cache_lock);        return NULL; //沒錯!會有分配新空間不成功,而且嘗試5次淘汰舊的item也沒成功的時候,只能返回NULL。。
    }
    assert(it->slabs_clsid == 0);
    assert(it != heads[id]);    //來到這裡,說明item分配成功,下面主要是一些初始化工作。
    /* Item initialization can happen outside of the lock; the item's already
     * been removed from the slab LRU.
     */
    it->refcount = 1; /* the caller will have a reference */
    mutex_unlock(&cache_lock);
    it->next = it->prev = it->h_next = 0;
    it->slabs_clsid = id;
    DEBUG_REFCNT(it, '*');
    it->it_flags = settings.use_cas ? ITEM_CAS : 0;
    it->nkey = nkey;
    it->nbytes = nbytes;
    memcpy(ITEM_key(it), key, nkey);
    it->exptime = exptime;
    memcpy(ITEM_suffix(it), suffix, (size_t)nsuffix);
    it->nsuffix = nsuffix;    return it;
}

至此,我們已經把“分配”item空間的工作說完了,但是分配完item空間之後實質還有一些”收尾工作”
我們的一個SET命令分成兩部分,一部分是“命令”行,第二部分是“資料”行,而上面所提到的item空間分配工作都是完成“命令”行的工作,回憶一下,狀態機在完成“SET命令”第二部分行為(即把value塞到了我們分配的item的data欄位的value位置)的時候,收尾的時候會來到:

static void complete_nread_ascii(conn *c) { //。。
 ret = store_item(it, comm, c); //。。}

其實這個store_item會對這個item進行一些收尾工作:

enum store_item_type store_item(item *item, int comm, conn* c) {    enum store_item_type ret;    uint32_t hv;
    hv = hash(ITEM_key(item), item->nkey); //鎖住item
    item_lock(hv);
    ret = do_store_item(item, comm, c, hv);
    item_unlock(hv);    return ret;
}enum store_item_type do_store_item(item *it, int comm, conn *c, const uint32_t hv) {  //。。。
      do_item_link(it, hv);  //。。。}

上面的do_item_link函式引出了一個叫“link”,連結的概念,這個link的意思就是主要包括下面三部分:

a)改變一些統計資料

b)把item加到雜湊表

c)把item插入到相應的slabclass lru連結串列中

int do_item_link(item *it, const uint32_t hv) {
    MEMCACHED_ITEM_LINK(ITEM_key(it), it->nkey, it->nbytes);
    assert((it->it_flags & (ITEM_LINKED|ITEM_SLABBED)) == 0);
    mutex_lock(&cache_lock);
    it->it_flags |= ITEM_LINKED;
    it->time = current_time;
    STATS_LOCK();
    stats.curr_bytes += ITEM_ntotal(it);
    stats.curr_items += 1;
    stats.total_items += 1;
    STATS_UNLOCK();    /* Allocate a new CAS ID on link. */
    ITEM_set_cas(it, (settings.use_cas) ? get_cas_id() : 0);
    assoc_insert(it, hv); //插入雜湊表
    item_link_q(it); //加入LRU連結串列
    refcount_incr(&it->refcount);
    mutex_unlock(&cache_lock);    return 1;
}

link收尾工作做完,我們的分配記憶體工作總算完成了。



作者:芥末無疆sss
連結:
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4830/viewspace-2815913/,如需轉載,請註明出處,否則將追究法律責任。

相關文章