Monibuca 中的內存複用
Go語言本身具備出色的性能,然而在流媒體服務器這種CPU密集+IO密集的雙重壓力下,GC帶來的性能損失是最主要的矛盾。而減少GC的操作最直接的辦法就是減少內存申請,多多複用內存。本文將圍繞內存複用這個主題,把M7S中相關技術原理講解一遍,也是M7S性能優化的歷程。
讀寫內存共享
在早期我在研究過許多流媒體服務器的數據轉發模式,基本都是在發送給訂閱者時將內存複製一份的方式實現讀寫分離,雖然沒有併發問題,但是內存頻繁的申請和複製比較消耗資源。
在網友的啓發下,從v2版本開始,採用了基於RingBuffer的內存共享讀寫方式。大大減少了內存複製。
下面的視頻是當時開發的一個UI,實時獲取RingBuffer的信息用SVG繪製而成。其中發佈者正在不斷寫入數據,訂閱者緊隨其後不斷讀取數據。
由於發佈者以及訂閱者不在同一個協程中,訪問同一個塊內存很有可能引起併發讀寫的問題。如何解決併發讀寫呢?M7S經過不斷的迭代在這塊上面實踐了各種方法。既要考慮到性能,還要考慮到代碼的可讀性和可維護性。
sync.RWMutex
這是最容易想到的,在M7S v2中就採用了讀寫鎖。操作步驟如下:
先鎖住Ring中的下一個待寫入單元,再將本次寫完的單元釋放寫鎖。
在本讀寫單元中等待讀取的訂閱者在寫鎖釋放的同時獲取到讀鎖,開始讀取數據
有點類似人走路的方式,前腳着地後,後腳再離地。可以保證訂閱者無法跑到發佈者前面。
優點是可讀性很強,一眼就能看懂這個原理。
缺點是, 鎖的開 銷比 較大,性能損失 很明顯。
還有一個缺點,就是當訂閱者阻塞,會導致發佈者追上訂閱者,寫鎖無法獲取從而阻塞整個流。(後來Go出了TryLock)
WaitGroup
v3中採用了這個,但是WaitGroup的Wait操作是一個無限阻塞的操作,必須用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() {
在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方法)。也可以減少僞自旋鎖帶來的輕微延遲。
協議轉換可以用下面的邏輯來實現:
實際情況比這個要複雜一些。所以這裡面第一步需要引入go標準庫中的net.Buffers來表示“連續的內存”(實際並不一定連續)。當收到一個協議傳來的數據時儘量保留,而不去複製它。
同一個協議轉發
對於相同的協議,能複用的內存更多一些,舉個例子:
RTMP轉發到RTMP
RTMP中傳輸視頻幀的格式爲AVCC格式,這也是能複用的部分,在實際傳輸過程中這部分內存並非一個連續內存。RTMP有chunk機制,會把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格式統一處理了,FLV的tag頭無法複用,涉及到時間戳需要重新生成。
不同協議轉發
不同協議之間轉發由於兩兩排列組合很多,因此需要抽象出大類來處理。
協議分類 RTMP、FLV、MP4
該類協議視頻是AVCC格式,音頻是裸格式(RTMP包含一到兩個字節的頭)
RTSP、WebRTC
該類的視頻是RTP(Header+裸NALU) 音頻是RTP(Header +AuHeaderLen+AuHeaderxN +AuxN )
HLS、GB28181
這類使用的MPEG2-TS、MPEG2-PS作爲傳輸協議 視頻採用Header+AnnexB音頻採用Header+ADTS+AAC
內存複用
總體而言,視頻格式都是前綴+NALU這種方式,AnnexB的前綴是00 00 00 01,而Avcc的前綴是CTS、NALU長度等,因此將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 inttype ListItem[T any] struct {Value TNext, Pre *ListItem[T] `json:"-" yaml:"-"`Pool *List[T] `json:"-" yaml:"-"` // 回收池list *List[T]type BytesPool []List[Buffer]回收內存
當RingBuffer中的訪問單元被覆蓋時,就可以將其中所有的內存對象進行放回Pool。由此實現了從內存使用的閉環,消除了GC。下圖中紅色箭頭代表內存複用機制,可以有效避免申請內存操作。
後記
經過上面三板斧的優化後,整體性能提升了50%以上。下圖測試10000路rtmp推流的對比:m7s內存佔用較高一些,原因就是採用了內存池來減少GC造成的。使用內存來換CPU,在這種場景下還是值得的。
流媒體服務器10000路推流CPU消耗monibuca90%~100%zlm90%~100% srs80%~90%lal160%~200%
由於livego的推流需要先調用一次HTTP獲取密鑰,所以無法使用壓測工具批量推流,本次對比無法參與。
所有流媒體服務器配置均關閉了協議轉換的開關,並以Release方式編譯。服務器也去除了所有限制,並以完全相同的操作方式進行壓測。