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
這是最容易想到的,在M7Sv2
中就采用了讀寫鎖。操作步驟如下:
- 先鎖住Ring中的下一個待寫入單元,再將本次寫完的單元釋放寫鎖。
- 在本讀寫單元中等待讀取的訂閱者在寫鎖釋放的同時獲取到讀鎖,開始讀取數據
優點是可讀性很強,一眼就能看懂這個原理。缺點是,鎖的開銷比較大,性能損失很明顯。還有一個缺點,就是當訂閱者阻塞,會導致發布者追上訂閱者,寫鎖無法獲取從而阻塞整個流。(后來Go出了TryLock)
WaitGroup
v3
中采用了這個,但是WaitGroup
的Wait
操作是一個無限阻塞的操作,必須用Done
操作才能結束等待,此時就會有一個問題,engine
和發布者有可能會同時去調用Done
完成釋放(具體原因另開章節介紹)。因此Done
就會多調用一次導致panic
。后來通過復雜的原子操作解決了(但是大大降低了代碼的可讀性)。
time.Sleep
v4
中采用了偽自旋鎖,所謂的偽自旋鎖,就是模仿自旋鎖的機制,只是用time.Sleep
代替了,runtime.Gosched
,減少了自旋次數,從而提高性能。
forr.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
提供一個空的鎖對象的方式避免了鎖:
typeemptyLockerstruct{}
func(emptyLocker)Lock(){}
func(emptyLocker)Unlock(){}
varEmptyLockeremptyLocker
sync.Cond
在喚醒協程的時候使用的是Broadcast
方法,這個方法可以多次調用而無副作用(不像WaitGroup
的Done
方法)。也可以減少偽自旋鎖帶來的輕微延遲。
實際測試中使用Cond比偽自旋鎖大概可以節省10%左右的CPU消耗
協議轉換中的內存復用
協議轉換可以用下面的邏輯來實現:
實際情況比這個要復雜一些。所以這里面第一步需要引入go標準庫中的net.Buffers來表示“連續的內存”(實際并不一定連續)。當收到一個協議傳來的數據時盡量保留,而不去復制它。
同一個協議轉發
對于相同的協議,能復用的內存更多一些,舉個例子:
RTMP轉發到RTMP
RTMP
中傳輸視頻幀的格式為AVCC
格式,這也是能復用的部分,在實際傳輸過程中這部分內存并非一個連續內存。RTMP
有chunk
機制,會把AVCC
切割一塊塊傳輸,并加上chunk header
。
chunkheader|avccpart1|chunkheader|avccpart2······
這個分割的大小默認是128字節,通常RTMP
協議會經過協商修改這個大小,因此傳入和傳出的分塊大小不一定相同。那如何復用AVCC
的數據呢?此時我們需要用到net.Buffers
來表示一幀AVCC
數據。
|avccpart1|avccpart2······
當我們需要另一種分塊大小的數據時,可以對原始數據再分割。比如說原始數據是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
的對象)的鏈表。
typeList[Tany]struct{
ListItem[T]
Lengthint
}
typeListItem[Tany]struct{
ValueT
Next,Pre*ListItem[T]`json:"-"yaml:"-"`
Pool*List[T]`json:"-"yaml:"-"`//回收池
list*List[T]
}
typeBytesPool[]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方式編譯。服務器也去除了所有限制,并以完全相同的操作方式進行壓測。
-
服務器
+關注
關注
12文章
9234瀏覽量
85638 -
架構設計
+關注
關注
0文章
31瀏覽量
6961 -
go語言
+關注
關注
1文章
158瀏覽量
9054
原文標題:方法)。也可以減少偽自旋鎖帶來的輕微延遲。
文章出處:【微信號:OSC開源社區,微信公眾號:OSC開源社區】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論