早就想寫了,不過前面有redis原始碼學習的系列在,就一直拖著。
在我學protobuf的時候,在網上看到一個部落格,說的挺好,但是偏偏插了這麼一句:fixed 和 int 相比,fixed重時間、int重空間。所以如果對空間效能有要求的話就使用int… 吧啦吧啦的。
然後我還就真信了。然後還就把我畢設專案協定裡的int32全換成fixed32了,給我一頓操作猛如虎啊。
後來,由於前面學的不全面,我又去了protobuf的官網檢視官方檔案,就想著看看那個人說的是不是對的。好傢伙,翻來翻去,人家只是說,如果大數出現頻繁的業務,考慮使用fixed,並沒有說什麼哪個快哪個慢。。。
然後我整個人就懵了,要知道我的畢設裡可幾乎都是小數啊(8位元數以下),那到底他說的是對的還是錯的啊???
想來想去,還是看原始碼裡怎麼寫的吧。
這裡的 [Length] 是可選的,含義是針對不同型別的資料編碼結構可能會變成 Tag - Value 的形式,如果變成這樣的形式,沒有了 Length 我們該如何確定 Value 的邊界?
注意其中的 Start group 和 End group 兩種型別已被遺棄。
這樣:
對 sint32、sint64 又會有一些特別編碼(ZigTag 編碼)處理,相當於 Varint 這個編碼型別裡又存在兩種不同編碼。
現在來模擬一下,我們接收到了一串序列化的二進位制資料,我們先讀一個 Varints 編碼塊,進行 Varints 解碼,讀取最後 3 bit 得到 wire_type(由此可知是後面的 Value 採用的哪種編碼),隨後獲取到 field_number (由此可知是哪一個欄位)。依據 wire_type 來正確讀取後面的 Value。接著繼續讀取下一個欄位 field…
1、在每個位元組開頭的 bit 設定了 msb(most significant bit ),標識是否需要繼續讀取下一個位元組
2、儲存數位對應的二進位制二補數
3、二補數的低位排在前面
看個例子:
int32 val = 1; // 設定一個 int32 的欄位的值 val = 1; 這時編碼的結果如下
原碼:0000 ... 0000 0001 // 1 的原碼錶示
二補數:0000 ... 0000 0001 // 1 的二補數表示
Varints 編碼:0#000 0001(0x01) // 1 的 Varints 編碼,其中第一個位元組的 msb = 0
編碼過程:
數位 1 對應二補數 0000 … 0000 0001(規則 2),從末端開始取每 7 位一組並且反轉排序(規則 3),因為 0000 … 0000 0001 除了第一個取出的 7 位組(即原數列的後 7 位),剩下的均為 0。所以只需取第一個 7 位組,無需再取下一個 7 bit,那麼第一個 7 位組的 msb = 0。最終得到:
0 | 000 0001(0x01)
解碼過程:
數位 1 的 Varints 編碼中 msb = 0,所以只需要讀完第一個位元組無需再讀。去掉 msb 之後,剩下的 000 0001 就是二補數的逆序,但是這裡只有一個位元組,所以無需反轉,直接解釋二補數 000 0001,還原即為數位 1。
這裡編碼數位 1,Varints 只使用了 1 個位元組。而正常情況下 int32 將使用 4 個位元組儲存數位 1。
msb 實際上就起到了 Length 的作用,正因為有了 msb(Length),所以我們可以擺脫原來那種無論數位大小都必須分配四個位元組的窘境。通過 Varints 我們可以讓小的數位用更少的位元組表示。從而提高了空間利用和效率。
不多說,直接上例子:
int32 val = -1
原碼:1000 ... 0001 // 注意這裡是 8 個位元組
二補數:1111 ... 1111 // 注意這裡是 8 個位元組
再次複習 Varints 編碼:對二補數取 7 bit 一組,低位放在前面。
上述二補數 8 個位元組共 64 bit,可分 9 組(負數的二補數和正數不一樣)且這 9 組均為 1,這 9 組的 msb 均為 1,最後剩下一個 bit 的 1,用 0 補齊作為最後一組放在最後,最後得到 Varints 編碼:
1#1111111 ... 0#000 0001 (FF FF FF FF FF FF FF FF FF 01)
注意,因為負數必須在最高位(符號位)置 1,這一點意味著無論如何,負數都必須佔用所有位元組,所以它的二補數總是佔滿 8 個位元組。你沒法像正數那樣去掉多餘的高位(都是 0)。再加上 msb,最終 Varints 編碼的結果將固定在 10 個位元組。
這也就是說為什麼在 protoc 裡直接用 int 儲存負數不好。
儲存負數推薦 sint 族,在使用 sint 的時候,預設採用 ZigZag 編碼。
一張圖其實就明白了:
這裡的「對映」是以移位實現的。
當 boolVal = false 時,其編碼結果為空。
這裡是 ProtoBuf 為了提高效率做的又一個小技巧:規定一個預設值機制,當讀出來的欄位為空的時候就設定欄位的值為預設值。而 bool 型別的預設值為 false。也就是說將 false 編碼然後傳遞(消耗一個位元組),不如直接不輸出任何編碼結果(空),終端解析時發現該欄位為空,它會按照規定設定其值為預設值(也就是 false)。如此,可進一步節省空間提高效率。
Varints 編碼在一定範圍內是有高效的,超過某一個數位佔用位元組反而更多,效率更低。如果現在有場景是存在大量的大數位,那麼使用 Varints 就不太合適了。具體的,如果數值比 2^56
大的話,fixed64 這個型別比 uint64 高效,如果數值比 2^28
大的話,fixed32 這個型別比 uint32 高效。
example.set_fixed64val(1)
example.set_sfixed64val(-1)
example.set_doubleval(1.2)
// 編碼結果,總是 8 個位元組
09 # 01 00 00 00 00 00 00 00
11 # FF FF FF FF FF FF FF FF (沒有 ZigZag 編碼)
19 # 33 33 33 33 33 33 F3 3F
fixed32、sfixed32、float 與 64-bit 同理
string、bytes、EmbeddedMessage、repeated。Length-delimited 型別的編碼結構為 Tag - Length - Value:
syntax = "proto3";
// message 定義
message Example1 {
string stringVal = 1;
bytes bytesVal = 2;
}
Example1 example1;
example1.set_stringval("hello,world");
example1.set_bytesval("are you ok?");
0A 0B 68 65 6C 6C 6F 2C 77 6F 72 6C 64
12 0B 61 72 65 20 79 6F 75 20 6F 6B 3F
原先的 repeated 欄位的編碼結構為 Tag-Length-Value-Tag-Length-Value-Tag-Length-Value…,因為這些 Tag 都是相同的(同一欄位),因此可以將這些欄位的 Value 打包,即將編碼結構變為 Tag-Length-Value-Value-Value…
syntax = "proto3";
// message 定義
message Example1 {
repeated int32 repeatedInt32Val = 4;
repeated string repeatedStringVal = 5;
}
example1.add_repeatedint32val(2);
example1.add_repeatedint32val(3);
example1.add_repeatedstringval("repeated1");
example1.add_repeatedstringval("repeated2");
22 02 02 03
2A 09 72 65 70 65 61 74 65 64 31 2A 09 72 65 70 65 61 74 65 64 32
repeatedInt32Val 欄位的編碼結果為:
22 | 02 02 03
22 即 00100010 -> wire_type = 2(Length-delimited), field_number = 4(repeatedInt32Val 欄位),02 位元組長度為 2,則讀取兩個位元組,之後按照 Varints 解碼出數位 2 和 3。
上文沒有提及巢狀欄位,因為:
依據元資訊(即 .proto 檔案,使用 protoc 編譯時,.proto 檔案會被編譯成字串儲存在程式碼 xxx.pb.cc 中)可以區分該欄位是否是巢狀欄位。簡單來說,你是無法直接從 pb 二進位制資料直接解碼出資訊的,一定是需要有 .proto 檔案的配合。只是在程式碼層面, .proto 檔案早就在 protoc 的時候就已經以某種形式存在於 protobuf 生成的使用者端程式碼中,程式碼可以隨時拿到 .proto 檔案中表達的元資訊,例如一個欄位是否為巢狀欄位。
文章標題寫的是原始碼探祕是吧。是得放點程式碼出來。
當某個 Message 呼叫 SerializeToString 時,經過一層層呼叫最終會呼叫底層的關鍵編碼函數 WriteVarint32ToArray 或 WriteVarint64ToArray,整個過程如下圖所示:
inline uint8* CodedOutputStream::WriteVarint32ToArray(uint32 value, uint8* target) {
// 0x80 -> 1000 0000
// 大於 1000 0000 意味這進行 Varints 編碼時至少需要兩個位元組
// 如果 value < 0x80,則只需要一個位元組,編碼結果和原值一樣,則沒有迴圈直接返回
// 如果至少需要兩個位元組
while (value >= 0x80) {
// 如果還有後續位元組,則 value | 0x80 將 value 的最後位元組的最高 bit 位設定為 1,並取後七位
*target = static_cast<uint8>(value | 0x80);
// 處理完七位,後移,繼續處理下一個七位
value >>= 7;
// 指標加一,(陣列後移一位)
++target;
}
// 跳出迴圈,則表示已無後續位元組,但還有最後一個位元組
// 把最後一個位元組放入陣列
*target = static_cast<uint8>(value);
// 結束地址指向陣列最後一個元素的末尾
return target + 1;
}
// Varint64 同理
inline uint8* CodedOutputStream::WriteVarint64ToArray(uint64 value,
uint8* target) {
while (value >= 0x80) {
*target = static_cast<uint8>(value | 0x80);
value >>= 7;
++target;
}
*target = static_cast<uint8>(value);
return target + 1;
}
inline uint8* WireFormatLite::WriteFixed32ToArray(int field_number,
uint32 value, uint8* target) {
// WriteTagToArray: Tag 依然是 Varint 編碼,與上一節 Varint 型別是一致的
// WriteFixed32NoTagToArray:固定寫四個位元組即可
target = WriteTagToArray(field_number, WIRETYPE_FIXED32, target);
return WriteFixed32NoTagToArray(value, target);
}
inline uint8* WireFormatLite::WriteSFixed32NoTagToArray(int32 value,
uint8* target) {
return io::CodedOutputStream::WriteLittleEndian32ToArray(
static_cast<uint32>(value), target);
}
inline uint8* CodedOutputStream::WriteLittleEndian32ToArray(uint32 value,
uint8* target) {
#if defined(PROTOBUF_LITTLE_ENDIAN)
memcpy(target, &value, sizeof(value));
#else
target[0] = static_cast<uint8>(value);
target[1] = static_cast<uint8>(value >> 8);
target[2] = static_cast<uint8>(value >> 16);
target[3] = static_cast<uint8>(value >> 24);
#endif
return target + sizeof(value);
}
這個會快?不要看上面一個while就以為人家慢了。
因為其編碼結構為 Tag - Length - Value,所以其欄位完整的序列化會稍微多出一些過程:
其序列化實現的幾個關鍵函數為:
ByteSizeLong:計算物件序列化所需要的空間大小,在記憶體中開闢相應大小的空間
WriteTagToArray:將 Tag 值寫入到之前開闢的記憶體中
WriteStringWithSizeToArray:將 Length + Value 值寫入到之前開闢的記憶體中