golang?chan傳遞數(shù)據(jù)的性能開銷詳解
這篇文章并不討論chan因為加鎖解鎖以及為了維持內(nèi)存模型定義的行為而付出的運行時開銷。
這篇文章要探討的是chan在接收和發(fā)送數(shù)據(jù)時因為“復(fù)制”而產(chǎn)生的開銷。
在做性能測試前先復(fù)習(xí)點基礎(chǔ)知識。
數(shù)據(jù)是如何在chan里流動的
首先我們來看看帶buffer的chan,這里要分成兩類來討論。那沒buffer的chan呢?后面會細(xì)說。
情況1:發(fā)送的數(shù)據(jù)有讀者在讀取
可能需要解釋一下這節(jié)的標(biāo)題,意思是:發(fā)送者正在發(fā)送數(shù)據(jù)同時另一個接收者在等待數(shù)據(jù),看圖可能更快一些↓
圖里的chan是空的,發(fā)送者協(xié)程正在發(fā)送數(shù)據(jù)到channel,同時有一個接收者協(xié)程正在等待從chan里接收數(shù)據(jù)。
如果你對chan的內(nèi)存模型比較了解的話,其實可以發(fā)現(xiàn)此時是buffered chan的一種特例,他的行為和無緩沖的chan是一樣的,事實上兩者的處理上也是類似的。
所以向無緩沖chan發(fā)送數(shù)據(jù)時的情況可以歸類到情況1里。
在這種情況下,雖然在圖里我們?nèi)匀划嬃薱han的緩沖區(qū),但實際上go有優(yōu)化:chan發(fā)現(xiàn)這種情況后會使用runtime api,直接將數(shù)據(jù)寫入接收者的內(nèi)存(通常是棧內(nèi)存),跳過chan自己的緩沖區(qū),只復(fù)制數(shù)據(jù)一次。
這種情況下就像數(shù)據(jù)直接從發(fā)送者流到了接收者那里一樣。
情況2:發(fā)送的數(shù)據(jù)沒有讀者在讀取
這個情況就簡單多了,基本上除了情況1之外的所有情形都屬于這種:
圖里描述的是最常見的情況,讀者和寫者在操作不同的內(nèi)存。寫者將數(shù)據(jù)復(fù)制進緩沖區(qū)然后返回,如果緩沖滿了就阻塞到有可用的空位為止;讀者從緩沖區(qū)中將數(shù)據(jù)復(fù)制到自己的內(nèi)存里然后把對應(yīng)位置的內(nèi)存標(biāo)記為可寫入,如果緩沖區(qū)是空的,就阻塞到有數(shù)據(jù)可讀為止。
可能有人會問,如果緩沖區(qū)滿了導(dǎo)致發(fā)送的一方被阻塞了呢?其實發(fā)送者從阻塞恢復(fù)后需要繼續(xù)發(fā)送數(shù)據(jù),這時是逃不出情況1和情況2的,所以是否會被阻塞在這里不會影響數(shù)據(jù)發(fā)送的方式,并不重要。
在情況2中,數(shù)據(jù)先要被復(fù)制進chan自己的緩沖區(qū),然后接收者在讀取的時候在從chan的緩沖區(qū)把數(shù)據(jù)復(fù)制到自己的內(nèi)存里??傮w來說數(shù)據(jù)要被復(fù)制兩次。
情況2中chan就像這個水池,數(shù)據(jù)先從發(fā)送者那流進水池里,過了一段時間后再從水池里流到接收者那里。
特例中的特例
這里要說的是空結(jié)構(gòu)體:struct{}
。在chan直接傳遞這東西不會有額外的內(nèi)存開銷,因為空結(jié)構(gòu)體本身不占內(nèi)存。和處理空結(jié)構(gòu)體的map一樣,go對這個特例做了特殊處理。
當(dāng)然,雖然不會消耗額外的內(nèi)存,但內(nèi)存模型是不變的。為了方便起見你可以把這個特例想象成情況2,只是相比之下使用更少的內(nèi)存。
為什么要復(fù)制
在情況1里我們看到了,runtime實際上有能力直接操作各個goroutine的內(nèi)存,那么為什么不選擇將數(shù)據(jù)“移動”到目標(biāo)位置,而要選擇復(fù)制呢?
我們先來看看如果是“移動”會發(fā)生什么。參考其他語言的慣例,被移動的對象將不可再被訪問,它的數(shù)據(jù)也將處于一種不確定但可以被安全刪除的狀態(tài),簡單地說,一點變量里的數(shù)據(jù)被移動到其他地方,這個變量就不應(yīng)該再被訪問了。在一些語言里移動后變量將強制性不可訪問,另一些語言里雖然可以訪問但會產(chǎn)生“undefined behavior”使程序陷入危險的狀態(tài)。go就比較尷尬了,既沒有手段阻止變量在移動后繼續(xù)被訪問,也沒有類似“undefined behavior”的手段兜底這些意外情況,隨意panic不僅消耗性能更是穩(wěn)定性方面的大忌。
因此移動在go中不現(xiàn)實。
再來看看在goroutine之間共享數(shù)據(jù),對于可以操作goroutine內(nèi)存的runtime來說,這個比移動要費事的多,但也可以實現(xiàn)。共享可能在cpu資源上會有些損耗,但確實能節(jié)約很多內(nèi)存。
共享的可行性也比移動高一些,因為不會對現(xiàn)有語法和語言設(shè)計有較大的沖擊,甚至可以說完全是在這套語法框架下合情合理的操作。但只要一個問題:不安全。chan的使用場景大部分情況下都是在并發(fā)編程中,共享的數(shù)據(jù)會帶來嚴(yán)重的并發(fā)安全問題。最常見的就是共享的數(shù)據(jù)被意外修改。對于以便利且安全的并發(fā)操作為賣點的go語言來說,內(nèi)置的并發(fā)原語會無時不刻生產(chǎn)出并發(fā)安全問題,無疑是不可接受的。
最后只剩下一個方案了,使用復(fù)制來傳遞數(shù)據(jù)。復(fù)制能在語法框架下使用,與共享相比也不容易引發(fā)問題(只是相對而言,chan的淺拷貝問題有時候反而是并發(fā)問題的溫床)。這也是go遵循的CSP(Communicating Sequential Process)模型所提倡的。
復(fù)制導(dǎo)致的開銷
既然復(fù)制有正當(dāng)理由且不可避免,那我們只能選擇接受了。因此復(fù)制會帶來多大開銷變得至關(guān)重要。
內(nèi)存用量上的開銷很簡單就能計算出來,不管是情況1還是情況2,數(shù)據(jù)一個時刻最多只會有自己本體外加一個副本存在——情況1是發(fā)送者持有本體,接收者持有副本;情況2是發(fā)送者持有本體,chan的緩沖區(qū)或者接收者(從緩沖區(qū)復(fù)制過去后緩沖區(qū)置空)持有副本。當(dāng)然,發(fā)送者完全可以將本體銷毀這樣只有一份數(shù)據(jù)留存在內(nèi)存里。所以內(nèi)存的消耗在最壞情況下會增加一倍。
cpu的消耗以及對速度的影響就沒那么好估計了,這個只能靠性能測試了。
測試的設(shè)計很簡單,選擇大中小三組數(shù)據(jù)利用buffered chan來測試chan和協(xié)程直接復(fù)制數(shù)據(jù)的開銷。
小的標(biāo)準(zhǔn)是2個int64
,大小16字節(jié),存進一個緩存行綽綽有余:
type SmallData struct { a, b int64 }
中型大小的數(shù)據(jù)更接**常的業(yè)務(wù)對象,大小是144字節(jié),包含十多個字段:
type Data struct { a, b, c, d int64 flag1, flag2 bool s1, s2, s3, s4 string e, f, g, h uint64 r1, r2 rune }
最后是大對象,大對象包含十個中對象,大小1440字節(jié),我知道也許沒人會這么寫,也許實際項目里還有筆者更重量級的,我當(dāng)然只能選個看起來合理的值用于測試:
type BigData struct { d1, d2, d3, d4, d5, d6, d7, d8, d9, d10 Data }
鑒于chan會阻塞協(xié)程的特殊性,我們只能發(fā)完數(shù)據(jù)后再把它從chan里取出來,不然就得反復(fù)創(chuàng)建和釋放chan,這樣代來的雜音太大,因此數(shù)據(jù)實際上要被復(fù)制上兩回,這里我們只關(guān)注內(nèi)存復(fù)制的開銷,其他因素控制好變量就不會有影響。完整的測試代碼長這樣:
import "testing" type SmallData struct { a, b int64 } func BenchmarkSendSmallData(b *testing.B) { c := make(chan SmallData, 1) sd := SmallData{ a: -1, b: -2, } for i := 0; i < b.N; i++ { c <- sd <-c } } func BenchmarkSendSmallPointer(b *testing.B) { c := make(chan *SmallData, 1) sd := &SmallData{ a: -1, b: -2, } for i := 0; i < b.N; i++ { c <- sd <-c } } type Data struct { a, b, c, d int64 flag1, flag2 bool s1, s2, s3, s4 string e, f, g, h uint64 r1, r2 rune } func BenchmarkSendData(b *testing.B) { c := make(chan Data, 1) d := Data{ a: -1, b: -2, c: -3, d: -4, flag1: true, flag2: false, s1: "甲甲甲", s2: "乙乙乙", s3: "丙丙丙", s4: "丁丁丁", e: 4, f: 3, g: 2, h: 1, r1: '測', r2: '試', } for i := 0; i < b.N; i++ { c <- d <-c } } func BenchmarkSendPointer(b *testing.B) { c := make(chan *Data, 1) d := &Data{ a: -1, b: -2, c: -3, d: -4, flag1: true, flag2: false, s1: "甲甲甲", s2: "乙乙乙", s3: "丙丙丙", s4: "丁丁丁", e: 4, f: 3, g: 2, h: 1, r1: '測', r2: '試', } for i := 0; i < b.N; i++ { c <- d <-c } } type BigData struct { d1, d2, d3, d4, d5, d6, d7, d8, d9, d10 Data } func BenchmarkSendBigData(b *testing.B) { c := make(chan BigData, 1) d := Data{ a: -1, b: -2, c: -3, d: -4, flag1: true, flag2: false, s1: "甲甲甲", s2: "乙乙乙", s3: "丙丙丙", s4: "丁丁丁", e: 4, f: 3, g: 2, h: 1, r1: '測', r2: '試', } bd := BigData{ d1: d, d2: d, d3: d, d4: d, d5: d, d6: d, d7: d, d8: d, d9: d, d10: d, } for i := 0; i < b.N; i++ { c <- bd <-c } } func BenchmarkSendBigDataPointer(b *testing.B) { c := make(chan *BigData, 1) d := Data{ a: -1, b: -2, c: -3, d: -4, flag1: true, flag2: false, s1: "甲甲甲", s2: "乙乙乙", s3: "丙丙丙", s4: "丁丁丁", e: 4, f: 3, g: 2, h: 1, r1: '測', r2: '試', } bd := &BigData{ d1: d, d2: d, d3: d, d4: d, d5: d, d6: d, d7: d, d8: d, d9: d, d10: d, } for i := 0; i < b.N; i++ { c <- bd <-c } }
我們選擇傳遞指針作為對比,這是日常開發(fā)中另一種常見的作法。
Windows11上的測試結(jié)果:
Linux上的測試結(jié)果:
對于小型數(shù)據(jù),復(fù)制帶來的開銷并不是很突出。
對于中型和大型數(shù)據(jù)就沒那么樂觀了,性能分別下降了20%和50%。
測試結(jié)果很清晰,但有一點容易產(chǎn)生疑問,為什么大型數(shù)據(jù)比中型大了10倍,但復(fù)制速度上只慢了2.5倍呢?
原因是golang會對大數(shù)據(jù)啟用SIMD指令增加單位時間內(nèi)的數(shù)據(jù)吞吐量,因此數(shù)據(jù)大了確實復(fù)制會更慢,但不是數(shù)據(jù)量大10倍速度就會慢10倍的。
由此可見復(fù)制數(shù)據(jù)帶來的開銷是很難置之不理的。
如何避免開銷
既然chan復(fù)制數(shù)據(jù)會產(chǎn)生不可忽視的性能開銷,我們得想些對策來解決問題才行。這里提供幾種思路。
只傳小對象
多小才算小,這個爭議很大。我只能說說我自己的經(jīng)驗談:1個緩存行里存得下的就是小。
一個緩存行有多大?現(xiàn)代的x64 cpu上L1D的大小通常是32字節(jié),也就是4個普通數(shù)據(jù)指針/int64
的大小。
從我們的測試來看小數(shù)據(jù)的復(fù)制開銷幾乎可以忽略不記,因此只在chan里傳遞這類小數(shù)據(jù)不會有什么性能問題。
唯一要注意的是string,目前的實現(xiàn)一個字符串本身的大小是16字節(jié),但這個大小是沒算字符串本身數(shù)據(jù)的,也就是說一個長度256的字符串和一個長度1的字符串,自身的結(jié)構(gòu)都是16字節(jié)大,但復(fù)制的時候一個要拷貝256個字符一個只用拷貝一個字符。因此字符串經(jīng)常出現(xiàn)看著小但實際大小很大的實例。
只傳指針
32字節(jié)實在是有點小,如果我需要傳遞2-3個緩存行大小的數(shù)據(jù)怎么辦?這個也是實際開發(fā)中的常見需求。
答案實際上在性能測試的對照組里給出了:傳指針給chan。
從性能測試的結(jié)果來看,只傳指針的情況下,無論數(shù)據(jù)多大,耗時都是一樣的,因為我們只復(fù)制了一份指針——8字節(jié)的數(shù)據(jù)。
這個作法也能節(jié)約內(nèi)存:只復(fù)制了指針,指針引用的數(shù)據(jù)沒有被復(fù)制。
看起來我們找到了向chan傳遞數(shù)據(jù)的銀彈——只傳指針,然而世界上并沒有銀彈——
- 傳指針相當(dāng)于上一節(jié)說的“共享”數(shù)據(jù),很容易帶來并發(fā)安全問題;
- 對于發(fā)送者,傳指針給chan很可能會影響逃逸分析,不僅會在堆上分配對象,還會使情況1中的優(yōu)化失去意義(調(diào)用runtime就為了寫入一個指針到接收者的棧上)
- 對于接收者來說,操作指針引用的數(shù)據(jù)需要一次或多次的解引用,而這種解引用很難被優(yōu)化掉,因此在一些熱點代碼上很可能會帶來可見的性能影響(通常不會有復(fù)制數(shù)據(jù)帶來的開銷大,但一切得以性能測試為準(zhǔn))。
- 太多的指針會加重gc的負(fù)擔(dān)
使用指針傳遞時切記要充分考慮上面列出的缺點。
使用lock-free數(shù)據(jù)結(jié)果替代chan
chan大部分時間都被用作并發(fā)安全的隊列,如果chan只有固定的一個發(fā)送者和固定一個的接收者,那么可以試試這種無鎖數(shù)據(jù)結(jié)構(gòu):SPSCQueue。
無鎖數(shù)據(jù)結(jié)構(gòu)相比chan好處在于沒有mutex,且沒有數(shù)據(jù)復(fù)制的開銷。
缺點是只支持單一接收者和單一發(fā)送者,實現(xiàn)也相對復(fù)雜所以需要很高的代碼質(zhì)量來保證使用上的安全和運行結(jié)果的正確,找不到一個高質(zhì)量庫的時候我建議是最好別嘗試自己寫,也最好別用。(一個壞消息,go里可靠的無鎖數(shù)據(jù)結(jié)構(gòu)庫不是很多)
開銷可以接受的情況
有一類系統(tǒng)追求正確性和安全性,對性能損耗和資源消耗有較高的容忍度。對于這類系統(tǒng)來說,復(fù)制數(shù)據(jù)帶來的開銷一般是可接受的。
這時候明顯復(fù)制傳遞比傳指針等操作簡單而安全。
另一種常見情形是:chan并不是性能瓶頸,復(fù)不復(fù)制對性能的影響微乎其微。這時候我也傾向于選擇復(fù)制傳遞數(shù)據(jù)。
總結(jié)
總體來說chan還是很方便的,在go里又是還不得不用。
我寫這篇文章不是為了嚇唬大家,只是提醒大家一些使用chan時可能發(fā)生的性能陷阱和對應(yīng)的解決辦法。
至于你怎么用chan,除了要結(jié)合實際需求之外,性能測試是另一個重要的參考標(biāo)準(zhǔn)。
如果問我,那么我傾向于復(fù)制數(shù)據(jù)優(yōu)先于指針傳遞,除非數(shù)據(jù)十分巨大/性能瓶頸在復(fù)制上/接收方發(fā)送方需要在同一個對象上做些協(xié)同作業(yè)。同樣性能測試和profile是我采用這些方式的參考標(biāo)準(zhǔn)。
到此這篇關(guān)于golang chan傳遞數(shù)據(jù)的性能開銷詳解的文章就介紹到這了,更多相關(guān)go chan傳遞數(shù)據(jù)內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
深入探討Go語言中的map是否是并發(fā)安全以及解決方法
這篇文章主要來和大家探討?Go?語言中的?map?是否是并發(fā)安全的,并提供三種方案來解決并發(fā)問題,文中的示例代碼講解詳細(xì),需要的可以參考一下2023-05-05GoLand編譯帶有構(gòu)建標(biāo)簽的程序思路詳解
這篇文章主要介紹了GoLand編譯帶有構(gòu)建標(biāo)簽的程序,本文通過實例代碼給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-11-11Golang利用channel協(xié)調(diào)協(xié)程的方法詳解
go?當(dāng)中的并發(fā)編程是通過goroutine來實現(xiàn)的,利用channel(管道)可以在協(xié)程之間傳遞數(shù)據(jù),所以本文就來講講Golang如何利用channel協(xié)調(diào)協(xié)程吧2023-05-05