golang chan傳遞數據的性能開銷

這篇文章並不討論chan因爲加鎖解鎖以及爲了維持內存模型定義的行爲而付出的運行時開銷。

這篇文章要探討的是chan在接收和發送數據時因爲“複製”而產生的開銷。

在做性能測試前先複習點基礎知識。

本文索引

數據是如何在chan裏流動的

首先我們來看看帶buffer的chan,這裏要分成兩類來討論。那沒buffer的chan呢?後面會細說。

情況1:發送的數據有讀者在讀取

可能需要解釋一下這節的標題,意思是:發送者正在發送數據同時另一個接收者在等待數據,看圖可能更快一些↓

sending1

圖裏的chan是空的,發送者協程正在發送數據到channel,同時有一個接收者協程正在等待從chan裏接收數據。

如果你對chan的內存模型比較瞭解的話,其實可以發現此時是buffered chan的一種特例,他的行爲和無緩衝的chan是一樣的,事實上兩者的處理上也是類似的。

所以向無緩衝chan發送數據時的情況可以歸類到情況1裏。

在這種情況下,雖然在圖裏我們仍然畫了chan的緩衝區,但實際上go有優化:chan發現這種情況後會使用runtime api,直接將數據寫入接收者的內存(通常是棧內存),跳過chan自己的緩衝區,只複製數據一次。

這種情況下就像數據直接從發送者流到了接收者那裏一樣。

情況2:發送的數據沒有讀者在讀取

這個情況就簡單多了,基本上除了情況1之外的所有情形都屬於這種:

sending2

圖裏描述的是最常見的情況,讀者和寫者在操作不同的內存。寫者將數據複製進緩衝區然後返回,如果緩衝滿了就阻塞到有可用的空位爲止;讀者從緩衝區中將數據複製到自己的內存裏然後把對應位置的內存標記爲可寫入,如果緩衝區是空的,就阻塞到有數據可讀爲止。

可能有人會問,如果緩衝區滿了導致發送的一方被阻塞了呢?其實發送者從阻塞恢復後需要繼續發送數據,這時是逃不出情況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上的測試結果:

winbench

Linux上的測試結果:

linuxbench

對於小型數據,複製帶來的開銷並不是很突出。

對於中型和大型數據就沒那麼樂觀了,性能分別下降了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傳遞數據的銀彈——只傳指針,然而世界上並沒有銀彈——

  1. 傳指針相當於上一節說的“共享”數據,很容易帶來併發安全問題;
  2. 對於發送者,傳指針給chan很可能會影響逃逸分析,不僅會在堆上分配對象,還會使情況1中的優化失去意義(調用runtime就爲了寫入一個指針到接收者的棧上)
  3. 對於接收者來說,操作指針引用的數據需要一次或多次的解引用,而這種解引用很難被優化掉,因此在一些熱點代碼上很可能會帶來可見的性能影響(通常不會有複製數據帶來的開銷大,但一切得以性能測試爲準)。
  4. 太多的指針會加重gc的負擔

使用指針傳遞時切記要充分考慮上面列出的缺點。

使用lock-free數據結構替代chan

chan大部分時間都被用作併發安全的隊列,如果chan只有固定的一個發送者和固定一個的接收者,那麼可以試試這種無鎖數據結構:SPSCQueue

無鎖數據結構相比chan好處在於沒有mutex,且沒有數據複製的開銷。

缺點是隻支持單一接收者和單一發送者,實現也相對複雜所以需要很高的代碼質量來保證使用上的安全和運行結果的正確,找不到一個高質量庫的時候我建議是最好別嘗試自己寫,也最好別用。(一個壞消息,go裏可靠的無鎖數據結構庫不是很多)

開銷可以接受的情況

有一類系統追求正確性和安全性,對性能損耗和資源消耗有較高的容忍度。對於這類系統來說,複製數據帶來的開銷一般是可接受的。

這時候明顯複製傳遞比傳指針等操作簡單而安全。

另一種常見情形是:chan並不是性能瓶頸,復不復制對性能的影響微乎其微。這時候我也傾向於選擇複製傳遞數據。

總結

總體來說chan還是很方便的,在go裏又是還不得不用。

我寫這篇文章不是爲了嚇唬大家,只是提醒大家一些使用chan時可能發生的性能陷阱和對應的解決辦法。

至於你怎麼用chan,除了要結合實際需求之外,性能測試是另一個重要的參考標準。

如果問我,那麼我傾向於複製數據優先於指針傳遞,除非數據十分巨大/性能瓶頸在複製上/接收方發送方需要在同一個對象上做些協同作業。同樣性能測試和profile是我採用這些方式的參考標準。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章