Go
語言本身具備出色的性能,然而在流媒體服務(wù)器這種CPU
密集+IO
密集的雙重壓力下,GC
帶來的性能損失是最主要的矛盾。而減少GC
的操作最直接的辦法就是減少內(nèi)存申請(qǐng),多多復(fù)用內(nèi)存。本文將圍繞內(nèi)存復(fù)用這個(gè)主題,把M7S
中相關(guān)技術(shù)原理講解一遍,也是M7S
性能優(yōu)化的歷程。
讀寫內(nèi)存共享
在早期我在研究過許多流媒體服務(wù)器的數(shù)據(jù)轉(zhuǎn)發(fā)模式,基本都是在發(fā)送給訂閱者時(shí)將內(nèi)存復(fù)制一份的方式實(shí)現(xiàn)讀寫分離,雖然沒有并發(fā)問題,但是內(nèi)存頻繁的申請(qǐng)和復(fù)制比較消耗資源。
在M7S v1版本中,也沿用了傳統(tǒng)的方式。然而Go語言由于采用GC的方式管理內(nèi)存,導(dǎo)致頻繁申請(qǐng)內(nèi)存會(huì)加大GC的壓力。
在網(wǎng)友的啟發(fā)下,從v2
版本開始,采用了基于RingBuffer
的內(nèi)存共享讀寫方式。大大減少了內(nèi)存復(fù)制。
在
Monibuca
中每一個(gè)流(Stream
)對(duì)象包含多個(gè)Track
(分為音視頻Track和DataTrack)每個(gè)Track
包含一個(gè)RingBuffer
。發(fā)布者將數(shù)據(jù)填入這個(gè)RingBuffer
中,訂閱者則從RingBuffer
中讀取數(shù)據(jù)再封裝到協(xié)議中發(fā)送出去,形成轉(zhuǎn)發(fā)的核心邏輯。
下面的視頻是當(dāng)時(shí)開發(fā)的一個(gè)UI
,實(shí)時(shí)獲取RingBuffer
的信息用SVG
繪制而成。其中發(fā)布者正在不斷寫入數(shù)據(jù),訂閱者緊隨其后不斷讀取數(shù)據(jù)。
由于發(fā)布者以及訂閱者不在同一個(gè)協(xié)程中,訪問同一個(gè)塊內(nèi)存很有可能引起并發(fā)讀寫的問題。如何解決并發(fā)讀寫呢?M7S
經(jīng)過不斷的迭代在這塊上面實(shí)踐了各種方法。既要考慮到性能,還要考慮到代碼的可讀性和可維護(hù)性。
sync.RWMutex
這是最容易想到的,在M7Sv2
中就采用了讀寫鎖。操作步驟如下:
- 先鎖住Ring中的下一個(gè)待寫入單元,再將本次寫完的單元釋放寫鎖。
- 在本讀寫單元中等待讀取的訂閱者在寫鎖釋放的同時(shí)獲取到讀鎖,開始讀取數(shù)據(jù)
優(yōu)點(diǎn)是可讀性很強(qiáng),一眼就能看懂這個(gè)原理。缺點(diǎn)是,鎖的開銷比較大,性能損失很明顯。還有一個(gè)缺點(diǎn),就是當(dāng)訂閱者阻塞,會(huì)導(dǎo)致發(fā)布者追上訂閱者,寫鎖無法獲取從而阻塞整個(gè)流。(后來Go出了TryLock)
WaitGroup
v3
中采用了這個(gè),但是WaitGroup
的Wait
操作是一個(gè)無限阻塞的操作,必須用Done
操作才能結(jié)束等待,此時(shí)就會(huì)有一個(gè)問題,engine
和發(fā)布者有可能會(huì)同時(shí)去調(diào)用Done
完成釋放(具體原因另開章節(jié)介紹)。因此Done
就會(huì)多調(diào)用一次導(dǎo)致panic
。后來通過復(fù)雜的原子操作解決了(但是大大降低了代碼的可讀性)。
time.Sleep
v4
中采用了偽自旋鎖,所謂的偽自旋鎖,就是模仿自旋鎖的機(jī)制,只是用time.Sleep
代替了,runtime.Gosched
,減少了自旋次數(shù),從而提高性能。
forr.Frame=&r.Value;r.ctx.Err()==nil&&!r.Frame.CanRead;r.Frame.wait(){
}
CanRead不需要原子操作,有人擔(dān)心可能會(huì)有并發(fā)讀寫問題,其原理同前面說的人走路是一樣的,即便出現(xiàn)了并發(fā)讀寫,也不影響邏輯正確運(yùn)行。最多就是多等待一個(gè)周期,稍微增加一點(diǎn)點(diǎn)延遲。
sync.Cond
在v1
版本中由于使用的是簡單的內(nèi)存復(fù)制,于是有人給了這個(gè)方案,但是我卻一直繞了一大圈,最后回到這個(gè)方案上了,也算是自作聰明。sync.Cond
之所以一開始沒有選擇,是因?yàn)槔锩姘艘粋€(gè)鎖(標(biāo)準(zhǔn)庫內(nèi)部強(qiáng)制調(diào)用了鎖)
func(c*Cond)Wait(){
c.checker.check()
t:=runtime_notifyListAdd(&c.notify)
c.L.Unlock()
runtime_notifyListWait(&c.notify,t)
c.L.Lock()
}
所以就認(rèn)為性能不高,直到繞了一大圈之后,才找到一個(gè)避免鎖的方案。當(dāng)然這些彎路可能必須要走,因?yàn)橹钡阶约簩懥藗巫孕i,才增加了一個(gè)是否可讀的屬性,也就是說有了這個(gè)屬性后,我們其實(shí)只需要一個(gè)喚醒的功能即可,于是想到了給sync.Cond
提供一個(gè)空的鎖對(duì)象的方式避免了鎖:
typeemptyLockerstruct{}
func(emptyLocker)Lock(){}
func(emptyLocker)Unlock(){}
varEmptyLockeremptyLocker
sync.Cond
在喚醒協(xié)程的時(shí)候使用的是Broadcast
方法,這個(gè)方法可以多次調(diào)用而無副作用(不像WaitGroup
的Done
方法)。也可以減少偽自旋鎖帶來的輕微延遲。
實(shí)際測(cè)試中使用Cond比偽自旋鎖大概可以節(jié)省10%左右的CPU消耗
協(xié)議轉(zhuǎn)換中的內(nèi)存復(fù)用
協(xié)議轉(zhuǎn)換可以用下面的邏輯來實(shí)現(xiàn):
實(shí)際情況比這個(gè)要復(fù)雜一些。所以這里面第一步需要引入go標(biāo)準(zhǔn)庫中的net.Buffers來表示“連續(xù)的內(nèi)存”(實(shí)際并不一定連續(xù))。當(dāng)收到一個(gè)協(xié)議傳來的數(shù)據(jù)時(shí)盡量保留,而不去復(fù)制它。
同一個(gè)協(xié)議轉(zhuǎn)發(fā)
對(duì)于相同的協(xié)議,能復(fù)用的內(nèi)存更多一些,舉個(gè)例子:
RTMP轉(zhuǎn)發(fā)到RTMP
RTMP
中傳輸視頻幀的格式為AVCC
格式,這也是能復(fù)用的部分,在實(shí)際傳輸過程中這部分內(nèi)存并非一個(gè)連續(xù)內(nèi)存。RTMP
有chunk
機(jī)制,會(huì)把AVCC
切割一塊塊傳輸,并加上chunk header
。
chunkheader|avccpart1|chunkheader|avccpart2······
這個(gè)分割的大小默認(rèn)是128字節(jié),通常RTMP
協(xié)議會(huì)經(jīng)過協(xié)商修改這個(gè)大小,因此傳入和傳出的分塊大小不一定相同。那如何復(fù)用AVCC
的數(shù)據(jù)呢?此時(shí)我們需要用到net.Buffers
來表示一幀AVCC
數(shù)據(jù)。
|avccpart1|avccpart2······
當(dāng)我們需要另一種分塊大小的數(shù)據(jù)時(shí),可以對(duì)原始數(shù)據(jù)再分割。比如說原始數(shù)據(jù)是256字節(jié)分塊的:
|256Bytes|256Bytes······
而新的分塊要求是128Bytes的
|128Bytes|128Bytes|128Bytes|128Bytes······
我們并沒有申請(qǐng)新的內(nèi)存,只是多了一些切片。那有人就可能會(huì)問了,如果不是正好倍數(shù)關(guān)系呢?其實(shí)無非就是多切幾塊。比如新的分塊要求是200Bytes:
|200Bytes|56Bytes|144Bytes|112Byts|88Bytes······
用下面的圖更加直觀:
這樣發(fā)送的時(shí)候,并不是一個(gè)連續(xù)內(nèi)存,那如何發(fā)送呢?這里就用到了writev
(windows對(duì)應(yīng)的是WSASend
)技術(shù)。在Go語言中通過net.Buffers類型寫入數(shù)據(jù)會(huì)自動(dòng)判斷使用的技術(shù)。
RTSP轉(zhuǎn)發(fā)到RTSP
RTSP
協(xié)議傳輸?shù)拿襟w數(shù)據(jù)是RTP
包,RTP
包在理想狀態(tài)下,可以完全復(fù)用,就是直接把RTP
包緩存起來,等需要發(fā)送的時(shí)候直接把這個(gè)RTP
數(shù)據(jù)原封不動(dòng)的發(fā)出去。在m7s
中,由于需要有跳幀追幀的邏輯,所以需要修改時(shí)間戳,就無法原封不動(dòng)的發(fā)送RTP
包,但是也可以復(fù)用其中的Payload
部分。
HLS轉(zhuǎn)發(fā)到HLS
在純轉(zhuǎn)發(fā)模式下,可以直接將TS
切片緩存,完全復(fù)用。如果需要將HLS
轉(zhuǎn)換成其他協(xié)議,則需要將TS
格式數(shù)據(jù)進(jìn)行解包處理。
FLV轉(zhuǎn)發(fā)到FLV
FLV
格式由于數(shù)據(jù)格式也是avcc
格式,因此處理邏輯就按照avcc
格式統(tǒng)一處理了,FLV
的tag
頭無法復(fù)用,涉及到時(shí)間戳需要重新生成。
不同協(xié)議轉(zhuǎn)發(fā)
不同協(xié)議之間轉(zhuǎn)發(fā)由于兩兩排列組合很多,因此需要抽象出大類來處理。
協(xié)議分類
RTMP、FLV、MP4
該類協(xié)議視頻是AVCC
格式,音頻是裸格式(RTMP
包含一到兩個(gè)字節(jié)的頭)
RTSP、WebRTC
該類的視頻是RTP
(Header+裸NALU
)音頻是RTP
(Header + AuHeaderLen
+ AuHeader
xN + Au
xN )
HLS、GB28181
這類使用的MPEG2-TS
、MPEG2-PS
作為傳輸協(xié)議視頻采用Header+AnnexB
音頻采用Header+ADTS
+AAC
內(nèi)存復(fù)用
總體而言,視頻格式都是前綴+NALU
這種方式,AnnexB
的前綴是00 00 00 01
,而Avcc的前綴是 CTS
、 NALU
長度等,因此將NALU
緩存起來就可以復(fù)用NALU
數(shù)據(jù)。在實(shí)際實(shí)現(xiàn)中,為了方便同類型的協(xié)議轉(zhuǎn)換,會(huì)同時(shí)緩存Avcc
格式、RTP
格式、以及裸格式,而這三種格式的NALU
部分都共用一組內(nèi)存(內(nèi)存不連續(xù))
減少發(fā)布者的GC
GC的產(chǎn)生
對(duì)于一個(gè)發(fā)布者,即需要不斷從網(wǎng)絡(luò)或是本地文件中讀取數(shù)據(jù)的對(duì)象,在不做任何優(yōu)化的情況下,都會(huì)不停的申請(qǐng)內(nèi)存。例如使用io.ReadAll
這種操作,內(nèi)部會(huì)頻繁的申請(qǐng)內(nèi)存。頻繁申請(qǐng)內(nèi)存的結(jié)果就是GC
壓力很大,尤其是高并發(fā)的時(shí)候,GC
帶來的消耗可以達(dá)到50%
的CPU
消耗。
sync.Pool
當(dāng)然我最先想到的一定是使用內(nèi)存池,也就是sync.Pool
來管理需要使用的內(nèi)存,但是sync.Pool
有個(gè)缺陷,就是為了協(xié)程安全內(nèi)部有鎖。盡管使用了多級(jí)緩存等一些列優(yōu)化手段,最終使用的時(shí)候也會(huì)消耗一定的性能(經(jīng)過實(shí)測(cè)性能開銷很大)。而且sync.Pool
比較通用,并不是針對(duì)特定的對(duì)象使用,我們這里是針對(duì)[]byte
類型進(jìn)行復(fù)用。
自定義Pool
如果Pool
不含有鎖,性能會(huì)大幅提升,那如何解決協(xié)程安全呢?答案是協(xié)程不安全,即我們只在一個(gè)協(xié)程里面去操作Pool
的取出和放回。通常情況下一個(gè)發(fā)布者的寫入是在同一個(gè)協(xié)程中的,比如rtmp
協(xié)議。少數(shù)協(xié)議如rtsp
可能會(huì)有多個(gè)協(xié)程寫入數(shù)據(jù),因此最后我們是每一個(gè)Track
一個(gè)Pool,保持一個(gè)Track
一個(gè)協(xié)程寫入。
下圖表示的是自定義Pool
的結(jié)構(gòu):
每個(gè)Pool
是一個(gè)數(shù)組,數(shù)組的每一個(gè)元素是一個(gè)鏈表,鏈表的每一個(gè)元素是一個(gè)包含[]byte
的類型,大小是2
的數(shù)組下標(biāo)次冪。
0號(hào)元素有特殊用途,由于我們需要記錄每一塊內(nèi)存所屬的鏈表來回收,因此需要有一個(gè)外殼,而外殼(ListItem
)也是需要回收的。而0號(hào)元素是存放的只有外殼需要回收而無需回收Value
(需要GC
的對(duì)象)的鏈表。
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]
回收內(nèi)存
當(dāng)RingBuffer
中的訪問單元被覆蓋時(shí),就可以將其中所有的內(nèi)存對(duì)象進(jìn)行放回Pool
。由此實(shí)現(xiàn)了從內(nèi)存使用的閉環(huán),消除了GC
。下圖中紅色箭頭代表內(nèi)存復(fù)用機(jī)制,可以有效避免申請(qǐng)內(nèi)存操作。
后記
經(jīng)過上面三板斧的優(yōu)化后,整體性能提升了50%
以上。下圖測(cè)試10000
路rtmp
推流的對(duì)比:m7s
內(nèi)存占用較高一些,原因就是采用了內(nèi)存池來減少GC
造成的。使用內(nèi)存來換CPU,在這種場(chǎng)景下還是值得的。
流媒體服務(wù)器 | 10000路推流CPU消耗 |
monibuca | 90%~100% |
zlm | 90%~100% |
srs | 80%~90% |
lal | 160%~200% |
由于livego的推流需要先調(diào)用一次HTTP獲取密鑰,所以無法使用壓測(cè)工具批量推流,本次對(duì)比無法參與。
所有流媒體服務(wù)器配置均關(guān)閉了協(xié)議轉(zhuǎn)換的開關(guān),并以Release方式編譯。服務(wù)器也去除了所有限制,并以完全相同的操作方式進(jìn)行壓測(cè)。
-
服務(wù)器
+關(guān)注
關(guān)注
13文章
9795瀏覽量
88001 -
架構(gòu)設(shè)計(jì)
+關(guān)注
關(guān)注
0文章
33瀏覽量
7116 -
go語言
+關(guān)注
關(guān)注
1文章
159瀏覽量
9377
原文標(biāo)題:方法)。也可以減少偽自旋鎖帶來的輕微延遲。
文章出處:【微信號(hào):OSC開源社區(qū),微信公眾號(hào):OSC開源社區(qū)】歡迎添加關(guān)注!文章轉(zhuǎn)載請(qǐng)注明出處。
發(fā)布評(píng)論請(qǐng)先 登錄
kintex產(chǎn)品架構(gòu)設(shè)計(jì)文檔(成為架構(gòu)師也是電子人不錯(cuò)的選...
【汽車電氣架構(gòu)設(shè)計(jì)軟件】
基于ARM架構(gòu)設(shè)計(jì)的M1芯片
STM32軟件架構(gòu)設(shè)計(jì)的意義
為何要進(jìn)行嵌入式軟件架構(gòu)設(shè)計(jì)?如何設(shè)計(jì)?
對(duì)嵌入式系統(tǒng)中的架構(gòu)設(shè)計(jì)的理解
基于ARMCortex_M3核的SoC架構(gòu)設(shè)計(jì)及性能分析

富可視發(fā)布全面屏新機(jī)“M7s”:前后攝像頭同時(shí)拍攝
ARMv7-M嵌入式架構(gòu)的特點(diǎn)是什么
系統(tǒng)架構(gòu)設(shè)計(jì)的詳細(xì)講解
SWE.2的軟件架構(gòu)設(shè)計(jì)
SYS.3的系統(tǒng)架構(gòu)設(shè)計(jì)
架構(gòu)與微架構(gòu)設(shè)計(jì)

評(píng)論