cornerstone中RAFT的buffer的實現

TomGeller發表於2024-10-17

1.概覽:

談到raft協議實現就繞不開網上流行的mit6.824,但其為go語言,官方沒有lab的答案,框架也很晦澀難懂,且全網沒有一個部落格對其有清晰的解釋,有的只是甩一堆名詞然後直接貼沒有任何註釋的程式碼,非常不適合學習。
但是github上面的cornerstone是純c++實現的一個非常優雅且精簡的raft協議,碼風優美,程式碼易懂,介面清晰,對c++黨非常友好,也很適合初學raft的人來學習。
鑑於cornerstone這麼優秀的程式碼還沒人對其有過原始碼級解析,我決定記錄自己學習其原始碼過程並對其原始碼進行詳細解析。
那我們得從哪裡開始分析cornerstone呢?開始的切入點應該越小越好,同時具有很強的通用性,在很多環節又用得到。
buffer是cornerstone中的一個非常重要的概念,從rpc傳送請求,接受response,到log記錄等等方面都採用buffer來儲存資訊。
因此讓我們先從buffer開始我們對cornerstone原始碼的解析。

2.buffer的總體架構


如圖,總體buffer可分為3部分:
size:記錄data塊的位元組個數,從0開始編號(size = 0代表data塊為空而不是buffer為空),不包括前面的sizepossize + pos 統稱header)。
根據size的大小(也就是data塊的位元組數)可將buffer分為大塊與小塊,其中size >= 0x8000 為大塊,否則為小塊。(這裡有個問題:大小塊size都是一個範圍,我們要怎麼快速來確定buffer是大塊還是小塊呢?這個問題答案我們放到後面再細說)
pos:記錄data塊的讀寫指標,也是從0開始編號(這裡pos既記錄讀又記錄寫操作的位置,本身是1個指標,存不了兩條資訊,所以我們需要自己手動調整pos
data:儲存buffer裡面的實際資料,可以是int,ulong,str等多種型別

3.buffer的記憶體分配

我們先上原始碼:

bufptr buffer::alloc(const size_t size) {
    if (size >= 0x80000000) {
        throw std::out_of_range("size exceed the max size that cornrestone::buffer could support");
    }

    if (size >= 0x8000) {
        bufptr buf(reinterpret_cast<buffer*>(new char[size + sizeof(uint) * 2]), &free_buffer);
        any_ptr ptr = reinterpret_cast<any_ptr>(buf.get());
        __init_b_block(ptr, size);
        return buf;
    }

    bufptr buf(reinterpret_cast<buffer*>(new char[size + sizeof(ushort) * 2]), &free_buffer);
    any_ptr ptr = reinterpret_cast<any_ptr>(buf.get());
    __init_s_block(ptr, size);
    return buf;
}

我們這裡只分析大塊的分配,小塊的程式碼同理。
(1)首先判斷要求分配size的大小,如果size >= 0x80000000,直接丟擲異常

(2)size >= 0x8000(也就是INT_MAX = 32768)的時候意味著要分配大塊。透過new char[size + sizeof(uint) * 2]分配了要求的size + header的位元組數。這裡bufptr的定義在buffer.hxx裡面using bufptr = uptr<buffer, void (*)(buffer*)>;。根據bufptr的定義可以知道這是一個指向buffer類的unique_ptr,第二個引數void(*)(buffer*)是一個函式指標, 返回值為void,引數是buffer*,對應著原始碼裡面的&free_buffer,是一個自定義的釋放bufptr指向內容的函式。

(3)把bufptr展開來就是unique_ptr<buffer, &free_buffer> buf(reinterpret_cast<buffer*>(new char[size + sizeof(uint) * 2]), &free_buffer), 這裡reinterpret_cast是用於無關型別的相互轉換。new char[]返回的是char *指標,但是根據unique_ptr<T> A(xxx)的語法括號裡面的xxx是指向T型別的指標,所以我們需要用reinterpret_cast將char *指標轉換為buffer *

(4)完成了記憶體的分配然後到any_ptr ptr = reinterpret_cast<any_ptr>(buf.get());這裡的any_ptr在basic_types.hxx裡面的定義是typedef void* any_ptr;。而buf.get()是unique_ptr的一個成員函式,用於獲取其原始指標,那麼any_ptr ptr = reinterpret_cast<any_ptr>(buf.get());這一行實現的便是將原始指標提取出來並轉換為void*型別


(5)接著是__init_b_block(ptr, size);這個宏定義

#define __init_block(p, s, t) ((t*)(p))[0] = (t)s;\
    ((t*)(p))[1] = 0
#define __init_s_block(p, s) __init_block(p, s, ushort)
#define __init_b_block(p, s) __init_block(p, s, uint);\
    *((uint*)(p)) |= 0x80000000

b_block表示大塊,s_block表示小塊
(5.1)不管大塊還是小塊都透過__init_block(p, s, t)來初始化,t表示型別(ushortuint),p就是指向buffer的指標,s是buffer的size引數。

(5.2)前面buffer的總體架構裡面我們說過buffer分為三個部分,那麼這裡的p[0],p[1]很明顯就是對應的size pos 引數。

(5.3)為什麼初始化sizepos引數不直接用p[0] = size, p[1] = pos呢?這裡的((t*)(p))[0],((t*)(p))[1]又是什麼?
由於我們規定size >= 0x8000(USHORT_MAX = 32768), 說明我們p[0]存的size在大塊的時候就不能用ushort來表示了,必須得用uint型別,所以我們將p指標強轉為uint*型別,這樣uint*意義下的p[0]便表示以p開始往後數uint個位元組來儲存我們的size。pos也是同理,因為pos是描述data塊的讀寫指標,所以pos \(\in\) [0, size),也需要考慮是用uint型別還是ushort型別。

(5.4)在初始化大塊的時候為什麼要*((uint*)(p)) |= 0x80000000
這裡就是我們前面說的如何確定buffer是大塊還是小塊問題的關鍵。
0x80000000轉換為10進位制是231,由於大塊的sizepos是uint,所以有32位且無符號位,231剛好佔據的是uint的最高位。
讓p強轉為uint*型別後又用*取內容得到uint型別的值(實際上就是uint型別的p[0]),接著將其|= 0x80000000使得最高位為1。

那具體這個最高位為1是怎麼用於判斷大小塊的呢?

#define __is_big_block(p) (0x80000000 & *((uint*)(p)))

我們將p[0]轉為uint型別,接著與0x80000000進行相與。

  • 如果是大塊,由於我們__init_b_block的時候將p[0]最高位置1,與0x80000000相與的結果就是1,對應的__is_big_block返回值是1
  • 如果是小塊,由於&操作是按位進行,所以最高位為0,而後面的位由於0x80000000全為0得到的也是0,對應的__is_big_block返回值是0
    我們便透過位運算而不是進行size >= 0x80000000的判斷從而快速確定buffer是大塊還是小塊。

(6)瞭解完__init_b_block的宏定義後,我們還有一個問題沒有解決,那就是為什麼要將bufptr取原始指標後再轉化為any_ptr
首先我們得知道智慧指標中的unique_ptr有獨佔所有權的概念,而uint*與ushort*都是沒有所有權管理的普通指標,所以不能進行轉換。
但是unique_ptr給我們提供了get()成員函式,允許我們不轉移所有權的使用原始指標,而原始指標是可以轉換成我們需要的uint*或者ushort*的。
因此我們需要先呼叫bufptr.get()取出原始指標,然後轉換為void*型別的any_ptr,再根據需要轉換為uint*或者ushort*。

4.buffer資料的寫入

4.1 byte資料的寫入

void buffer::put(byte b) {
    if (size() - pos() < sz_byte) {
        throw std::overflow_error("insufficient buffer to store byte");
    }

    byte* d = data();
    *d = b;
    __mv_fw_block(this, sz_byte);
}

再具體解釋怎麼寫入之前,我們先把程式碼裡面的陌生函式解釋一遍。


(1)size()函式

size_t buffer::size() const {
    return (size_t)(__size_of_block(this));
}

__size_of_block的宏定義是

#define __size_of_block(p) (__is_big_block(p)) ? (*((uint*)(p)) ^ 0x80000000) : *((ushort*)(p))
  • 如果是大塊,就講轉成int型別的p[0]異或上0x80000000。前面我們說過大塊的p[0]需要 |= 0x80000000將最高位置1達到快速判斷大小塊的目的。在獲取大塊真實的p[0]表示的size資料時,我們需要反過來取異或將1消掉得到真實的size。
  • 如果是小塊,則直接取ushort型別的p[0]

(2)pos()函式

size_t buffer::pos() const {
    return (size_t)(__pos_of_block(this));
}

對應的宏定義是

#define __pos_of_s_block(p) ((ushort*)(p))[1]
#define __pos_of_b_block(p) ((uint*)(p))[1]

根據大塊還是小塊選擇uint或者ushort型別的p[1]

(3)data()函式

byte* buffer::data() const {
    return __data_of_block(this);
}

對應的宏定義:

#define __data_of_block(p) (__is_big_block(p)) ? (byte*) (((byte*)(((uint*)(p)) + 2)) + __pos_of_b_block(p)) : (byte*) (((byte*)(((ushort*)p) + 2)) + __pos_of_s_block(p))

這裡的__data_of_block有點複雜,我們以大塊為例逐步分解來看,小塊同理。

  • 首先透過(uint*)(p)將p轉成uint*型別,然後再此基礎上 + 2(2個uint的位元組)。根據前面buffer的總體架構我們知道,buffer前兩個區域是sizepos。大塊的size 與 pos均為uint型別,將p轉成uint*然後再 + 2便可以實現跳轉到data塊的開始處。
  • 但是讀寫buffer的過程中讀寫指標會變化,比如我們寫入了1,然後又想寫入0,如果還是從data開始處寫的話會直接覆蓋開始的1,只有從1的末尾繼續寫0才合理,換句話說我們要實現追加(append)模式。因此((byte*)(((uint*)(p)) + 2)) + __pos_of_b_block(p)) 透過 +__pos_of_b_block(p) 跳轉到當前讀寫指標的位置。
    小塊也是同理,只不過從uint*換成ushort*。

透過這兩步我們便可以得到當前讀寫指標所在位置的指標。

(4)__mv_fw_block(this, sz_byte);
這是一個宏定義

#define __mv_fw_block(p, d) if(__is_big_block(p)){\
    ((uint*)(p))[1] += (d);\
    }\
    else{\
    ((ushort*)(p))[1] += (ushort)(d);\
    }

每次讀或者寫buffer的時候,我們都要更新p[1]代表的pos,實現流的讀入或者流的寫入。
比如說要讀入12345,我們讀了1,讓pos += 1,這樣再讀就可以讀到2。如果一次讀了123, 就讓pos += 3,下次再讀就可以從4開始。寫也是同理。


介紹完這幾個函式後,我們再回到byte資料的寫入。

  • 首先判斷寫入的資料是否超過buffer的大小,如果是,丟擲overflow異常
  • 否則取到當前讀寫指標所在位置的byte *d = data();
  • 透過*d = b寫入位元組型資料b
  • __mv_fw_block(this, sz_byte);更新讀寫指標

4.2 int32型別資料的寫入

與簡單的byte資料直接寫入不同,多位元組型資料寫入有著順序上的講究。
我們先來看原始碼:

void buffer::put(int32 val) {
    if (size() - pos() < sz_int) {
        throw std::overflow_error("insufficient buffer to store int32");
    }

    byte* d = data();
    for (size_t i = 0; i < sz_int; ++i) {
        *(d + i) = (byte)(val >> (i * 8));
    }

    __mv_fw_block(this, sz_int);
}

前面都與byte資料的寫入大同小異,重點是後面的for迴圈

    for (size_t i = 0; i < sz_int; ++i) {
        *(d + i) = (byte)(val >> (i * 8));
    }
  • sz_int是指int的位元組個數,for迴圈遍歷了要寫入的int val的每一個位元組。
  • *(d + i)是給從d開始數的第i個位元組的位置賦值。
  • (byte)(val >> (i * 8)); 是將val右移了i * 8位,然後透過byte轉換取到右移後的低8位。

合起來就是從d開始數的第i個位元組的位置賦值成val右移了i * 8位的低8位
我們具體舉例來說明:
比如要放入1010001111111110010(10進位制 = 327658)。

  • 第一個i = 0, 放入11110010 到buffer的d[0]。
  • 第二次i = 1, 放入01111111 到buffer的d[1]。
  • 第三次i = 2, 放入00010100 到buffer的d[2]。
  • 第四次i = 3, 放入00000000 到buffer的d[3]。

很明顯可以看到這是將val按位元組進行了逆序儲存,為什麼不直接按原順序正向儲存呢?
答案就是因為難,我們進行位元組的轉換是透過(byte)來轉的,而這種轉換是擷取低8位而非高8位,所以我們for迴圈遍歷val的每個位元組時只能做到逆序儲存val。

4.3 ulong型別資料的寫入

void buffer::put(ulong val) {
    if (size() - pos() < sz_ulong) {
        throw std::overflow_error("insufficient buffer to store int32");
    }

    byte* d = data();
    for (size_t i = 0; i < sz_ulong; ++i) {
        *(d + i) = (byte)(val >> (i * 8));
    }

    __mv_fw_block(this, sz_ulong);
}

解析同4.2,for迴圈遍歷val的每個位元組,並且逆序儲存。

4.4 string型別資料的寫入

void buffer::put(const std::string& str) {
    if (size() - pos() < (str.length() + 1)) {
        throw std::overflow_error("insufficient buffer to store a string");
    }

    byte* d = data();
    for (size_t i = 0; i < str.length(); ++i) {
        *(d + i) = (byte)str[i];
    }

    *(d + str.length()) = (byte)0;
    __mv_fw_block(this, str.length() + 1);
}
  • 由於string型別每一個元素都是char型別(byte其實就是unsigned char),所以即使string也是多位元組資料,但是其不需要像int32或者ulong逆序儲存。
  • *(d + str.length()) = (byte)0;將最後一個位元組置為0,表示字串的終止,與“hello World!”等c-string在末尾自動新增'\0'同理。

4.5 buffer型別資料的寫入

void buffer::put(const buffer& buf) {
    size_t sz = size();
    size_t p = pos();
    size_t src_sz = buf.size();
    size_t src_p = buf.pos();
    if ((sz - p) < (src_sz - src_p)) {
        throw std::overflow_error("insufficient buffer to hold the other buffer");
    }

    byte* d = data();
    byte* src = buf.data();
    ::memcpy(d, src, src_sz - src_p);
    __mv_fw_block(this, src_sz - src_p);
}

透過memcpy實現deep copy。

5.buffer資料的讀取

5.1 byte資料的讀取

byte buffer::get_byte() {
    size_t avail = size() - pos();
    if (avail < sz_byte) {
        throw std::overflow_error("insufficient buffer available for a byte");
    }

    byte val = *data();
    __mv_fw_block(this, sz_byte);
    return val;
}
  • 先判斷buffer是否有足夠空間,沒有就丟擲overflow異常
  • 直接呼叫data(),獲取讀寫指標所在位置,並用*符號取其內容,得到所需val
  • __mv_fw_block(this, sz_byte);更新讀寫指標

5.2 int32資料的讀取

int32 buffer::get_int() {
    size_t avail = size() - pos();
    if (avail < sz_int) {
        throw std::overflow_error("insufficient buffer available for an int32 value");
    }

    byte* d = data();
    int32 val = 0;
    for (size_t i = 0; i < sz_int; ++i) {
        int32 byte_val = (int32)*(d + i);
        val += (byte_val << (i * 8));
    }

    __mv_fw_block(this, sz_int);
    return val;
}

重點關注for迴圈,從d開始遍歷一個int32的所有位元組。

  • 對每個位元組取出byte_val
  • byte_val左移 i * 8位後累加到val
  • 返回val

在前面4.2 int32資料的寫入中我們已經說過多位元組資料是逆序儲存的(除string型別)

我們再以1010001111111110010(10進位制 = 327658)來舉例說明
由於逆序儲存buffer中資料應該是11110010|01111111|00010100 |00000000

  • i = 0, 取出byte_val = 11110010, 這個byte_val應該在原始資料裡面佔據0-7位,因此我們左移 0 * 8 = 0位並累加到val
  • i = 1, 取出byte_val = 01111111, 這個byte_val應該在原始資料裡面佔據8-15位,因此我們左移 1 * 8 = 8位並累加到val
  • i = 2, 取出byte_val = 00010100, 這個byte_val應該在原始資料裡面佔據16-23位,因此我們左移 2 * 8 = 16位並累加到val
  • i = 3, 取出byte_val = 00000000, 這個byte_val應該在原始資料裡面佔據24-31位,因此我們左移 3 * 8 = 24位並累加到val

最後我們便得到正常順序的資料val

5.3 ulong資料的讀取

ulong buffer::get_ulong() {
    size_t avail = size() - pos();
    if (avail < sz_ulong) {
        throw std::overflow_error("insufficient buffer available for an ulong value");
    }

    byte* d = data();
    ulong val = 0L;
    for (size_t i = 0; i < sz_ulong; ++i) {
        ulong byte_val = (ulong)*(d + i);
        val += (byte_val << (i * 8));
    }

    __mv_fw_block(this, sz_ulong);
    return val;
}

分析同5.2,只是遍歷的位元組數從sz_int 變為sz_ulong

5.4 string型別的讀取

const char* buffer::get_str() {
    size_t p = pos();
    size_t s = size();
    size_t i = 0;
    byte* d = data();
    while ((p + i) < s && *(d + i)) ++i;
    if (p + i >= s || i == 0) {
        return nilptr;
    }

    __mv_fw_block(this, i + 1);
    return reinterpret_cast<const char*>(d);
}

string型別資料的讀取有點不一樣。

while ((p + i) < s && *(d + i)) ++i;
    if (p + i >= s || i == 0) {
        return nilptr;
    }
  • 首先我們不知道string型別資料的長度,我們只知道其以0結尾。所以要用while來一直取
  • while裡面判斷0直接用 while(*(d + i)) ++i就行了,為什麼還要加一個(p + i) < s呢?
    因為只有*(d + i)我們並不清楚自己是否會越界,可能由於某些原因即使遍歷完buffer依舊沒找到0,
    這個時候就需要我們再加一個(p + i) < s保證不會越界
  • 如果p + i >= s說明越界, i == 0說明根本沒資料,都應該返回nilptr
    __mv_fw_block(this, i + 1);
    return reinterpret_cast<const char*>(d);

最後調整讀寫指標的__mv_fw_block(this, i + 1);i + 1個位元組而不是i的原因:
假設string = "abc"(以'\0'標記結尾),i = 2的時候while條件依舊滿足,i++→i = 3
不妨設沒讀入string前的pos = 0, 這時候__mv_fw_block將pos += (i + 1)→pos = 4
表示下次讀寫操作都要從pos = 4開始。即跳過了"abc"與'\0'後的第一個位置。
如果__mv_fw_block(this, i + 1);i個位元組,則表示下次讀寫操作要從'\0'開始,會改變'\0'導致無法該字串無法被識別。

5.5 讀取資料並存到指定的buffer裡面

void buffer::get(bufptr& dst) {
    size_t sz = dst->size() - dst->pos();
    ::memcpy(dst->data(), data(), sz);
    __mv_fw_block(this, sz);
}
  • 先計算要copy的位元組數sz,dst->size() - dst->pos();表示是dst所剩的所有位元組
  • ::memcpy(dst->data(), data(), sz); 將data()開始往後數sz位元組的資料全部複製到dst裡面
  • 調整讀寫指標 __mv_fw_block(this, sz);

6.總結

  • 1.在判斷大小塊面前,我們巧妙的運用最高位透過位運算來快速判斷。
  • 2.buffer最重要的兩個操作便是讀(get)寫(put),在面對多位元組資料的時候我們可以看到逆序儲存的想法。
  • 3.面對智慧指標需要轉換的情況,我們應該先採取get()得到原始指標,然後再透過reinterpret_cast進行轉換。

相關文章