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
繪製而成。其中發佈者正在不斷寫入數據,訂閱者緊隨其後不斷讀取數據。
由於發佈者以及訂閱者不在同一個協程中,訪問同一個塊內存很有可能引起併發讀寫的問題。如何解決併發讀寫呢?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() {
}
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
方法,這個方法可以多次調用而無副作用(不像WaitGroup
的Done
方法)。也可以減少僞自旋鎖帶來的輕微延遲。
實際測試中使用Cond比僞自旋鎖大概可以節省10%左右的CPU消耗
協議轉換中的內存複用
協議轉換可以用下面的邏輯來實現:
實際情況比這個要複雜一些。所以這裏面第一步需要引入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
+ AuHeader
xN + Au
xN )
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 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%
以上。下圖測試10000
路rtmp
推流的對比:m7s
內存佔用較高一些,原因就是採用了內存池來減少GC
造成的。使用內存來換CPU,在這種場景下還是值得的。
流媒體服務器 | 10000路推流CPU消耗 |
monibuca | 90%~100% |
zlm | 90%~100% |
srs | 80%~90% |
lal | 160%~200% |
由於livego的推流需要先調用一次HTTP獲取密鑰,所以無法使用壓測工具批量推流,本次對比無法參與。
所有流媒體服務器配置均關閉了協議轉換的開關,並以Release方式編譯。服務器也去除了所有限制,並以完全相同的操作方式進行壓測。