Protobuf序列化原理大揭祕-爲什麼Protobuf性能這麼好?

在本文的下載附件中,有一個參考例子,將 .proto 文件編譯生成 XML 的 compiler,可以作為參考。

Protobuf 的更多細節

人們一直在強調,同 XML 相比, Protobuf 的主要優點在於性能高。它以高效的二進制方式存儲,比 XML 小 3 到 10 倍,快 20 到 100 倍。

對於這些 “小 3 到 10 倍”,“快 20 到 100 倍”的說法,嚴肅的程序員需要一個解釋。因此在本文的最後,讓我們稍微深入 Protobuf 的內部實現吧。

有兩項技術保證了採用 Protobuf 的程序能獲得相對於 XML 極大的性能提高。

第一點,我們可以考察 Protobuf 序列化後的信息內容。您可以看到 Protocol Buffer 信息的表示非常緊湊,這意味著消息的體積減少,自然需要更少的資源。比如網絡上傳輸的字節數更少,需要的 IO 更少等,從而提高性能。

第二點我們需要理解 Protobuf 封解包的大致過程,從而理解為什麼會比 XML 快很多。

Google Protocol Buffer 的 Encoding

Protobuf 序列化後所生成的二進制消息非常緊湊,這得益於 Protobuf 採用的非常巧妙的 Encoding 方法。

考察消息結構之前,讓我首先要介紹一個叫做 Varint 的術語。

Varint 是一種緊湊的表示數字的方法。它用一個或多個字節來表示一個數字,值越小的數字使用越少的字節數。這能減少用來表示數字的字節數。

比如對於 int32 類型的數字,一般需要 4 個 byte 來表示。但是採用 Varint,對於很小的 int32 類型的數字,則可以用 1 個 byte 來表示。當然凡事都有好的也有不好的一面,採用 Varint 表示法,大的數字則需要 5 個 byte 來表示。從統計的角度來說,一般不會所有的消息中的數字都是大數,因此大多數情況下,採用 Varint 後,可以用更少的字節數來表示數字信息。下面就詳細介紹一下 Varint。

Varint 中的每個 byte 的最高位 bit 有特殊的含義,如果該位為 1,表示後續的 byte 也是該數字的一部分,如果該位為 0,則結束。其他的 7 個 bit 都用來表示數字。因此小於 128 的數字都可以用一個 byte 表示。大於 128 的數字,比如 300,會用兩個字節來表示:1010 1100 0000 0010

下圖演示了 Google Protocol Buffer 如何解析兩個 bytes。注意到最終計算前將兩個 byte 的位置相互交換過一次,這是因為 Google Protocol Buffer 字節序採用 little-endian 的方式。

圖 6. Varint 編碼

Protobuf序列化原理大揭秘-為什麼Protobuf性能這麼好?

消息經過序列化後會成為一個二進制數據流,該流中的數據為一系列的 Key-Value 對。如下圖所示:

圖 7. Message Buffer

Protobuf序列化原理大揭秘-為什麼Protobuf性能這麼好?

採用這種 Key-Pair 結構無需使用分隔符來分割不同的 Field。對於可選的 Field,如果消息中不存在該 field,那麼在最終的 Message Buffer 中就沒有該 field,這些特性都有助於節約消息本身的大小。

以代碼清單 1 中的消息為例。假設我們生成如下的一個消息 Test1:

Protobuf序列化原理大揭秘-為什麼Protobuf性能這麼好?

則最終的 Message Buffer 中有兩個 Key-Value 對,一個對應消息中的 id;另一個對應 str。

Key 用來標識具體的 field,在解包的時候,Protocol Buffer 根據 Key 就可以知道相應的 Value 應該對應於消息中的哪一個 field。

Key 的定義如下:

Protobuf序列化原理大揭秘-為什麼Protobuf性能這麼好?

可以看到 Key 由兩部分組成。第一部分是 field_number,比如消息 lm.helloworld 中 field id 的 field_number 為 1。第二部分為 wire_type。表示 Value 的傳輸類型。

Wire Type 可能的類型如下表所示:

表 1. Wire Type

Protobuf序列化原理大揭秘-為什麼Protobuf性能這麼好?

在我們的例子當中,field id 所採用的數據類型為 int32,因此對應的 wire type 為 0。細心的讀者或許會看到在 Type 0 所能表示的數據類型中有 int32 和 sint32 這兩個非常類似的數據類型。Google Protocol Buffer 區別它們的主要意圖也是為了減少 encoding 後的字節數。

在計算機內,一個負數一般會被表示為一個很大的整數,因為計算機定義負數的符號位為數字的最高位。如果採用 Varint 表示一個負數,那麼一定需要 5 個 byte。為此 Google Protocol Buffer 定義了 sint32 這種類型,採用 zigzag 編碼。

Zigzag 編碼用無符號數來表示有符號數字,正數和負數交錯,這就是 zigzag 這個詞的含義了。

如圖所示:

圖 8. ZigZag 編碼

Protobuf序列化原理大揭秘-為什麼Protobuf性能這麼好?

使用 zigzag 編碼,絕對值小的數字,無論正負都可以採用較少的 byte 來表示,充分利用了 Varint 這種技術。

其他的數據類型,比如字符串等則採用類似數據庫中的 varchar 的表示方法,即用一個 varint 表示長度,然後將其餘部分緊跟在這個長度部分之後即可。

通過以上對 protobuf Encoding 方法的介紹,想必您也已經發現 protobuf 消息的內容小,適於網絡傳輸。假如您對那些有關技術細節的描述缺乏耐心和興趣,那麼下面這個簡單而直觀的比較應該能給您更加深刻的印象。

對於代碼清單 1 中的消息,用 Protobuf 序列化後的字節序列為:

Protobuf序列化原理大揭秘-為什麼Protobuf性能這麼好?

而如果用 XML,則類似這樣:

Protobuf序列化原理大揭秘-為什麼Protobuf性能這麼好?

封解包的速度

首先我們來了解一下 XML 的封解包過程。XML 需要從文件中讀取出字符串,再轉換為 XML 文檔對象結構模型。之後,再從 XML 文檔對象結構模型中讀取指定節點的字符串,最後再將這個字符串轉換成指定類型的變量。這個過程非常複雜,其中將 XML 文件轉換為文檔對象結構模型的過程通常需要完成詞法文法分析等大量消耗 CPU 的複雜計算。

反觀 Protobuf,它只需要簡單地將一個二進制序列,按照指定的格式讀取到 C++ 對應的結構類型中就可以了。從上一節的描述可以看到消息的 decoding 過程也可以通過幾個位移操作組成的表達式計算即可完成。速度非常快。

為了說明這並不是我拍腦袋隨意想出來的說法,下面讓我們簡單分析一下 Protobuf 解包的代碼流程吧。

以代碼清單 3 中的 Reader 為例,該程序首先調用 msg1 的 ParseFromIstream 方法,這個方法解析從文件讀入的二進制數據流,並將解析出來的數據賦予 helloworld 類的相應數據成員。

該過程可以用下圖表示:

圖 9. 解包流程圖

Protobuf序列化原理大揭秘-為什麼Protobuf性能這麼好?

整個解析過程需要 Protobuf 本身的框架代碼和由 Protobuf 編譯器生成的代碼共同完成。Protobuf 提供了基類 Message 以及 Message_lite 作為通用的 Framework,,CodedInputStream 類,WireFormatLite 類等提供了對二進制數據的 decode 功能,從 5.1 節的分析來看,Protobuf 的解碼可以通過幾個簡單的數學運算完成,無需複雜的詞法語法分析,因此 ReadTag() 等方法都非常快。 在這個調用路徑上的其他類和方法都非常簡單,感興趣的讀者可以自行閱讀。 相對於 XML 的解析過程,以上的流程圖實在是非常簡單吧?這也就是 Protobuf 效率高的第二個原因了。

結束語

往往瞭解越多,人們就會越覺得自己無知。我惶恐地發現自己竟然寫了一篇關於序列化的文章,文中必然有許多想當然而自以為是的東西,還希望各位能夠去偽存真,更希望真的高手能不吝賜教,給我來信。謝謝。


分享到:


相關文章: