日日操夜夜添-日日操影院-日日草夜夜操-日日干干-精品一区二区三区波多野结衣-精品一区二区三区高清免费不卡

公告:魔扣目錄網(wǎng)為廣大站長提供免費收錄網(wǎng)站服務(wù),提交前請做好本站友鏈:【 網(wǎng)站目錄:http://www.ylptlb.cn 】, 免友鏈快審服務(wù)(50元/站),

點擊這里在線咨詢客服
新站提交
  • 網(wǎng)站:51998
  • 待審:31
  • 小程序:12
  • 文章:1030137
  • 會員:747

本文選自“字節(jié)跳動基礎(chǔ)架構(gòu)實踐”系列文章。
“字節(jié)跳動基礎(chǔ)架構(gòu)實踐”系列文章是由字節(jié)跳動基礎(chǔ)架構(gòu)部門各技術(shù)團隊及專家傾力打造的技術(shù)干貨內(nèi)容,和大家分享團隊在基礎(chǔ)架構(gòu)發(fā)展和演進過程中的實踐經(jīng)驗與教訓,與各位技術(shù)同學一起交流成長。
KiteX 自 2020.04 正式發(fā)布以來,公司內(nèi)部服務(wù)數(shù)量 8k+,QPS 過億。經(jīng)過持續(xù)迭代,KiteX 在吞吐和延遲表現(xiàn)上都取得了顯著收益。本文將簡單分享一些較有成效的優(yōu)化方向,希望為大家提供參考。

前言

KiteX 是字節(jié)跳動框架組研發(fā)的下一代高性能、強可擴展性的 Go RPC 框架。除具備豐富的服務(wù)治理特性外,相比其他框架還有以下特點:集成了自研的網(wǎng)絡(luò)庫 Netpoll;支持多消息協(xié)議(Thrift、Protobuf)和多交互方式(Ping-Pong、Oneway、 Streaming);提供了更加靈活可擴展的代碼生成器。

目前公司內(nèi)主要業(yè)務(wù)線都已經(jīng)大范圍使用 KiteX,據(jù)統(tǒng)計當前接入服務(wù)數(shù)量多達 8k。KiteX 推出后,我們一直在不斷地優(yōu)化性能,本文將分享我們在 Netpoll 和 序列化方面的優(yōu)化工作。

自研網(wǎng)絡(luò)庫 Netpoll 優(yōu)化

自研的基于 epoll 的網(wǎng)絡(luò)庫 —— Netpoll,在性能方面有了較為顯著的優(yōu)化。測試數(shù)據(jù)表明,當前版本(2020.12) 相比于上次分享時(2020.05),吞吐能力 ↑30%,延遲 AVG ↓25%,TP99 ↓67%,性能已遠超官方 net 庫。以下,我們將分享兩點顯著提升性能的方案。

epoll_wait 調(diào)度延遲優(yōu)化

Netpoll 在剛發(fā)布時,遇到了延遲 AVG 較低,但 TP99 較高的問題。經(jīng)過認真研究 epoll_wait,我們發(fā)現(xiàn)結(jié)合 polling 和 event trigger 兩種模式,并優(yōu)化調(diào)度策略,可以顯著降低延遲。

首先我們來看 Go 官方提供的 syscall.EpollWait 方法:

func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error)

這里共提供 3 個參數(shù),分別表示 epoll 的 fd、回調(diào)事件、等待時間,其中只有 msec 是動態(tài)可調(diào)的。

通常情況下,我們主動調(diào)用 EpollWait 都會設(shè)置 msec=-1,即無限等待事件到來。事實上不少開源網(wǎng)絡(luò)庫也是這么做的。但是我們研究發(fā)現(xiàn),msec=-1 并不是最優(yōu)解。

epoll_wait 內(nèi)核源碼(如下) 表明,msec=-1 比 msec=0 增加了 fetch_events 檢查,因此耗時更長。

static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
                   int maxevents, long timeout)
{
    ...
    if (timeout > 0) {
       ...
    } else if (timeout == 0) {
        ...
        goto send_events;
    }

fetch_events:
    ...
    if (eavail)
        goto send_events;

send_events:
    ...

Benchmark 表明,在有事件觸發(fā)的情況下,msec=0 比 msec=-1 調(diào)用要快 18% 左右,因此在頻繁事件觸發(fā)場景下,使用 msec=0 調(diào)用明顯是更優(yōu)的。

字節(jié)跳動 Go RPC 框架 KiteX 性能優(yōu)化實踐

 

而在無事件觸發(fā)的場景下,使用 msec=0 顯然會造成無限輪詢,空耗大量資源。

綜合考慮后,我們更希望在有事件觸發(fā)時,使用 msec=0 調(diào)用,而在無事件時,使用 msec=-1 來減少輪詢開銷。偽代碼如下:

var msec = -1
for {
   n, err = syscall.EpollWait(epfd, events, msec)
   if n <= 0 {
      msec = -1
      continue
   }
   msec = 0
   ...
}

那么這樣就可以了嗎?事實證明優(yōu)化效果并不明顯。

我們再做思考:

msec=0 僅單次調(diào)用耗時減少 50ns,影響太小,如果想要進一步優(yōu)化,必須要在調(diào)度邏輯上做出調(diào)整。

進一步思考:

上述偽代碼中,當無事件觸發(fā),調(diào)整 msec=-1 時,直接 continue 會立即再次執(zhí)行 EpollWait,而由于無事件,msec=-1,當前 goroutine 會 block 并被 P 切換。但是被動切換效率較低,如果我們在 continue 前主動為 P 切換 goroutine,則可以節(jié)約時間。因此我們將上述偽代碼改為如下:

var msec = -1
for {
   n, err = syscall.EpollWait(epfd, events, msec)
   if n <= 0 {
      msec = -1
      runtime.Gosched()
      continue
   }
   msec = 0
   ...
}

測試表明,調(diào)整代碼后,吞吐量 ↑12%,TP99 ↓64%,獲得了顯著的延遲收益。

合理利用 unsafe.Pointer

繼續(xù)研究 epoll_wait,我們發(fā)現(xiàn) Go 官方對外提供的 syscall.EpollWait 和 runtime 自用的 epollwait 是不同的版本,即兩者使用了不同的 EpollEvent。以下我們展示兩者的區(qū)別:

// @syscall
type EpollEvent struct {
   Events uint32
   Fd     int32
   Pad    int32
}
// @runtime
type epollevent struct {
   events uint32
   data   [8]byte // unaligned uintptr
}

我們看到,runtime 使用的 epollevent 是系統(tǒng)層 epoll 定義的原始結(jié)構(gòu);而對外版本則對其做了封裝,將 epoll_data(epollevent.data) 拆分為固定的兩字段:Fd 和 Pad。那么 runtime 又是如何使用的呢?在源碼里我們看到這樣的邏輯:

*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd

pd := *(**pollDesc)(unsafe.Pointer(&ev.data))

顯然,runtime 使用 epoll_data(&ev.data) 直接存儲了 fd 對應(yīng)結(jié)構(gòu)體(pollDesc)的指針,這樣在事件觸發(fā)時,可以直接找到結(jié)構(gòu)體對象,并執(zhí)行相應(yīng)邏輯。而對外版本則由于只能獲得封裝后的 Fd 參數(shù),因此需要引入額外的 Map 來增刪改查結(jié)構(gòu)體對象,這樣性能肯定相差很多。

所以我們果斷拋棄了 syscall.EpollWait,轉(zhuǎn)而仿照 runtime 自行設(shè)計了 EpollWait 調(diào)用,同樣采用 unsafe.Pointer 存取結(jié)構(gòu)體對象。測試表明,該方案下 吞吐量 ↑10%,TP99 ↓10%,獲得了較為明顯的收益。

Thrift 序列化/反序列化優(yōu)化

序列化是指把數(shù)據(jù)結(jié)構(gòu)或?qū)ο筠D(zhuǎn)換成字節(jié)序列的過程,反序列化則是相反的過程。RPC 在通信時需要約定好序列化協(xié)議,client 在發(fā)送請求前進行序列化,字節(jié)序列通過網(wǎng)絡(luò)傳輸?shù)?server,server 再反序列進行邏輯處理,完成一次 RPC 請求。Thrift 支持 Binary、Compact 和 JSON 序列化協(xié)議。目前公司內(nèi)部使用的基本都是 Binary,這里只介紹 Binary 協(xié)議。

Binary 采用 TLV 編碼實現(xiàn),即每個字段都由 TLV 結(jié)構(gòu)來描述,TLV 意為:Type 類型, Lenght 長度,Value 值,Value 也可以是個 TLV 結(jié)構(gòu),其中 Type 和 Length 的長度固定,Value 的長度則由 Length 的值決定。TLV 編碼結(jié)構(gòu)簡單清晰,并且擴展性較好,但是由于增加了 Type 和 Length,有額外的內(nèi)存開銷,特別是在大部分字段都是基本類型的情況下有不小的空間浪費。

序列化和反序列的性能優(yōu)化從大的方面來看可以從空間和時間兩個維度進行優(yōu)化。從兼容已有的 Binary 協(xié)議來看,空間上的優(yōu)化似乎不太可行,只能從時間維度進行優(yōu)化,包括:

  1. 減少內(nèi)存操作次數(shù),包括內(nèi)存分配和拷貝,盡量預(yù)分配內(nèi)存,減少不必要的開銷;
  2. 減少函數(shù)調(diào)用次數(shù),比如可調(diào)整代碼結(jié)構(gòu)和 inline 等手段進行優(yōu)化;

調(diào)研

根據(jù) go_serialization_benchmarks 的壓測數(shù)據(jù),我們找到了一些性能卓越的序列化方案進行調(diào)研,希望能夠?qū)ξ覀兊膬?yōu)化工作有所啟發(fā)。

通過對 protobuf、gogoprotobuf 和 Cap'n Proto 的分析,我們得出以下結(jié)論:

  1. 網(wǎng)絡(luò)傳輸中出于 IO 的考慮,都會盡量壓縮傳輸數(shù)據(jù),protobuf 采用了 Varint 編碼在大部分場景中都有著不錯的壓縮效果;
  2. gogoprotobuf 采用預(yù)計算方式,在序列化時能夠減少內(nèi)存分配次數(shù),進而減少了內(nèi)存分配帶來的系統(tǒng)調(diào)用、鎖和 GC 等代價;
  3. Cap'n Proto 直接操作 buffer,也是減少了內(nèi)存分配和內(nèi)存拷貝(少了中間的數(shù)據(jù)結(jié)構(gòu)),并且在 struct pointer 的設(shè)計中把固定長度類型數(shù)據(jù)和非固定長度類型數(shù)據(jù)分開處理,針對固定長度類型可以快速處理;

從兼容性考慮,不可能改變現(xiàn)有的 TLV 編碼格式,因此數(shù)據(jù)壓縮不太現(xiàn)實,但是 2 和 3 對我們的優(yōu)化工作是有啟發(fā)的,事實上我們也是采取了類似的思路。

思路

減少內(nèi)存操作

buffer 管理

無論是序列化還是反序列化,都是從一塊內(nèi)存拷貝數(shù)據(jù)到另一塊內(nèi)存,這就涉及到內(nèi)存分配和內(nèi)存拷貝操作,盡量避免內(nèi)存操作可以減少不必要的系統(tǒng)調(diào)用、鎖和 GC 等開銷。

事實上 KiteX 已經(jīng)提供了 LinkBuffer 用于 buffer 的管理,LinkBuffer 設(shè)計上采用鏈式結(jié)構(gòu),由多個 block 組成,其中 block 是大小固定的內(nèi)存塊,構(gòu)建對象池維護空閑 block,由此復(fù)用 block,減少內(nèi)存占用和 GC。

剛開始我們簡單地采用 sync.Pool 來復(fù)用 netpoll 的 LinkBufferNode,但是這樣仍然無法解決對于大包場景下的內(nèi)存復(fù)用(大的 Node 不能回收,否則會導致內(nèi)存泄漏)。目前我們改成了維護一組 sync.Pool,每組中的 buffer size 都不同,新建 block 時根據(jù)最接近所需 size 的 pool 中去獲取,這樣可以盡可能復(fù)用內(nèi)存,從測試來看內(nèi)存分配和 GC 優(yōu)化效果明顯。

string / binary 零拷貝

對于有一些業(yè)務(wù),比如視頻相關(guān)的業(yè)務(wù),會在請求或者返回中有一個很大的 Binary 二進制數(shù)據(jù)代表了處理后的視頻或者圖片數(shù)據(jù),同時會有一些業(yè)務(wù)會返回很大的 String(如全文信息等)。這種場景下,我們通過火焰圖看到的熱點都在數(shù)據(jù)的 copy 上,那我們就想了,我們是否可以減少這種拷貝呢?

答案是肯定的。既然我們底層使用的 Buffer 是個鏈表,那么就可以很容易地在鏈表中間插入一個節(jié)點。

字節(jié)跳動 Go RPC 框架 KiteX 性能優(yōu)化實踐

 

我們就采用了類似的思想,當序列化的過程中遇到了 string 或者 binary 的時候, 將這個節(jié)點的 buffer 分成兩段,在中間原地插入用戶的 string / binary 對應(yīng)的 buffer,這樣可以避免大的 string / binary 的拷貝了。

這里再介紹一下,如果我們直接用 []byte(string) 去轉(zhuǎn)換一個 string 到 []byte 的話實際上是會發(fā)生一次拷貝的,原因是 Go 的設(shè)計中 string 是 immutable 的但是 []byte 是 mutable 的,所以這么轉(zhuǎn)換的時候會拷貝一次;如果要不拷貝轉(zhuǎn)換的話,就需要用到 unsafe 了:

func StringToSliceByte(s string) []byte {
   l := len(s)
   return *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{
      Data: (*(*reflect.StringHeader)(unsafe.Pointer(&s))).Data,
      Len:  l,
      Cap:  l,
   }))
}

這段代碼的意思是,先把 string 的地址拿到,再拼裝上一個 slice byte 的 header,這樣就可以不拷貝數(shù)據(jù)而將 string 轉(zhuǎn)換成 []byte 了,不過要注意這樣生成的 []byte 不可寫,否則行為未定義。

預(yù)計算

線上存在某些服務(wù)有大包傳輸?shù)膱鼍埃@種場景下會引入不小的序列化 / 反序列化開銷。一般大包都是容器類型的大小非常大導致的,如果能夠提前計算出 buffer,一些 O(n) 的操作就能降到 O(1),減少了函數(shù)調(diào)用次數(shù),在大包場景下也大量減少了內(nèi)存分配的次數(shù),帶來的收益是可觀的。

基本類型

如果容器元素為基本類型(bool, byte, i16, i32, i64, double)的話,由于基本類型大小固定,在序列化時是可以提前計算出總的大小,并且一次性分配足夠的 buffer,O(n) 的 malloc 操作次數(shù)可以降到 O(1),從而大量減少了 malloc 的次數(shù),同理在反序列化時可以減少 next 的操作次數(shù)。

struct 字段重排

上面的優(yōu)化只能針對容器元素類型為基本類型的有效,那么對于元素類型為 struct 的是否也能優(yōu)化呢?答案是肯定的。

沿用上面的思路,假如 struct 中如果存在基本類型的 field,也可以預(yù)先計算出這些 field 的大小,在序列化時為這些 field 提前分配 buffer,寫的時候也把這些 field 順序統(tǒng)一放到前面寫,這樣也能在一定程度上減少 malloc 的次數(shù)。

一次性計算

上面提到的是基本類型的優(yōu)化,如果在序列化時,先遍歷一遍 request 所有 field,便可以計算得到整個 request 的大小,提前分配好 buffer,在序列化和反序列時直接操作 buffer,這樣對于非基本類型也能有優(yōu)化效果。

定義新的 codec 接口:

type thriftMsgFastCodec interface {
   BLength() int // count length of whole req/resp
   FastWrite(buf []byte) int
   FastRead(buf []byte) (int, error)
}

在 Marshal 和 Unmarshal 接口中做相應(yīng)改造:

func (c thriftCodec) Marshal(ctx context.Context, message remote.Message, out remote.ByteBuffer) error {
    ...
    if msg, ok := data.(thriftMsgFastCodec); ok {
       msgBeginLen := bthrift.Binary.MessageBeginLength(methodName, thrift.TMessageType(msgType), int32(seqID))
       msgEndLen := bthrift.Binary.MessageEndLength()
       buf, err := out.Malloc(msgBeginLen + msg.BLength() + msgEndLen)// malloc once
       if err != nil {
          return perrors.NewProtocolErrorWithMsg(fmt.Sprintf("thrift marshal, Malloc failed: %s", err.Error()))
       }
       offset := bthrift.Binary.WriteMessageBegin(buf, methodName, thrift.TMessageType(msgType), int32(seqID))
       offset += msg.FastWrite(buf[offset:])
       bthrift.Binary.WriteMessageEnd(buf[offset:])
       return nil
    }
    ...
}

func (c thriftCodec) Unmarshal(ctx context.Context, message remote.Message, in remote.ByteBuffer) error {
    ...
    data := message.Data()
if msg, ok := data.(thriftMsgFastCodec); ok && message.PayloadLen() != 0 {
   msgBeginLen := bthrift.Binary.MessageBeginLength(methodName, msgType, seqID)
   buf, err := tProt.next(message.PayloadLen() - msgBeginLen - bthrift.Binary.MessageEndLength()) // next once
   if err != nil {
      return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())
   }
   _, err = msg.FastRead(buf)
   if err != nil {
      return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())
   }
   err = tProt.ReadMessageEnd()
   if err != nil {
      return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())
   }
   tProt.Recycle()
   return err
   }
   ...
}

生成代碼中也做相應(yīng)改造:

func (p *Demo) BLength() int {
        l := 0
        l += bthrift.Binary.StructBeginLength("Demo")
        if p != nil {
                l += p.field1Length()
                l += p.field2Length()
                l += p.field3Length()
    ...
        }
        l += bthrift.Binary.FieldStopLength()
        l += bthrift.Binary.StructEndLength()
        return l
}

func (p *Demo) FastWrite(buf []byte) int {
        offset := 0
        offset += bthrift.Binary.WriteStructBegin(buf[offset:], "Demo")
        if p != nil {
                offset += p.fastWriteField2(buf[offset:])
                offset += p.fastWriteField4(buf[offset:])
                offset += p.fastWriteField1(buf[offset:])
                offset += p.fastWriteField3(buf[offset:])
        }
        offset += bthrift.Binary.WriteFieldStop(buf[offset:])
        offset += bthrift.Binary.WriteStructEnd(buf[offset:])
        return offset
}

使用 SIMD 優(yōu)化 Thrift 編碼

公司內(nèi)廣泛使用 list<i64/i32> 類型來承載 ID 列表,并且 list<i64/i32> 的編碼方式十分符合向量化的規(guī)律,于是我們用了 SIMD 來優(yōu)化 list<i64/i32> 的編碼過程。

我們使用了 avx2,優(yōu)化后的結(jié)果比較顯著,在大數(shù)據(jù)量下針對 i64 可以提升 6 倍性能,針對 i32 可以提升 12 倍性能;在小數(shù)據(jù)量下提升更明顯,針對 i64 可以提升 10 倍,針對 i32 可以提升 20 倍。

減少函數(shù)調(diào)用

inline

inline 是在編譯期間將一個函數(shù)調(diào)用原地展開,替換成這個函數(shù)的實現(xiàn),它可以減少函數(shù)調(diào)用的開銷以提高程序的性能。

在 Go 中并不是所有函數(shù)都能 inline,使用參數(shù)-gflags="-m"運行進程,可顯示被 inline 的函數(shù)。以下幾種情況無法內(nèi)聯(lián):

  1. 包含循環(huán)的函數(shù);
  2. 包含以下內(nèi)容的函數(shù):閉包調(diào)用,select,for,defer,go 關(guān)鍵字創(chuàng)建的協(xié)程;
  3. 超過一定長度的函數(shù),默認情況下當解析 AST 時,Go 申請了 80 個節(jié)點作為內(nèi)聯(lián)的預(yù)算。每個節(jié)點都會消耗一個預(yù)算。比如,a = a + 1 這行代碼包含了 5 個節(jié)點:AS, NAME, ADD, NAME, LITERAL。當一個函數(shù)的開銷超過了這個預(yù)算,就無法內(nèi)聯(lián)。

編譯時通過指定參數(shù)-l可以指定編譯器對代碼內(nèi)聯(lián)的強度(go 1.9+),不過這里不推薦大家使用,在我們的測試場景下是 buggy 的,無法正常運行:

// The debug['l'] flag controls the aggressiveness. Note that main() swaps level 0 and 1, making 1 the default and -l disable. Additional levels (beyond -l) may be buggy and are not supported.
//      0: disabled
//      1: 80-nodes leaf functions, oneliners, panic, lazy typechecking (default)
//      2: (unassigned)
//      3: (unassigned)
//      4: allow non-leaf functions

內(nèi)聯(lián)雖然可以減少函數(shù)調(diào)用的開銷,但是也可能因為存在重復(fù)代碼,從而導致 CPU 緩存命中率降低,所以并不能盲目追求過度的內(nèi)聯(lián),需要結(jié)合 profile 結(jié)果來具體分析。

go test -gcflags='-m=2' -v -test.run TestNewCodec 2>&1 | grep "function too complex" | wc -l
48

go test -gcflags='-m=2 -l=4' -v -test.run TestNewCodec 2>&1 | grep "function too complex" | wc -l
25

從上面的輸出結(jié)果可以看出,加強內(nèi)聯(lián)程度確實減少了一些"function too complex",看下 benchmark 結(jié)果:

字節(jié)跳動 Go RPC 框架 KiteX 性能優(yōu)化實踐

 

上面開啟最高程度的內(nèi)聯(lián)強度,確實消除了不少因為“function too complex”帶來無法內(nèi)聯(lián)的函數(shù),但是壓測結(jié)果顯示收益不太明顯。

測試結(jié)果

我們構(gòu)建了基準測試來對比優(yōu)化前后的性能,下面是測試結(jié)果。

環(huán)境:Go 1.13.5 darwin/amd64 on a 2.5 GHz Intel Core i7 16GB

小包

data size: 20KB

字節(jié)跳動 Go RPC 框架 KiteX 性能優(yōu)化實踐

 

大包

data size: 6MB

字節(jié)跳動 Go RPC 框架 KiteX 性能優(yōu)化實踐

 

無拷貝序列化

在一些 request 和 response 數(shù)據(jù)較大的服務(wù)中,序列化和反序列化的代價較高,有兩種優(yōu)化思路:

  1. 如前文所述進行序列化和反序列化的優(yōu)化
  2. 以無拷貝序列化的方式進行調(diào)用

調(diào)研

通過無拷貝序列化進行 RPC 調(diào)用,最早出自 Kenton Varda 的 Cap'n Proto 項目,Cap'n Proto 提供了一套數(shù)據(jù)交換格式和對應(yīng)的編解碼庫。

Cap'n Proto 本質(zhì)上是開辟一個 bytes slice 作為 buffer ,所有對數(shù)據(jù)結(jié)構(gòu)的讀寫操作都是直接讀寫 buffer,讀寫完成后,在頭部添加一些 buffer 的信息就可以直接發(fā)送,對端收到后即可讀取,因為沒有 Go 語言結(jié)構(gòu)體作為中間存儲,所有無需序列化這個步驟,反序列化亦然。

簡單總結(jié)下 Cap'n Proto 的特點:

  1. 所有數(shù)據(jù)的讀寫都是在一段連續(xù)內(nèi)存中
  2. 將序列化操作前置,在數(shù)據(jù) Get/Set 的同時進行編解碼
  3. 在數(shù)據(jù)交換格式中,通過 pointer(數(shù)據(jù)存儲位置的 offset)機制,使得數(shù)據(jù)可以存儲在連續(xù)內(nèi)存的任意位置,進而使得結(jié)構(gòu)體中的數(shù)據(jù)可以以任意順序讀寫
    1. 對于結(jié)構(gòu)體的固定大小字段,通過重新排列,使得這些字段存儲在一塊連續(xù)內(nèi)存中
    2. 對于結(jié)構(gòu)體的不定大小字段(如 list),則通過一個固定大小的 pointer 來表示,pointer 中存儲了包括數(shù)據(jù)位置在內(nèi)的一些信息

首先 Cap'n Proto 沒有 Go 語言結(jié)構(gòu)體作為中間載體,得以減少一次拷貝,然后 Cap'n Proto 是在一段連續(xù)內(nèi)存上進行操作,編碼數(shù)據(jù)的讀寫可以一次完成,因為這兩個原因,使得 Cap' Proto 的性能表現(xiàn)優(yōu)秀。

下面是相同數(shù)據(jù)結(jié)構(gòu)下 Thrift 和 Cap'n Proto 的 Benchmark,考慮到 Cap'n Proto 是將編解碼操作前置了,所以對比的是包括數(shù)據(jù)初始化在內(nèi)的完整過程,即結(jié)構(gòu)體數(shù)據(jù)初始化+(序列化)+寫入 buffer +從 buffer 讀出+(反序列化)+從結(jié)構(gòu)體讀出數(shù)據(jù)。

struct MyTest {
    1: i64 Num,
    2: Ano Ano,
    3: list<i64> Nums, // 長度131072 大小1MB
}

struct Ano {
    1: i64 Num,
}
字節(jié)跳動 Go RPC 框架 KiteX 性能優(yōu)化實踐

 

(反序列化)+讀出數(shù)據(jù),視包大小,Cap'n Proto 性能大約是 Thrift 的 8-9 倍。寫入數(shù)據(jù)+(序列化),視包大小,Cap'n Proto 性能大約是 Thrift 的 2-8 倍。整體性能 Cap' Proto 性能大約是 Thrift 的 4-8 倍。

前面說了 Cap'n Proto 的優(yōu)勢,下面總結(jié)一下 Cap'n Proto 存在的一些問題:

  1. Cap'n Proto 的連續(xù)內(nèi)存存儲這一特性帶來的一個問題:當對不定大小數(shù)據(jù)進行 resize ,且需要的空間大于原有空間時,只能在后面重新分配一塊空間,導致原來數(shù)據(jù)的空間成為了一個無法去掉的 hole 。這個問題隨著調(diào)用鏈路的不斷 resize 會越來越嚴重,要解決只能在整個鏈路上嚴格約束:盡量避免對不定大小字段的 resize ,當不得不 resize 的時候,重新構(gòu)建一個結(jié)構(gòu)體并對數(shù)據(jù)進行深拷貝。
  2. Cap'n Proto 因為沒有 Go 語言結(jié)構(gòu)體作為中間載體,使得所有的字段都只能通過接口進行讀寫,用戶體驗較差。

Thrift 協(xié)議兼容的無拷貝序列化

Cap'n Proto 為了更好更高效的支持無拷貝序列化,使用了一套自研的編解碼格式,但在現(xiàn)在 Thrift 和 ProtoBuf 占主流的環(huán)境中難以鋪開。為了能在協(xié)議兼容的同時獲得無拷貝序列化的性能,我們開始了 Thrift 協(xié)議兼容的無拷貝序列化的探索。

Cap'n Proto 作為無拷貝序列化的標桿,那么我們就看看 Cap'n Proto 上的優(yōu)化能否應(yīng)用到 Thrift 上:

  1. 自然是無拷貝序列化的核心,不使用 Go 語言結(jié)構(gòu)體作為中間載體,減少一次拷貝。此優(yōu)化點是協(xié)議無關(guān)的,能夠適用于任何已有的協(xié)議,自然也能和 Thrift 協(xié)議兼容,但是從 Cap'n Proto 的使用上來看,用戶體驗還需要仔細打磨一下。
  2. Cap'n Proto 是在一段連續(xù)內(nèi)存上進行操作,編碼數(shù)據(jù)的讀寫可以一次完成。Cap'n Proto 得以在連續(xù)內(nèi)存上操作的原因:有 pointer 機制,數(shù)據(jù)可以存儲在任意位置,允許字段可以以任意順序?qū)懭攵挥绊懡獯a。但是一方面,在連續(xù)內(nèi)存上容易因為誤操作,導致在 resize 的時候留下 hole,另一方面,Thrift 沒有類似于 pointer 的機制,故而對數(shù)據(jù)布局有著更嚴格的要求。這里有兩個思路:
    1. 堅持在連續(xù)內(nèi)存上進行操作,并對用戶使用提出嚴格要求:1. resize 操作必須重新構(gòu)建數(shù)據(jù)結(jié)構(gòu) 2. 當存在結(jié)構(gòu)體嵌套時,對字段寫入順序有著嚴格要求(可以想象為把一個存在嵌套的結(jié)構(gòu)體從外往里展開,寫入時需要按展開順序?qū)懭耄乙驗?Binary 等 TLV 編碼的關(guān)系,在每個嵌套開始寫入時,需要用戶主動聲明(如 StartWriteFieldX)。
    2. 不完全在連續(xù)內(nèi)存上操作,局部內(nèi)存連續(xù),可變字段則單獨分配一塊內(nèi)存,既然內(nèi)存不是完全連續(xù)的,自然也無法做到一次寫操作便完成輸出。為了盡可能接近一次寫完數(shù)據(jù)的性能,我們采取了一種鏈式 buffer 的方案,一方面當可變字段 resize 時只需替換鏈式 buffer 的一個節(jié)點,無需像 Cap'n Proto 一樣重新構(gòu)建結(jié)構(gòu)體,另一方面在需要輸出時無需像 Thrift 一樣需要感知實際的結(jié)構(gòu),只要把整個鏈路上的 buffer 寫入即可。

先總結(jié)下目前確定的兩個點:1. 不使用 Go 語言結(jié)構(gòu)體作為中間載體,通過接口直接操作底層內(nèi)存,在 Get/Set 時完成編解碼 2. 通過鏈式 buffer 存儲數(shù)據(jù)

然后讓我們看下目前還有待解決的問題:

  1. 不使用 Go 語言結(jié)構(gòu)體后帶來的用戶體驗劣化
    1. 解決方案:改善 Get/Set 接口的使用體驗,盡可能做到和 Go 語言結(jié)構(gòu)體同等的易用
  2. Cap'n Proto 的 Binary Format 是針對無拷貝序列化場景專門設(shè)計的,雖然每次 Get 時都會進行一次解碼,但是解碼代價非常小。而 Thrift 的協(xié)議(以 Binary 為例),沒有類似于 pointer 的機制,當存在多個不定大小字段或者存在嵌套時,必須順序解析而無法直接通過計算偏移拿到字段數(shù)據(jù)所在的位置,而每次 Get 都進行順序解析的代價過于高昂。
    1. 解決方案:我們在表示結(jié)構(gòu)體的時候,除了記錄結(jié)構(gòu)體的 buffer 節(jié)點,還加了一個索引,里面記錄了每個不定大小字段開始的 buffer 節(jié)點的指針。

下面是目前的無拷貝序列化方案與 FastRead/Write,在 4 核下的極限性能對比測試:

字節(jié)跳動 Go RPC 框架 KiteX 性能優(yōu)化實踐

 

測試結(jié)果概述:

  1. 小包場景,無序列化性能表現(xiàn)較差,約為 FastWrite/FastRead 的 85%。
  2. 大包場景,無序列化性能表現(xiàn)較好,4K 以上的包較 FastWrite/FastRead 提升 7%-40%。

后記

希望以上的分享能夠?qū)ι鐓^(qū)有所幫助。同時,我們也在嘗試 share memory-based IPC、io_uring、tcp zero copy 、RDMA 等,更好地提升 KiteX 性能;重點優(yōu)化同機、同容器的通訊場景。歡迎各位感興趣的同學加入我們,共同建設(shè) Go 語言生態(tài)!

參考資料

  1. https://github.com/alecthomas/go_serialization_benchmarks
  2. https://capnproto.org/
  3. https://software.intel.com/content/www/us/en/develop/documentation/cpp-compiler-developer-guide-and-reference/top/compiler-reference/intrinsics/intrinsics-for-intel-advanced-vector-extensions-2/intrinsics-for-shuffle-operations-1/mm256-shuffle-epi8.html

字節(jié)跳動基礎(chǔ)架構(gòu)團隊

字節(jié)跳動基礎(chǔ)架構(gòu)團隊是支撐字節(jié)跳動旗下包括抖音、今日頭條、西瓜視頻、火山小視頻在內(nèi)的多款億級規(guī)模用戶產(chǎn)品平穩(wěn)運行的重要團隊,為字節(jié)跳動及旗下業(yè)務(wù)的快速穩(wěn)定發(fā)展提供了保證和推動力。

公司內(nèi),基礎(chǔ)架構(gòu)團隊主要負責字節(jié)跳動私有云建設(shè),管理數(shù)以萬計服務(wù)器規(guī)模的集群,負責數(shù)萬臺計算/存儲混合部署和在線/離線混合部署,支持若干 EB 海量數(shù)據(jù)的穩(wěn)定存儲。

文化上,團隊積極擁抱開源和創(chuàng)新的軟硬件架構(gòu)。我們長期招聘基礎(chǔ)架構(gòu)方向的同學,具體可參見 job.bytedance.com (文末“閱讀原文”),感興趣可以聯(lián)系郵箱: tech@bytedance.com ,郵件標題: 姓名 - 工作年限 - 基礎(chǔ)架構(gòu) 。


歡迎關(guān)注「 字節(jié)跳動技術(shù)團隊 」

分享到:
標簽:框架 RPC
用戶無頭像

網(wǎng)友整理

注冊時間:

網(wǎng)站:5 個   小程序:0 個  文章:12 篇

  • 51998

    網(wǎng)站

  • 12

    小程序

  • 1030137

    文章

  • 747

    會員

趕快注冊賬號,推廣您的網(wǎng)站吧!
最新入駐小程序

數(shù)獨大挑戰(zhàn)2018-06-03

數(shù)獨一種數(shù)學游戲,玩家需要根據(jù)9

答題星2018-06-03

您可以通過答題星輕松地創(chuàng)建試卷

全階人生考試2018-06-03

各種考試題,題庫,初中,高中,大學四六

運動步數(shù)有氧達人2018-06-03

記錄運動步數(shù),積累氧氣值。還可偷

每日養(yǎng)生app2018-06-03

每日養(yǎng)生,天天健康

體育訓練成績評定2018-06-03

通用課目體育訓練成績評定