golang chan传递数据的性能开销
這篇文章并不討論chan因為加鎖解鎖以及為了維持內存模型定義的行為而付出的運行時開銷。
這篇文章要探討的是chan在接收和發送數據時因為“復制”而產生的開銷。
在做性能測試前先復習點基礎知識。
本文索引
- 數據是如何在chan里流動的
- 情況1:發送的數據有讀者在讀取
- 情況2:發送的數據沒有讀者在讀取
- 特例中的特例
- 為什么要復制
- 復制導致的開銷
- 如何避免開銷
- 只傳小對象
- 只傳指針
- 使用lock-free數據結果替代chan
- 開銷可以接受的情況
- 總結
數據是如何在chan里流動的
首先我們來看看帶buffer的chan,這里要分成兩類來討論。那沒buffer的chan呢?后面會細說。
情況1:發送的數據有讀者在讀取
可能需要解釋一下這節的標題,意思是:發送者正在發送數據同時另一個接收者在等待數據,看圖可能更快一些↓
圖里的chan是空的,發送者協程正在發送數據到channel,同時有一個接收者協程正在等待從chan里接收數據。
如果你對chan的內存模型比較了解的話,其實可以發現此時是buffered chan的一種特例,他的行為和無緩沖的chan是一樣的,事實上兩者的處理上也是類似的。
所以向無緩沖chan發送數據時的情況可以歸類到情況1里。
在這種情況下,雖然在圖里我們仍然畫了chan的緩沖區,但實際上go有優化:chan發現這種情況后會使用runtime api,直接將數據寫入接收者的內存(通常是棧內存),跳過chan自己的緩沖區,只復制數據一次。
這種情況下就像數據直接從發送者流到了接收者那里一樣。
情況2:發送的數據沒有讀者在讀取
這個情況就簡單多了,基本上除了情況1之外的所有情形都屬于這種:
圖里描述的是最常見的情況,讀者和寫者在操作不同的內存。寫者將數據復制進緩沖區然后返回,如果緩沖滿了就阻塞到有可用的空位為止;讀者從緩沖區中將數據復制到自己的內存里然后把對應位置的內存標記為可寫入,如果緩沖區是空的,就阻塞到有數據可讀為止。
可能有人會問,如果緩沖區滿了導致發送的一方被阻塞了呢?其實發送者從阻塞恢復后需要繼續發送數據,這時是逃不出情況1和情況2的,所以是否會被阻塞在這里不會影響數據發送的方式,并不重要。
在情況2中,數據先要被復制進chan自己的緩沖區,然后接收者在讀取的時候在從chan的緩沖區把數據復制到自己的內存里。總體來說數據要被復制兩次。
情況2中chan就像這個水池,數據先從發送者那流進水池里,過了一段時間后再從水池里流到接收者那里。
特例中的特例
這里要說的是空結構體:struct{}。在chan直接傳遞這東西不會有額外的內存開銷,因為空結構體本身不占內存。和處理空結構體的map一樣,go對這個特例做了特殊處理。
當然,雖然不會消耗額外的內存,但內存模型是不變的。為了方便起見你可以把這個特例想象成情況2,只是相比之下使用更少的內存。
為什么要復制
在情況1里我們看到了,runtime實際上有能力直接操作各個goroutine的內存,那么為什么不選擇將數據“移動”到目標位置,而要選擇復制呢?
我們先來看看如果是“移動”會發生什么。參考其他語言的慣例,被移動的對象將不可再被訪問,它的數據也將處于一種不確定但可以被安全刪除的狀態,簡單地說,一點變量里的數據被移動到其他地方,這個變量就不應該再被訪問了。在一些語言里移動后變量將強制性不可訪問,另一些語言里雖然可以訪問但會產生“undefined behavior”使程序陷入危險的狀態。go就比較尷尬了,既沒有手段阻止變量在移動后繼續被訪問,也沒有類似“undefined behavior”的手段兜底這些意外情況,隨意panic不僅消耗性能更是穩定性方面的大忌。
因此移動在go中不現實。
再來看看在goroutine之間共享數據,對于可以操作goroutine內存的runtime來說,這個比移動要費事的多,但也可以實現。共享可能在cpu資源上會有些損耗,但確實能節約很多內存。
共享的可行性也比移動高一些,因為不會對現有語法和語言設計有較大的沖擊,甚至可以說完全是在這套語法框架下合情合理的操作。但只要一個問題:不安全。chan的使用場景大部分情況下都是在并發編程中,共享的數據會帶來嚴重的并發安全問題。最常見的就是共享的數據被意外修改。對于以便利且安全的并發操作為賣點的go語言來說,內置的并發原語會無時不刻生產出并發安全問題,無疑是不可接受的。
最后只剩下一個方案了,使用復制來傳遞數據。復制能在語法框架下使用,與共享相比也不容易引發問題(只是相對而言,chan的淺拷貝問題有時候反而是并發問題的溫床)。這也是go遵循的CSP(Communicating Sequential Process)模型所提倡的。
復制導致的開銷
既然復制有正當理由且不可避免,那我們只能選擇接受了。因此復制會帶來多大開銷變得至關重要。
內存用量上的開銷很簡單就能計算出來,不管是情況1還是情況2,數據一個時刻最多只會有自己本體外加一個副本存在——情況1是發送者持有本體,接收者持有副本;情況2是發送者持有本體,chan的緩沖區或者接收者(從緩沖區復制過去后緩沖區置空)持有副本。當然,發送者完全可以將本體銷毀這樣只有一份數據留存在內存里。所以內存的消耗在最壞情況下會增加一倍。
cpu的消耗以及對速度的影響就沒那么好估計了,這個只能靠性能測試了。
測試的設計很簡單,選擇大中小三組數據利用buffered chan來測試chan和協程直接復制數據的開銷。
小的標準是2個int64,大小16字節,存進一個緩存行綽綽有余:
type SmallData struct {
    a, b int64
}
中型大小的數據更接**常的業務對象,大小是144字節,包含十多個字段:
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字節,我知道也許沒人會這么寫,也許實際項目里還有筆者更重量級的,我當然只能選個看起來合理的值用于測試:
type BigData struct {
	d1, d2, d3, d4, d5, d6, d7, d8, d9, d10 Data
}
鑒于chan會阻塞協程的特殊性,我們只能發完數據后再把它從chan里取出來,不然就得反復創建和釋放chan,這樣代來的雜音太大,因此數據實際上要被復制上兩回,這里我們只關注內存復制的開銷,其他因素控制好變量就不會有影響。完整的測試代碼長這樣:
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
	}
}
我們選擇傳遞指針作為對比,這是日常開發中另一種常見的作法。
Windows11上的測試結果:
Linux上的測試結果:
對于小型數據,復制帶來的開銷并不是很突出。
對于中型和大型數據就沒那么樂觀了,性能分別下降了20%和50%。
測試結果很清晰,但有一點容易產生疑問,為什么大型數據比中型大了10倍,但復制速度上只慢了2.5倍呢?
原因是golang會對大數據啟用SIMD指令增加單位時間內的數據吞吐量,因此數據大了確實復制會更慢,但不是數據量大10倍速度就會慢10倍的。
由此可見復制數據帶來的開銷是很難置之不理的。
如何避免開銷
既然chan復制數據會產生不可忽視的性能開銷,我們得想些對策來解決問題才行。這里提供幾種思路。
只傳小對象
多小才算小,這個爭議很大。我只能說說我自己的經驗談:1個緩存行里存得下的就是小。
一個緩存行有多大?現代的x64 cpu上L1D的大小通常是32字節,也就是4個普通數據指針/int64的大小。
從我們的測試來看小數據的復制開銷幾乎可以忽略不記,因此只在chan里傳遞這類小數據不會有什么性能問題。
唯一要注意的是string,目前的實現一個字符串本身的大小是16字節,但這個大小是沒算字符串本身數據的,也就是說一個長度256的字符串和一個長度1的字符串,自身的結構都是16字節大,但復制的時候一個要拷貝256個字符一個只用拷貝一個字符。因此字符串經常出現看著小但實際大小很大的實例。
只傳指針
32字節實在是有點小,如果我需要傳遞2-3個緩存行大小的數據怎么辦?這個也是實際開發中的常見需求。
答案實際上在性能測試的對照組里給出了:傳指針給chan。
從性能測試的結果來看,只傳指針的情況下,無論數據多大,耗時都是一樣的,因為我們只復制了一份指針——8字節的數據。
這個作法也能節約內存:只復制了指針,指針引用的數據沒有被復制。
看起來我們找到了向chan傳遞數據的銀彈——只傳指針,然而世界上并沒有銀彈——
- 傳指針相當于上一節說的“共享”數據,很容易帶來并發安全問題;
- 對于發送者,傳指針給chan很可能會影響逃逸分析,不僅會在堆上分配對象,還會使情況1中的優化失去意義(調用runtime就為了寫入一個指針到接收者的棧上)
- 對于接收者來說,操作指針引用的數據需要一次或多次的解引用,而這種解引用很難被優化掉,因此在一些熱點代碼上很可能會帶來可見的性能影響(通常不會有復制數據帶來的開銷大,但一切得以性能測試為準)。
- 太多的指針會加重gc的負擔
使用指針傳遞時切記要充分考慮上面列出的缺點。
使用lock-free數據結果替代chan
chan大部分時間都被用作并發安全的隊列,如果chan只有固定的一個發送者和固定一個的接收者,那么可以試試這種無鎖數據結構:SPSCQueue。
無鎖數據結構相比chan好處在于沒有mutex,且沒有數據復制的開銷。
缺點是只支持單一接收者和單一發送者,實現也相對復雜所以需要很高的代碼質量來保證使用上的安全和運行結果的正確,找不到一個高質量庫的時候我建議是最好別嘗試自己寫,也最好別用。(一個壞消息,go里可靠的無鎖數據結構庫不是很多)
開銷可以接受的情況
有一類系統追求正確性和安全性,對性能損耗和資源消耗有較高的容忍度。對于這類系統來說,復制數據帶來的開銷一般是可接受的。
這時候明顯復制傳遞比傳指針等操作簡單而安全。
另一種常見情形是:chan并不是性能瓶頸,復不復制對性能的影響微乎其微。這時候我也傾向于選擇復制傳遞數據。
總結
總體來說chan還是很方便的,在go里又是還不得不用。
我寫這篇文章不是為了嚇唬大家,只是提醒大家一些使用chan時可能發生的性能陷阱和對應的解決辦法。
至于你怎么用chan,除了要結合實際需求之外,性能測試是另一個重要的參考標準。
如果問我,那么我傾向于復制數據優先于指針傳遞,除非數據十分巨大/性能瓶頸在復制上/接收方發送方需要在同一個對象上做些協同作業。同樣性能測試和profile是我采用這些方式的參考標準。
總結
以上是生活随笔為你收集整理的golang chan传递数据的性能开销的全部內容,希望文章能夠幫你解決所遇到的問題。
 
                            
                        - 上一篇: 试题 基础练习 Huffuman树(详解
- 下一篇: Java jdom解析xml文件带冒号的
