Monibuca 中的內存複用

Go語言本身具備出色的性能,然而在流媒體服務器這種CPU密集+IO密集的雙重壓力下,GC帶來的性能損失是最主要的矛盾。而減少GC的操作最直接的辦法就是減少內存申請,多多複用內存。本文將圍繞內存複用這個主題,把M7S中相關技術原理講解一遍,也是M7S性能優化的歷程。

讀寫內存共享

在早期我在研究過許多流媒體服務器的數據轉發模式,基本都是在發送給訂閱者時將內存複製一份的方式實現讀寫分離,雖然沒有併發問題,但是內存頻繁的申請和複製比較消耗資源。

在M7S v1版本中,也沿用了傳統的方式。然而Go語言由於採用GC的方式管理內存,導致頻繁申請內存會加大GC的壓力。

在網友的啓發下,從v2版本開始,採用了基於RingBuffer的內存共享讀寫方式。大大減少了內存複製。

Monibuca中每一個流(Stream)對象包含多個Track(分爲音視頻Track和DataTrack)每個Track包含一個RingBuffer發佈者將數據填入這個RingBuffer中,訂閱者則從RingBuffer中讀取數據再封裝到協議中發送出去,形成轉發的核心邏輯。

下面的視頻是當時開發的一個UI,實時獲取RingBuffer的信息用SVG繪製而成。其中發佈者正在不斷寫入數據,訂閱者緊隨其後不斷讀取數據。

RingBuffer演示 - 知乎 (zhihu.com)

由於發佈者以及訂閱者不在同一個協程中,訪問同一個塊內存很有可能引起併發讀寫的問題。如何解決併發讀寫呢?M7S 經過不斷的迭代在這塊上面實踐了各種方法。既要考慮到性能,還要考慮到代碼的可讀性可維護性。

sync.RWMutex

這是最容易想到的,在M7S v2中就採用了讀寫鎖。操作步驟如下:

  • 先鎖住Ring中的下一個待寫入單元,再將本次寫完的單元釋放寫鎖。
  • 在本讀寫單元中等待讀取的訂閱者在寫鎖釋放的同時獲取到讀鎖,開始讀取數據

有點類似人走路的方式,前腳着地後,後腳再離地。可以保證訂閱者無法跑到發佈者前面。

優點是可讀性很強,一眼就能看懂這個原理。 缺點是, 鎖的開 銷比 較大,性能損失 很明顯 還有一個缺點,就是當訂閱者阻塞,會導致發佈者追上訂閱者,寫鎖無法獲取從而阻塞整個流。(後來Go出了TryLock)

WaitGroup

v3 中採用了這個,但是WaitGroupWait操作是一個無限阻塞的操作,必須用Done操作才能結束等待,此時就會有一個問題,engine和發佈者有可能會同時去調用Done完成釋放(具體原因另開章節介紹)。因此Done就會多調用一次導致panic。後來通過複雜的原子操作解決了(但是大大降低了代碼的可讀性)。

time.Sleep

v4 中採用了僞自旋鎖,所謂的僞自旋鎖,就是模仿自旋鎖的機制,只是用time.Sleep代替了,runtime.Gosched,減少了自旋次數,從而提高性能。

for r.Frame = &r.Value; r.ctx.Err() == nil && !r.Frame.CanRead; r.Frame.wait() {
}

CanRead不需要原子操作,有人擔心可能會有併發讀寫問題,其原理同前面說的人走路是一樣的,即便出現了併發讀寫,也不影響邏輯正確運行。最多就是多等待一個週期,稍微增加一點點延遲。

sync.Cond

v1版本中由於使用的是簡單的內存複製,於是有人給了這個方案,但是我卻一直繞了一大圈,最後回到這個方案上了,也算是自作聰明。sync.Cond之所以一開始沒有選擇,是因爲裏面包含了一個鎖(標準庫內部強制調用了鎖)

func (c *Cond) Wait() {
 c.checker.check()
 t := runtime_notifyListAdd(&c.notify)
 c.L.Unlock()
 runtime_notifyListWait(&c.notify, t)
 c.L.Lock()
}

所以就認爲性能不高,直到繞了一大圈之後,才找到一個避免鎖的方案。當然這些彎路可能必須要走,因爲直到自己寫了僞自旋鎖,才增加了一個是否可讀的屬性,也就是說有了這個屬性後,我們其實只需要一個喚醒的功能即可,於是想到了給sync.Cond提供一個空的鎖對象的方式避免了鎖:

type emptyLocker struct{}

func (emptyLocker) Lock()   {}
func (emptyLocker) Unlock() {}

var EmptyLocker emptyLocker

sync.Cond在喚醒協程的時候使用的是Broadcast方法,這個方法可以多次調用而無副作用(不像WaitGroupDone方法)。也可以減少僞自旋鎖帶來的輕微延遲。

 

實際測試中使用Cond比僞自旋鎖大概可以節省10%左右的CPU消耗

協議轉換中的內存複用

協議轉換可以用下面的邏輯來實現:

實際情況比這個要複雜一些。所以這裏面第一步需要引入go標準庫中的net.Buffers來表示“連續的內存”(實際並不一定連續)。當收到一個協議傳來的數據時儘量保留,而不去複製它。

同一個協議轉發

對於相同的協議,能複用的內存更多一些,舉個例子:

RTMP轉發到RTMP

RTMP中傳輸視頻幀的格式爲AVCC格式,這也是能複用的部分,在實際傳輸過程中這部分內存並非一個連續內存。RTMPchunk機制,會把AVCC切割一塊塊傳輸,並加上chunk header

chunk header | avcc part1 | chunk header | avcc part2 ······

這個分割的大小默認是128字節,通常RTMP協議會經過協商修改這個大小,因此傳入和傳出的分塊大小不一定相同。那如何複用AVCC的數據呢?此時我們需要用到net.Buffers來表示一幀AVCC數據。

| avcc part1 | avcc part2 ······

當我們需要另一種分塊大小的數據時,可以對原始數據再分割。比如說原始數據是256字節分塊的:

| 256Bytes | 256Bytes ······

而新的分塊要求是128Bytes的

| 128Bytes | 128Bytes | 128Bytes | 128Bytes ······

我們並沒有申請新的內存,只是多了一些切片。那有人就可能會問了,如果不是正好倍數關係呢?其實無非就是多切幾塊。比如新的分塊要求是200Bytes:

| 200Bytes | 56Bytes| 144Bytes | 112Byts | 88Bytes ······

用下面的圖更加直觀:

這樣發送的時候,並不是一個連續內存,那如何發送呢?這裏就用到了writev(windows對應的是WSASend)技術。在Go語言中通過net.Buffers類型寫入數據會自動判斷使用的技術。

RTSP轉發到RTSP

RTSP協議傳輸的媒體數據是RTP包,RTP包在理想狀態下,可以完全複用,就是直接把RTP包緩存起來,等需要發送的時候直接把這個RTP數據原封不動的發出去。在m7s中,由於需要有跳幀追幀的邏輯,所以需要修改時間戳,就無法原封不動的發送RTP包,但是也可以複用其中的Payload部分。

HLS轉發到HLS

在純轉發模式下,可以直接將TS切片緩存,完全複用。如果需要將HLS轉換成其他協議,則需要將TS格式數據進行解包處理。

FLV轉發到FLV

FLV格式由於數據格式也是avcc格式,因此處理邏輯就按照avcc格式統一處理了,FLVtag頭無法複用,涉及到時間戳需要重新生成。

不同協議轉發

不同協議之間轉發由於兩兩排列組合很多,因此需要抽象出大類來處理。

協議分類

RTMP、FLV、MP4

該類協議視頻是AVCC格式,音頻是裸格式(RTMP包含一到兩個字節的頭)

RTSP、WebRTC

該類的視頻是RTP(Header+裸NALU) 音頻是RTP(Header + AuHeaderLen + AuHeaderxN + AuxN )

HLS、GB28181

這類使用的MPEG2-TSMPEG2-PS作爲傳輸協議 視頻採用Header+AnnexB音頻採用Header+ADTS+AAC

內存複用

總體而言,視頻格式都是前綴+NALU這種方式,AnnexB的前綴是00 00 00 01,而Avcc的前綴是 CTSNALU長度等,因此將NALU緩存起來就可以複用NALU數據。在實際實現中,爲了方便同類型的協議轉換,會同時緩存Avcc格式、RTP格式、以及裸格式,而這三種格式的NALU部分都共用一組內存(內存不連續)

減少發佈者的GC

GC的產生

對於一個發佈者,即需要不斷從網絡或是本地文件中讀取數據的對象,在不做任何優化的情況下,都會不停的申請內存。例如使用io.ReadAll這種操作,內部會頻繁的申請內存。頻繁申請內存的結果就是GC壓力很大,尤其是高併發的時候,GC帶來的消耗可以達到50%CPU消耗。

sync.Pool

當然我最先想到的一定是使用內存池,也就是sync.Pool來管理需要使用的內存,但是sync.Pool有個缺陷,就是爲了協程安全內部有鎖。儘管使用了多級緩存等一些列優化手段,最終使用的時候也會消耗一定的性能(經過實測性能開銷很大)。而且sync.Pool比較通用,並不是針對特定的對象使用,我們這裏是針對[]byte類型進行復用。

自定義Pool

如果Pool不含有鎖,性能會大幅提升,那如何解決協程安全呢?答案是協程不安全,即我們只在一個協程裏面去操作Pool的取出和放回。通常情況下一個發佈者的寫入是在同一個協程中的,比如rtmp協議。少數協議如rtsp可能會有多個協程寫入數據,因此最後我們是每一個Track一個Pool,保持一個Track一個協程寫入。

下圖表示的是自定義Pool的結構:

每個Pool是一個數組,數組的每一個元素是一個鏈表,鏈表的每一個元素是一個包含[]byte的類型,大小是2的數組下標次冪。

0號元素有特殊用途,由於我們需要記錄每一塊內存所屬的鏈表來回收,因此需要有一個外殼,而外殼(ListItem)也是需要回收的。而0號元素是存放的只有外殼需要回收而無需回收Value(需要GC的對象)的鏈表。

 

type List[T any] struct {
 ListItem[T]
 Length int
}
type ListItem[T any] struct {
 Value     T
 Next, Pre *ListItem[T] `json:"-" yaml:"-"`
 Pool      *List[T]     `json:"-" yaml:"-"` // 回收池
 list      *List[T]
}
type BytesPool []List[Buffer]

回收內存

RingBuffer中的訪問單元被覆蓋時,就可以將其中所有的內存對象進行放回Pool。由此實現了從內存使用的閉環,消除了GC。下圖中紅色箭頭代表內存複用機制,可以有效避免申請內存操作。

後記

經過上面三板斧的優化後,整體性能提升了50%以上。下圖測試10000rtmp推流的對比:m7s內存佔用較高一些,原因就是採用了內存池來減少GC造成的。使用內存來換CPU,在這種場景下還是值得的。

 

 

 

 

流媒體服務器 10000路推流CPU消耗
monibuca 90%~100%
zlm 90%~100%
srs 80%~90%
lal 160%~200%

由於livego的推流需要先調用一次HTTP獲取密鑰,所以無法使用壓測工具批量推流,本次對比無法參與。
所有流媒體服務器配置均關閉了協議轉換的開關,並以Release方式編譯。服務器也去除了所有限制,並以完全相同的操作方式進行壓測。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章