四萬(wàn)字長(zhǎng)文帶你了解 Go 高性能編程技法
作者:dablelv,騰訊 IEG 后臺(tái)開(kāi)發(fā)工程師
代碼的穩(wěn)健、可讀和高效是我們每一個(gè) coder 的共同追求。本文將結(jié)合 Go 語(yǔ)言特性,為書(shū)寫(xiě)效率更高的代碼,從常用數(shù)據(jù)結(jié)構(gòu)、內(nèi)存管理和并發(fā),三個(gè)方面給出相關(guān)建議。話(huà)不多說(shuō),讓我們一起學(xué)習(xí) Go 高性能編程的技法吧。
常用數(shù)據(jù)結(jié)構(gòu)
1.反射雖好,切莫貪杯
標(biāo)準(zhǔn)庫(kù) reflect 為 Go 語(yǔ)言提供了運(yùn)行時(shí)動(dòng)態(tài)獲取對(duì)象的類(lèi)型和值以及動(dòng)態(tài)創(chuàng)建對(duì)象的能力。反射可以幫助抽象和簡(jiǎn)化代碼,提高開(kāi)發(fā)效率。
Go 語(yǔ)言標(biāo)準(zhǔn)庫(kù)以及很多開(kāi)源軟件中都使用了 Go 語(yǔ)言的反射能力,例如用于序列化和反序列化的 json、ORM 框架 gorm、xorm 等。
1.1 優(yōu)先使用 strconv 而不是 fmt
基本數(shù)據(jù)類(lèi)型與字符串之間的轉(zhuǎn)換,優(yōu)先使用 strconv 而不是 fmt,因?yàn)榍罢咝阅芨选?/span>
// Badfor i := 0; i < b.N; i { s := fmt.Sprint(rand.Int())}BenchmarkFmtSprint-4 143 ns/op 2 allocs/op// Goodfor i := 0; i < b.N; i { s := strconv.Itoa(rand.Int())}BenchmarkStrconv-4 64.2 ns/op 1 allocs/op
為什么性能上會(huì)有兩倍多的差距,因?yàn)?fmt 實(shí)現(xiàn)上利用反射來(lái)達(dá)到范型的效果,在運(yùn)行時(shí)進(jìn)行類(lèi)型的動(dòng)態(tài)判斷,所以帶來(lái)了一定的性能損耗。
1.2 少量的重復(fù)不比反射差
有時(shí),我們需要一些工具函數(shù)。比如從 uint64 切片過(guò)濾掉指定的元素。
利用反射,我們可以實(shí)現(xiàn)一個(gè)類(lèi)型泛化支持?jǐn)U展的切片過(guò)濾函數(shù)。
// DeleteSliceElms 從切片中過(guò)濾指定元素。注意:不修改原切片。func DeleteSliceElms(i interface{}, elms ...interface{}) interface{} { // 構(gòu)建 map set。 m := make(map[interface{}]struct{}, len(elms)) for _, v := range elms { m[v] = struct{}{} } // 創(chuàng)建新切片,過(guò)濾掉指定元素。 v := reflect.ValueOf(i) t := reflect.MakeSlice(reflect.TypeOf(i), 0, v.Len()) for i := 0; i < v.Len(); i { if _, ok := m[v.Index(i).Interface()]; !ok { t = reflect.Append(t, v.Index(i)) } } return t.Interface()}
很多時(shí)候,我們可能只需要操作一個(gè)類(lèi)型的切片,利用反射實(shí)現(xiàn)的類(lèi)型泛化擴(kuò)展的能力壓根沒(méi)用上。退一步說(shuō),如果我們真地需要對(duì) uint64 以外類(lèi)型的切片進(jìn)行過(guò)濾,拷貝一次代碼又何妨呢?可以肯定的是,絕大部份場(chǎng)景,根本不會(huì)對(duì)所有類(lèi)型的切片進(jìn)行過(guò)濾,那么反射帶來(lái)好處我們并沒(méi)有充分享受,但卻要為其帶來(lái)的性能成本買(mǎi)單。
// DeleteU64liceElms 從 []uint64 過(guò)濾指定元素。注意:不修改原切片。func DeleteU64liceElms(i []uint64, elms ...uint64) []uint64 { // 構(gòu)建 map set。 m := make(map[uint64]struct{}, len(elms)) for _, v := range elms { m[v] = struct{}{} } // 創(chuàng)建新切片,過(guò)濾掉指定元素。 t := make([]uint64, 0, len(i)) for _, v := range i { if _, ok := m[v]; !ok { t = append(t, v) } } return t}
下面看一下二者的性能對(duì)比。
func BenchmarkDeleteSliceElms(b *testing.B) { slice := []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9} elms := []interface{}{uint64(1), uint64(3), uint64(5), uint64(7), uint64(9)} for i := 0; i < b.N; i { _ = DeleteSliceElms(slice, elms...) }}func BenchmarkDeleteU64liceElms(b *testing.B) { slice := []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9} elms := []uint64{1, 3, 5, 7, 9} for i := 0; i < b.N; i { _ = DeleteU64liceElms(slice, elms...) }}
運(yùn)行上面的基準(zhǔn)測(cè)試。
go test -bench=. -benchmem main/reflect goos: darwingoarch: amd64pkg: main/reflectcpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHzBenchmarkDeleteSliceElms-12 1226868 978.2 ns/op 296 B/op 16 allocs/opBenchmarkDeleteU64liceElms-12 8249469 145.3 ns/op 80 B/op 1 allocs/opPASSok main/reflect 3.809s
可以看到,反射涉及了額外的類(lèi)型判斷和大量的內(nèi)存分配,導(dǎo)致其對(duì)性能的影響非常明顯。隨著切片元素的遞增,每一次判斷元素是否在 map 中,因?yàn)?map 的 key 是不確定的類(lèi)型,會(huì)發(fā)生變量逃逸,觸發(fā)堆內(nèi)存的分配。所以,可預(yù)見(jiàn)的是當(dāng)元素?cái)?shù)量增加時(shí),性能差異會(huì)越來(lái)大。
當(dāng)使用反射時(shí),請(qǐng)問(wèn)一下自己,我真地需要它嗎?
1.3 慎用 binary.Read 和 binary.Write
binary.Read 和 binary.Write 使用反射并且很慢。如果有需要用到這兩個(gè)函數(shù)的地方,我們應(yīng)該手動(dòng)實(shí)現(xiàn)這兩個(gè)函數(shù)的相關(guān)功能,而不是直接去使用它們。
encoding/binary 包實(shí)現(xiàn)了數(shù)字和字節(jié)序列之間的簡(jiǎn)單轉(zhuǎn)換以及 varints 的編碼和解碼。varints 是一種使用可變字節(jié)表示整數(shù)的方法。其中數(shù)值本身越小,其所占用的字節(jié)數(shù)越少。Protocol Buffers 對(duì)整數(shù)采用的便是這種編碼方式。
其中數(shù)字與字節(jié)序列的轉(zhuǎn)換可以用如下三個(gè)函數(shù):
// Read 從結(jié)構(gòu)化二進(jìn)制數(shù)據(jù) r 讀取到 data。data 必須是指向固定大小值的指針或固定大小值的切片。func Read(r io.Reader, order byteOrder, data interface{}) error// Write 將 data 的二進(jìn)制表示形式寫(xiě)入 w。data 必須是固定大小的值或固定大小值的切片,或指向此類(lèi)數(shù)據(jù)的指針。func Write(w io.Writer, order ByteOrder, data interface{}) error// Size 返回 Wirte 函數(shù)將 v 寫(xiě)入到 w 中的字節(jié)數(shù)。func Size(v interface{}) int
下面以我們熟知的 C 標(biāo)準(zhǔn)庫(kù)函數(shù) ntohl() 函數(shù)為例,看看 Go 利用 binary 包如何實(shí)現(xiàn)。
// Ntohl 將網(wǎng)絡(luò)字節(jié)序的 uint32 轉(zhuǎn)為主機(jī)字節(jié)序。func Ntohl(bys []byte) uint32 { r := bytes.NewReader(bys) err = binary.Read(buf, binary.BigEndian, &num)}// 如將 IP 127.0.0.1 網(wǎng)絡(luò)字節(jié)序解析到 uint32fmt.Println(Ntohl([]byte{0x7f, 0, 0, 0x1})) // 2130706433 <nil>
如果我們針對(duì) uint32 類(lèi)型手動(dòng)實(shí)現(xiàn)一個(gè) ntohl() 呢?
func NtohlNotUseBinary(bys []byte) uint32 { return uint32(bys[3]) | uint32(bys[2])<<8 | uint32(bys[1])<<16 | uint32(bys[0])<<24}// 如將 IP 127.0.0.1 網(wǎng)絡(luò)字節(jié)序解析到 uint32fmt.Println(NtohlNotUseBinary([]byte{0x7f, 0, 0, 0x1})) // 2130706433
該函數(shù)也是參考了 encoding/binary 包針對(duì)大端字節(jié)序?qū)⒆止?jié)序列轉(zhuǎn)為 uint32 類(lèi)型時(shí)的實(shí)現(xiàn)。
下面看下剝?nèi)シ瓷淝昂蠖叩男阅懿町悺?/span>
func BenchmarkNtohl(b *testing.B) { for i := 0; i < b.N; i { _, _ = Ntohl([]byte{0x7f, 0, 0, 0x1}) }}func BenchmarkNtohlNotUseBinary(b *testing.B) { for i := 0; i < b.N; i { _ = NtohlNotUseBinary([]byte{0x7f, 0, 0, 0x1}) }}
運(yùn)行上面的基準(zhǔn)測(cè)試,結(jié)果如下:
go test -bench=BenchmarkNtohl.* -benchmem main/reflectgoos: darwingoarch: amd64pkg: main/reflectcpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHzBenchmarkNtohl-12 13026195 81.96 ns/op 60 B/op 4 allocs/opBenchmarkNtohlNotUseBinary-12 1000000000 0.2511 ns/op 0 B/op 0 allocs/opPASSok main/reflect 1.841s
可見(jiàn)使用反射實(shí)現(xiàn)的 encoding/binary 包的性能相較于針對(duì)具體類(lèi)型實(shí)現(xiàn)的版本,性能差異非常大。
2.避免重復(fù)的字符串到字節(jié)切片的轉(zhuǎn)換
不要反復(fù)從固定字符串創(chuàng)建字節(jié) slice,因?yàn)橹貜?fù)的切片初始化會(huì)帶來(lái)性能損耗。相反,請(qǐng)執(zhí)行一次轉(zhuǎn)換并捕獲結(jié)果。
// Badfor i := 0; i < b.N; i { w.Write([]byte("Hello world"))}BenchmarkBad-4 50000000 22.2 ns/op// Gooddata := []byte("Hello world")for i := 0; i < b.N; i { w.Write(data)}BenchmarkGood-4 500000000 3.25 ns/op
3.指定容器容量
盡可能指定容器容量,以便為容器預(yù)先分配內(nèi)存。這將在后續(xù)添加元素時(shí)減少通過(guò)復(fù)制來(lái)調(diào)整容器大小。
3.1 指定 map 容量提示
在盡可能的情況下,在使用 make() 初始化的時(shí)候提供容量信息。
make(map[T1]T2, hint)
向 make() 提供容量提示會(huì)在初始化時(shí)嘗試調(diào)整 map 的大小,這將減少在將元素添加到 map 時(shí)為 map 重新分配內(nèi)存。
注意,與 slice 不同。map capacity 提示并不保證完全的搶占式分配,而是用于估計(jì)所需的 hashmap bucket 的數(shù)量。 因此,在將元素添加到 map 時(shí),甚至在指定 map 容量時(shí),仍可能發(fā)生分配。
// Badm := make(map[string]os.FileInfo)files, _ := ioutil.ReadDir("./files")for _, f := range files { m[f.Name()] = f}// m 是在沒(méi)有大小提示的情況下創(chuàng)建的; 在運(yùn)行時(shí)可能會(huì)有更多分配。// Goodfiles, _ := ioutil.ReadDir("./files")m := make(map[string]os.FileInfo, len(files))for _, f := range files { m[f.Name()] = f}// m 是有大小提示創(chuàng)建的;在運(yùn)行時(shí)可能會(huì)有更少的分配。
3.2 指定切片容量
在盡可能的情況下,在使用 make() 初始化切片時(shí)提供容量信息,特別是在追加切片時(shí)。
make([]T, length, capacity)
與 map 不同,slice capacity 不是一個(gè)提示:編譯器將為提供給 make() 的 slice 的容量分配足夠的內(nèi)存,這意味著后續(xù)的 append() 操作將導(dǎo)致零分配(直到 slice 的長(zhǎng)度與容量匹配,在此之后,任何 append 都可能調(diào)整大小以容納其他元素)。
const size = 1000000// Badfor n := 0; n < b.N; n { data := make([]int, 0) for k := 0; k < size; k { data = append(data, k) }}BenchmarkBad-4 219 5202179 ns/op// Goodfor n := 0; n < b.N; n { data := make([]int, 0, size) for k := 0; k < size; k { data = append(data, k) }}BenchmarkGood-4 706 1528934 ns/op
執(zhí)行基準(zhǔn)測(cè)試:
go test -bench=^BenchmarkJoinStr -benchmem BenchmarkJoinStrWithOperator-8 66930670 17.81 ns/op 0 B/op 0 allocs/opBenchmarkJoinStrWithSprintf-8 7032921 166.0 ns/op 64 B/op 4 allocs/op
4.字符串拼接方式的選擇
4.1 行內(nèi)拼接字符串推薦使用運(yùn)算符
行內(nèi)拼接字符串為了書(shū)寫(xiě)方便快捷,最常用的兩個(gè)方法是:
- 運(yùn)算符
- fmt.Sprintf()
行內(nèi)字符串的拼接,主要追求的是代碼的簡(jiǎn)潔可讀。fmt.Sprintf() 能夠接收不同類(lèi)型的入?yún)?,通過(guò)格式化輸出完成字符串的拼接,使用非常方便。但因其底層實(shí)現(xiàn)使用了反射,性能上會(huì)有所損耗。
運(yùn)算符 只能簡(jiǎn)單地完成字符串之間的拼接,非字符串類(lèi)型的變量需要單獨(dú)做類(lèi)型轉(zhuǎn)換。行內(nèi)拼接字符串不會(huì)產(chǎn)生內(nèi)存分配,也不涉及類(lèi)型地動(dòng)態(tài)轉(zhuǎn)換,所以性能上優(yōu)于fmt.Sprintf()。
從性能出發(fā),兼顧易用可讀,如果待拼接的變量不涉及類(lèi)型轉(zhuǎn)換且數(shù)量較少(<=5),行內(nèi)拼接字符串推薦使用運(yùn)算符 ,反之使用 fmt.Sprintf()。
下面看下二者的性能對(duì)比。
// Goodfunc BenchmarkJoinStrWithOperator(b *testing.B) { s1, s2, s3 := "foo", "bar", "baz" for i := 0; i < b.N; i { _ = s1 s2 s3 }}// Badfunc BenchmarkJoinStrWithSprintf(b *testing.B) { s1, s2, s3 := "foo", "bar", "baz" for i := 0; i < b.N; i { _ = fmt.Sprintf("%s%s%s", s1, s2, s3) }}
執(zhí)行基準(zhǔn)測(cè)試結(jié)果如下:
go test -bench=^BenchmarkJoinStr -benchmem .BenchmarkJoinStrWithOperator-8 70638928 17.53 ns/op 0 B/op 0 allocs/opBenchmarkJoinStrWithSprintf-8 7520017 157.2 ns/op 64 B/op 4 allocs/op
4.2 非行內(nèi)拼接字符串推薦使用 strings.Builder
字符串拼接還有其他的方式,比如strings.Join()、strings.Builder、bytes.Buffer和byte[],這幾種不適合行內(nèi)使用。當(dāng)待拼接字符串?dāng)?shù)量較多時(shí)可考慮使用。
先看下其性能測(cè)試的對(duì)比。
func BenchmarkJoinStrWithStringsJoin(b *testing.B) { s1, s2, s3 := "foo", "bar", "baz" for i := 0; i < b.N; i { _ = strings.Join([]string{s1, s2, s3}, "") }}func BenchmarkJoinStrWithStringsBuilder(b *testing.B) { s1, s2, s3 := "foo", "bar", "baz" for i := 0; i < b.N; i { var builder strings.Builder _, _ = builder.WriteString(s1) _, _ = builder.WriteString(s2) _, _ = builder.WriteString(s3) }}func BenchmarkJoinStrWithBytesBuffer(b *testing.B) { s1, s2, s3 := "foo", "bar", "baz" for i := 0; i < b.N; i { var buffer bytes.Buffer _, _ = buffer.WriteString(s1) _, _ = buffer.WriteString(s2) _, _ = buffer.WriteString(s3) }}func BenchmarkJoinStrWithByteSlice(b *testing.B) { s1, s2, s3 := "foo", "bar", "baz" for i := 0; i < b.N; i { var bys []byte bys= append(bys, s1...) bys= append(bys, s2...) _ = append(bys, s3...) }}func BenchmarkJoinStrWithByteSlicePreAlloc(b *testing.B) { s1, s2, s3 := "foo", "bar", "baz" for i := 0; i < b.N; i { bys:= make([]byte, 0, 9) bys= append(bys, s1...) bys= append(bys, s2...) _ = append(bys, s3...) }}
基準(zhǔn)測(cè)試結(jié)果如下:
go test -bench=^BenchmarkJoinStr .goos: windowsgoarch: amd64pkg: main/perfcpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHzBenchmarkJoinStrWithStringsJoin-8 31543916 36.39 ns/opBenchmarkJoinStrWithStringsBuilder-8 30079785 40.60 ns/opBenchmarkJoinStrWithBytesBuffer-8 31663521 39.58 ns/opBenchmarkJoinStrWithByteSlice-8 30748495 37.34 ns/opBenchmarkJoinStrWithByteSlicePreAlloc-8 665341896 1.813 ns/op
從結(jié)果可以看出,strings.Join()、strings.Builder、bytes.Buffer和byte[] 的性能相近。如果結(jié)果字符串的長(zhǎng)度是可預(yù)知的,使用 byte[] 且預(yù)先分配容量的拼接方式性能最佳。
所以如果對(duì)性能要求非常嚴(yán)格,或待拼接的字符串?dāng)?shù)量足夠多時(shí),建議使用 byte[] 預(yù)先分配容量這種方式。
綜合易用性和性能,一般推薦使用strings.Builder來(lái)拼接字符串。
string.Builder也提供了預(yù)分配內(nèi)存的方式 Grow:
func BenchmarkJoinStrWithStringsBuilderPreAlloc(b *testing.B) { s1, s2, s3 := "foo", "bar", "baz" for i := 0; i < b.N; i { var builder strings.Builder builder.Grow(9) _, _ = builder.WriteString(s1) _, _ = builder.WriteString(s2) _, _ = builder.WriteString(s3) }}
使用了 Grow 優(yōu)化后的版本的性能測(cè)試結(jié)果如下??梢钥闯鱿噍^于不預(yù)先分配空間的方式,性能提升了很多。
BenchmarkJoinStrWithStringsBuilderPreAlloc-8 60079003 20.95 ns/op
5.遍歷 []struct{} 使用下標(biāo)而不是 range
Go 中遍歷切片或數(shù)組有兩種方式,一種是通過(guò)下標(biāo),一種是 range。二者在功能上沒(méi)有區(qū)別,但是在性能上會(huì)有區(qū)別嗎?
5.1 []int
首先看一下遍歷基本類(lèi)型切片時(shí)二者的性能差別,以 []int 為例。
// genRandomIntSlice 生成指定長(zhǎng)度的隨機(jī) []int 切片func genRandomIntSlice(n int) []int { rand.Seed(time.Now().UnixNano()) nums := make([]int, 0, n) for i := 0; i < n; i { nums = append(nums, rand.Int()) } return nums}func BenchmarkIndexIntSlice(b *testing.B) { nums := genRandomIntSlice(1024) for i := 0; i < b.N; i { var tmp int for k := 0; k < len(nums); k { tmp = nums[k] } _ = tmp }}func BenchmarkRangeIntSlice(b *testing.B) { nums := genRandomIntSlice(1024) for i := 0; i < b.N; i { var tmp int for _, num := range nums { tmp = num } _ = tmp }}
運(yùn)行測(cè)試結(jié)果如下:
go test -bench=IntSlice$ .goos: windowsgoarch: amd64pkg: main/perfcpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHzBenchmarkIndexIntSlice-8 5043324 236.2 ns/opBenchmarkRangeIntSlice-8 5076255 239.1 ns/op
genRandomIntSlice() 函數(shù)用于生成指定長(zhǎng)度元素類(lèi)型為 int 的切片。從最終的結(jié)果可以看到,遍歷 []int 類(lèi)型的切片,下標(biāo)與 range 遍歷性能幾乎沒(méi)有區(qū)別。
5.2 []struct{}
那么對(duì)于稍微復(fù)雜一點(diǎn)的 []struct 類(lèi)型呢?
type Item struct { id int val [1024]byte}func BenchmarkIndexStructSlice(b *testing.B) { var items [1024]Item for i := 0; i < b.N; i { var tmp int for j := 0; j < len(items); j { tmp = items[j].id } _ = tmp }}func BenchmarkRangeIndexStructSlice(b *testing.B) { var items [1024]Item for i := 0; i < b.N; i { var tmp int for k := range items { tmp = items[k].id } _ = tmp }}func BenchmarkRangeStructSlice(b *testing.B) { var items [1024]Item for i := 0; i < b.N; i { var tmp int for _, item := range items { tmp = item.id } _ = tmp }}
運(yùn)行測(cè)試結(jié)果如下:
go test -bench=StructSlice$ .goos: windowsgoarch: amd64pkg: main/perfcpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHzBenchmarkIndexStructSlice-8 5079468 234.9 ns/opBenchmarkRangeIndexStructSlice-8 5087448 236.2 ns/opBenchmarkRangeStructSlice-8 38716 32265 ns/op
可以看出,兩種通過(guò) index 遍歷 []struct 性能沒(méi)有差別,但是 range 遍歷 []struct 中元素時(shí),性能非常差。
range 只遍歷 []struct 下標(biāo)時(shí),性能比 range 遍歷 []struct 值好很多。從這里我們應(yīng)該能夠知道二者性能差別之大的原因。
Item 是一個(gè)結(jié)構(gòu)體類(lèi)型 ,Item 由兩個(gè)字段構(gòu)成,一個(gè)類(lèi)型是 int,一個(gè)是類(lèi)型是 [1024]byte,如果每次遍歷 []Item,都會(huì)進(jìn)行一次值拷貝,所以帶來(lái)了性能損耗。
此外,因?yàn)?range 時(shí)獲取的是值拷貝的副本,所以對(duì)副本的修改,是不會(huì)影響到原切片。
5.3 []*struct
那如果切片中是指向結(jié)構(gòu)體的指針,而不是結(jié)構(gòu)體呢?
// genItems 生成指定長(zhǎng)度 []*Item 切片func genItems(n int) []*Item { items := make([]*Item, 0, n) for i := 0; i < n; i { items = append(items, &Item{id: i}) } return items}func BenchmarkIndexPointer(b *testing.B) { items := genItems(1024) for i := 0; i < b.N; i { var tmp int for k := 0; k < len(items); k { tmp = items[k].id } _ = tmp }}func BenchmarkRangePointer(b *testing.B) { items := genItems(1024) for i := 0; i < b.N; i { var tmp int for _, item := range items { tmp = item.id } _ = tmp }}
執(zhí)行性能測(cè)試結(jié)果:
go test -bench=Pointer$ main/perfgoos: windowsgoarch: amd64pkg: main/perfcpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHzBenchmarkIndexPointer-8 773634 1521 ns/opBenchmarkRangePointer-8 752077 1514 ns/op
切片元素從結(jié)構(gòu)體 Item 替換為指針 *Item 后,for 和 range 的性能幾乎是一樣的。而且使用指針還有另一個(gè)好處,可以直接修改指針對(duì)應(yīng)的結(jié)構(gòu)體的值。
5.4 小結(jié)
range 在迭代過(guò)程中返回的是元素的拷貝,index 則不存在拷貝。
如果 range 迭代的元素較小,那么 index 和 range 的性能幾乎一樣,如基本類(lèi)型的切片 []int。但如果迭代的元素較大,如一個(gè)包含很多屬性的 struct 結(jié)構(gòu)體,那么 index 的性能將顯著地高于 range,有時(shí)候甚至?xí)猩锨П兜男阅懿町?。?duì)于這種場(chǎng)景,建議使用 index。如果使用 range,建議只迭代下標(biāo),通過(guò)下標(biāo)訪問(wèn)元素,這種使用方式和 index 就沒(méi)有區(qū)別了。如果想使用 range 同時(shí)迭代下標(biāo)和值,則需要將切片/數(shù)組的元素改為指針,才能不影響性能。
內(nèi)存管理
1.使用空結(jié)構(gòu)體節(jié)省內(nèi)存
1.1 不占內(nèi)存空間
在 Go 中,我們可以使用 unsafe.Sizeof 計(jì)算出一個(gè)數(shù)據(jù)類(lèi)型實(shí)例需要占用的字節(jié)數(shù)。
package mainimport ( "fmt" "unsafe")func main() { fmt.Println(unsafe.Sizeof(struct{}{}))}
運(yùn)行上面的例子將會(huì)輸出:
go run main.go0
可以看到,Go 中空結(jié)構(gòu)體 struct{} 是不占用內(nèi)存空間,不像 C/C 中空結(jié)構(gòu)體仍占用 1 字節(jié)。
1.2 用法
因?yàn)榭战Y(jié)構(gòu)體不占據(jù)內(nèi)存空間,因此被廣泛作為各種場(chǎng)景下的占位符使用。一是節(jié)省資源,二是空結(jié)構(gòu)體本身就具備很強(qiáng)的語(yǔ)義,即這里不需要任何值,僅作為占位符,達(dá)到的代碼即注釋的效果。
1.2.1 實(shí)現(xiàn)集合(Set)
Go 語(yǔ)言標(biāo)準(zhǔn)庫(kù)沒(méi)有提供 Set 的實(shí)現(xiàn),通常使用 map 來(lái)代替。事實(shí)上,對(duì)于集合來(lái)說(shuō),只需要 map 的鍵,而不需要值。即使是將值設(shè)置為 bool 類(lèi)型,也會(huì)多占據(jù) 1 個(gè)字節(jié),那假設(shè) map 中有一百萬(wàn)條數(shù)據(jù),就會(huì)浪費(fèi) 1MB 的空間。
因此呢,將 map 作為集合(Set)使用時(shí),可以將值類(lèi)型定義為空結(jié)構(gòu)體,僅作為占位符使用即可。
type Set map[string]struct{}func (s Set) Has(key string) bool { _, ok := s[key] return ok}func (s Set) Add(key string) { s[key] = struct{}{}}func (s Set) Delete(key string) { delete(s, key)}func main() { s := make(Set) s.Add("foo") s.Add("bar") fmt.Println(s.Has("foo")) fmt.Println(s.Has("bar"))}
如果想使用 Set 的完整功能,如初始化(通過(guò)切片構(gòu)建一個(gè) Set)、Add、Del、Clear、Contains 等操作,可以使用開(kāi)源庫(kù) golang-set。
1.2.2 不發(fā)送數(shù)據(jù)的信道
func worker(ch chan struct{}) { <-ch fmt.Println("do something")}func main() { ch := make(chan struct{}) go worker(ch) ch <- struct{}{} close(ch)}
有時(shí)候使用 channel 不需要發(fā)送任何的數(shù)據(jù),只用來(lái)通知子協(xié)程(goroutine)執(zhí)行任務(wù),或只用來(lái)控制協(xié)程的并發(fā)。這種情況下,使用空結(jié)構(gòu)體作為占位符就非常合適了。
1.2.3 僅包含方法的結(jié)構(gòu)體
type Door struct{}func (d Door) Open() { fmt.Println("Open the door")}func (d Door) Close() { fmt.Println("Close the door")}
在部分場(chǎng)景下,結(jié)構(gòu)體只包含方法,不包含任何的字段。例如上面例子中的 Door,在這種情況下,Door 事實(shí)上可以用任何的數(shù)據(jù)結(jié)構(gòu)替代。
type Door inttype Door bool
無(wú)論是 int 還是 bool 都會(huì)浪費(fèi)額外的內(nèi)存,因此呢,這種情況下,聲明為空結(jié)構(gòu)體最合適。
2. struct 布局要考慮內(nèi)存對(duì)齊
2.1 為什么需要內(nèi)存對(duì)齊
CPU 訪問(wèn)內(nèi)存時(shí),并不是逐個(gè)字節(jié)訪問(wèn),而是以字長(zhǎng)(word size)為單位訪問(wèn)。比如 32 位的 CPU ,字長(zhǎng)為 4 字節(jié),那么 CPU 訪問(wèn)內(nèi)存的單位也是 4 字節(jié)。
這么設(shè)計(jì)的目的,是減少 CPU 訪問(wèn)內(nèi)存的次數(shù),加大 CPU 訪問(wèn)內(nèi)存的吞吐量。比如同樣讀取 8 個(gè)字節(jié)的數(shù)據(jù),一次讀取 4 個(gè)字節(jié)那么只需要讀取 2 次。
CPU 始終以字長(zhǎng)訪問(wèn)內(nèi)存,如果不進(jìn)行內(nèi)存對(duì)齊,很可能增加 CPU 訪問(wèn)內(nèi)存的次數(shù),例如:
變量 a、b 各占據(jù) 3 字節(jié)的空間,內(nèi)存對(duì)齊后,a、b 占據(jù) 4 字節(jié)空間,CPU 讀取 b 變量的值只需要進(jìn)行一次內(nèi)存訪問(wèn)。如果不進(jìn)行內(nèi)存對(duì)齊,CPU 讀取 b 變量的值需要進(jìn)行 2 次內(nèi)存訪問(wèn)。第一次訪問(wèn)得到 b 變量的第 1 個(gè)字節(jié),第二次訪問(wèn)得到 b 變量的后兩個(gè)字節(jié)。
從這個(gè)例子中也可以看到,內(nèi)存對(duì)齊對(duì)實(shí)現(xiàn)變量的原子性操作也是有好處的,每次內(nèi)存訪問(wèn)是原子的,如果變量的大小不超過(guò)字長(zhǎng),那么內(nèi)存對(duì)齊后,對(duì)該變量的訪問(wèn)就是原子的,這個(gè)特性在并發(fā)場(chǎng)景下至關(guān)重要。
簡(jiǎn)言之:合理的內(nèi)存對(duì)齊可以提高內(nèi)存讀寫(xiě)的性能,并且便于實(shí)現(xiàn)變量操作的原子性。
2.2 Go 內(nèi)存對(duì)齊規(guī)則
編譯器一般為了減少 CPU 訪存指令周期,提高內(nèi)存的訪問(wèn)效率,會(huì)對(duì)變量進(jìn)行內(nèi)存對(duì)齊。Go 作為一門(mén)追求高性能的后臺(tái)編程語(yǔ)言,當(dāng)然也不例外。
Go Language Specification 中 Size and alignment guarantees 描述了內(nèi)存對(duì)齊的規(guī)則。
1.For a variable x of any type: unsafe.Alignof(x) is at least 1. 2.For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1. 3.For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array's element type.
- 對(duì)于任意類(lèi)型的變量 x ,unsafe.Alignof(x) 至少為 1。
- 對(duì)于結(jié)構(gòu)體類(lèi)型的變量 x,計(jì)算 x 每一個(gè)字段 f 的 unsafe.Alignof(x.f),unsafe.Alignof(x) 等于其中的最大值。
- 對(duì)于數(shù)組類(lèi)型的變量 x,unsafe.Alignof(x) 等于構(gòu)成數(shù)組的元素類(lèi)型的對(duì)齊系數(shù)。
其中函數(shù) unsafe.Alignof 用于獲取變量的對(duì)齊系數(shù)。 對(duì)齊系數(shù)決定了字段的偏移和變量的大小,兩者必須是對(duì)齊系數(shù)的整數(shù)倍。
2.3 合理的 struct 布局
因?yàn)閮?nèi)存對(duì)齊的存在,合理的 struct 布局可以減少內(nèi)存占用,提高程序性能。
type demo1 struct { a int8 b int16 c int32}type demo2 struct { a int8 c int32 b int16}func main() { fmt.Println(unsafe.Sizeof(demo1{})) // 8 fmt.Println(unsafe.Sizeof(demo2{})) // 12}
可以看到,同樣的字段,因字段排列順序不同,最終會(huì)導(dǎo)致不一樣的結(jié)構(gòu)體大小。
每個(gè)字段按照自身的對(duì)齊系數(shù)來(lái)確定在內(nèi)存中的偏移量,一個(gè)字段因偏移而浪費(fèi)的大小也不同。
接下來(lái)逐個(gè)分析,首先是 demo1: a 是第一個(gè)字段,默認(rèn)是已經(jīng)對(duì)齊的,從第 0 個(gè)位置開(kāi)始占據(jù) 1 字節(jié)。 b 是第二個(gè)字段,對(duì)齊系數(shù)為 2,因此,必須空出 1 個(gè)字節(jié),偏移量才是 2 的倍數(shù),從第 2 個(gè)位置開(kāi)始占據(jù) 2 字節(jié)。 c 是第三個(gè)字段,對(duì)齊倍數(shù)為 4,此時(shí),內(nèi)存已經(jīng)是對(duì)齊的,從第 4 個(gè)位置開(kāi)始占據(jù) 4 字節(jié)即可。
因此 demo1 的內(nèi)存占用為 8 字節(jié)。
對(duì)于 demo2: a 是第一個(gè)字段,默認(rèn)是已經(jīng)對(duì)齊的,從第 0 個(gè)位置開(kāi)始占據(jù) 1 字節(jié)。 c 是第二個(gè)字段,對(duì)齊倍數(shù)為 4,因此,必須空出 3 個(gè)字節(jié),偏移量才是 4 的倍數(shù),從第 4 個(gè)位置開(kāi)始占據(jù) 4 字節(jié)。 b 是第三個(gè)字段,對(duì)齊倍數(shù)為 2,從第 8 個(gè)位置開(kāi)始占據(jù) 2 字節(jié)。
demo2 的對(duì)齊系數(shù)由 c 的對(duì)齊系數(shù)決定,也是 4,因此,demo2 的內(nèi)存占用為 12 字節(jié)。
因此,在對(duì)內(nèi)存特別敏感的結(jié)構(gòu)體的設(shè)計(jì)上,我們可以通過(guò)調(diào)整字段的順序,將字段寬度從小到大由上到下排列,來(lái)減少內(nèi)存的占用。
2.4 空結(jié)構(gòu)與空數(shù)組對(duì)內(nèi)存對(duì)齊的影響
空結(jié)構(gòu)與空數(shù)組在 Go 中比較特殊。沒(méi)有任何字段的空 struct{} 和沒(méi)有任何元素的 array 占據(jù)的內(nèi)存空間大小為 0。
因?yàn)檫@一點(diǎn),空 struct{} 或空 array 作為其他 struct 的字段時(shí),一般不需要內(nèi)存對(duì)齊。但是有一種情況除外:即當(dāng) struct{} 或空 array 作為結(jié)構(gòu)體最后一個(gè)字段時(shí),需要內(nèi)存對(duì)齊。因?yàn)槿绻兄羔樦赶蛟撟侄?,返回的地址將在結(jié)構(gòu)體之外,如果此指針一直存活不釋放對(duì)應(yīng)的內(nèi)存,就會(huì)有內(nèi)存泄露的問(wèn)題(該內(nèi)存不因結(jié)構(gòu)體釋放而釋放)。
type demo3 struct { a struct{} b int32}type demo4 struct { b int32 a struct{}}func main() { fmt.Println(unsafe.Sizeof(demo3{})) // 4 fmt.Println(unsafe.Sizeof(demo4{})) // 8}
可以看到,demo3{} 的大小為 4 字節(jié),與字段 b 占據(jù)空間一致,而 demo4{} 的大小為 8 字節(jié),即額外填充了 4 字節(jié)的空間。
3.減少逃逸,將變量限制在棧上
變量逃逸一般發(fā)生在如下幾種情況:
- 變量較大
- 變量大小不確定
- 變量類(lèi)型不確定
- 返回指針
- 返回引用
- 閉包
知道變量逃逸的原因后,我們可以有意識(shí)的控制變量不發(fā)生逃逸,將其控制在棧上,減少堆變量的分配,降低 GC 成本,提高程序性能。
3.1 小的拷貝好過(guò)引用
小的拷貝好過(guò)引用,什么意思呢,就是盡量使用棧變量而不是堆變量。下面舉一個(gè)反常識(shí)的例子,來(lái)證明小的拷貝比在堆上創(chuàng)建引用變量要好。
我們都知道 Go 里面的 Array 以 pass-by-value 方式傳遞后,再加上其長(zhǎng)度不可擴(kuò)展,考慮到性能我們一般很少使用它。實(shí)際上,凡事無(wú)絕對(duì)。有時(shí)使用數(shù)組進(jìn)行拷貝傳遞,比使用切片要好。
// copy/copy.goconst capacity = 1024func arrayFibonacci() [capacity]int { var d [capacity]int for i := 0; i < len(d); i { if i <= 1 { d[i] = 1 continue } d[i] = d[i-1] d[i-2] } return d}func sliceFibonacci() []int { d := make([]int, capacity) for i := 0; i < len(d); i { if i <= 1 { d[i] = 1 continue } d[i] = d[i-1] d[i-2] } return d}
下面看一下性能對(duì)比。
func BenchmarkArray(b *testing.B) { for i := 0; i < b.N; i { _ = arrayFibonacci() }}func BenchmarkSlice(b *testing.B) { for i := 0; i < b.N; i { _ = sliceFibonacci() }}
運(yùn)行上面的基準(zhǔn)測(cè)試,將得到如下結(jié)果。
go test -bench=. -benchmem -gcflags="-l" main/copygoos: darwingoarch: amd64pkg: main/copycpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHzBenchmarkArray-12 692400 1708 ns/op 0 B/op 0 allocs/opBenchmarkSlice-12 464974 2242 ns/op 8192 B/op 1 allocs/opPASSok main/copy 3.908s
從測(cè)試結(jié)果可以看出,對(duì)數(shù)組的拷貝性能卻比使用切片要好。為什么會(huì)這樣呢?
sliceFibonacci() 函數(shù)中分配的局部變量切片因?yàn)橐祷氐胶瘮?shù)外部,所以發(fā)生了逃逸,需要在堆上申請(qǐng)內(nèi)存空間。從測(cè)試也過(guò)也可以看出,arrayFibonacci() 函數(shù)沒(méi)有內(nèi)存分配,完全在棧上完成數(shù)組的創(chuàng)建。這里說(shuō)明了對(duì)于一些短小的對(duì)象,棧上復(fù)制的成本遠(yuǎn)小于在堆上分配和回收操作。
需要注意,運(yùn)行上面基準(zhǔn)測(cè)試時(shí),傳遞了禁止內(nèi)聯(lián)的編譯選項(xiàng) "-l",如果發(fā)生內(nèi)聯(lián),那么將不會(huì)出現(xiàn)變量的逃逸,就不存在堆上分配內(nèi)存與回收的操作了,二者將看不出性能差異。
編譯時(shí)可以借助選項(xiàng) -gcflags=-m 查看編譯器對(duì)上面兩個(gè)函數(shù)的優(yōu)化決策。
go build -gcflags=-m copy/copy.go# command-line-argumentscopy/copy.go:5:6: can inline arrayFibonaccicopy/copy.go:17:6: can inline sliceFibonaccicopy/copy.go:18:11: make([]int, capacity) escapes to heap
可以看到,arrayFibonacci() 和 sliceFibonacci() 函數(shù)均可內(nèi)聯(lián)。sliceFibonacci() 函數(shù)中定義的局部變量切片逃逸到了堆。
那么多大的變量才算是小變量呢?對(duì) Go 編譯器而言,超過(guò)一定大小的局部變量將逃逸到堆上,不同的 Go 版本的大小限制可能不一樣。一般是 <64KB,局部變量將不會(huì)逃逸到堆上。
3.2 返回值 VS 返回指針
值傳遞會(huì)拷貝整個(gè)對(duì)象,而指針傳遞只會(huì)拷貝地址,指向的對(duì)象是同一個(gè)。返回指針可以減少值的拷貝,但是會(huì)導(dǎo)致內(nèi)存分配逃逸到堆中,增加垃圾回收(GC)的負(fù)擔(dān)。在對(duì)象頻繁創(chuàng)建和刪除的場(chǎng)景下,傳遞指針導(dǎo)致的 GC 開(kāi)銷(xiāo)可能會(huì)嚴(yán)重影響性能。
一般情況下,對(duì)于需要修改原對(duì)象值,或占用內(nèi)存比較大的結(jié)構(gòu)體,選擇返回指針。對(duì)于只讀的占用內(nèi)存較小的結(jié)構(gòu)體,直接返回值能夠獲得更好的性能。
3.3 返回值使用確定的類(lèi)型
如果變量類(lèi)型不確定,那么將會(huì)逃逸到堆上。所以,函數(shù)返回值如果能確定的類(lèi)型,就不要使用 interface{}。
我們還是以上面斐波那契數(shù)列函數(shù)為例,看下返回值為確定類(lèi)型和 interface{} 的性能差別。
const capacity = 1024func arrayFibonacci() [capacity]int { var d [capacity]int for i := 0; i < len(d); i { if i <= 1 { d[i] = 1 continue } d[i] = d[i-1] d[i-2] } return d}func arrayFibonacciIfc() interface{} { var d [capacity]int for i := 0; i < len(d); i { if i <= 1 { d[i] = 1 continue } d[i] = d[i-1] d[i-2] } return d}func BenchmarkArray(b *testing.B) { for i := 0; i < b.N; i { _ = arrayFibonacci() }}func BenchmarkIfc(b *testing.B) { for i := 0; i < b.N; i { _ = arrayFibonacciIfc() }}
運(yùn)行上面的基準(zhǔn)測(cè)試結(jié)果如下:
go test -bench=. -benchmem main/copygoos: darwingoarch: amd64pkg: main/copycpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHzBenchmarkArray-12 832418 1427 ns/op 0 B/op 0 allocs/opBenchmarkIfc-12 380626 2861 ns/op 8192 B/op 1 allocs/opPASSok main/copy 3.742s
可見(jiàn),函數(shù)返回值使用 interface{} 返回時(shí),編譯器無(wú)法確定返回值的具體類(lèi)型,導(dǎo)致返回值逃逸到堆上。當(dāng)發(fā)生了堆上內(nèi)存的申請(qǐng)與回收時(shí),性能會(huì)差一點(diǎn)。
4.sync.Pool 復(fù)用對(duì)象
4.1 簡(jiǎn)介
sync.Pool 是 sync 包下的一個(gè)組件,可以作為保存臨時(shí)取還對(duì)象的一個(gè)“池子”。個(gè)人覺(jué)得它的名字有一定的誤導(dǎo)性,因?yàn)?Pool 里裝的對(duì)象可以被無(wú)通知地被回收,可能 sync.Cache 是一個(gè)更合適的名字。
sync.Pool 是可伸縮的,同時(shí)也是并發(fā)安全的,其容量?jī)H受限于內(nèi)存的大小。存放在池中的對(duì)象如果不活躍了會(huì)被自動(dòng)清理。
4.2 作用
對(duì)于很多需要重復(fù)分配、回收內(nèi)存的地方,sync.Pool 是一個(gè)很好的選擇。頻繁地分配、回收內(nèi)存會(huì)給 GC 帶來(lái)一定的負(fù)擔(dān),嚴(yán)重的時(shí)候會(huì)引起 CPU 的毛刺,而 sync.Pool 可以將暫時(shí)不用的對(duì)象緩存起來(lái),待下次需要的時(shí)候直接使用,不用再次經(jīng)過(guò)內(nèi)存分配,復(fù)用對(duì)象的內(nèi)存,減輕 GC 的壓力,提升系統(tǒng)的性能。
一句話(huà)總結(jié):用來(lái)保存和復(fù)用臨時(shí)對(duì)象,減少內(nèi)存分配,降低 GC 壓力。
4.3 如何使用
sync.Pool 的使用方式非常簡(jiǎn)單,只需要實(shí)現(xiàn) New 函數(shù)即可。對(duì)象池中沒(méi)有對(duì)象時(shí),將會(huì)調(diào)用 New 函數(shù)創(chuàng)建。
假設(shè)我們有一個(gè)“學(xué)生”結(jié)構(gòu)體,并復(fù)用改結(jié)構(gòu)體對(duì)象。
type Student struct { Name string Age int32 Remark [1024]byte}var studentPool = sync.Pool{ New: func() interface{} { return new(Student) },}
然后調(diào)用 Pool 的 Get() 和 Put() 方法來(lái)獲取和放回池子中。
stu := studentPool.Get().(*Student)json.Unmarshal(buf, stu)studentPool.Put(stu)
- Get() 用于從對(duì)象池中獲取對(duì)象,因?yàn)榉祷刂凳?interface{},因此需要類(lèi)型轉(zhuǎn)換。
- Put() 則是在對(duì)象使用完畢后,放回到對(duì)象池。
4.4 性能差異
我們以 bytes.Buffer 字節(jié)緩沖器為例,利用 sync.Pool 復(fù)用 bytes.Buffer 對(duì)象,避免重復(fù)創(chuàng)建與回收內(nèi)存,來(lái)看看對(duì)性能的提升效果。
var bufferPool = sync.Pool{ New: func() interface{} { return &bytes.Buffer{} },}var data = make([]byte, 10000)func BenchmarkBufferWithPool(b *testing.B) { for n := 0; n < b.N; n { buf := bufferPool.Get().(*bytes.Buffer) buf.Write(data) buf.Reset() bufferPool.Put(buf) }}func BenchmarkBuffer(b *testing.B) { for n := 0; n < b.N; n { var buf bytes.Buffer buf.Write(data) }}
測(cè)試結(jié)果如下:
go test -bench=. -benchmem main/poolgoos: darwingoarch: amd64pkg: main/poolcpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHzBenchmarkBufferWithPool-12 11987966 97.12 ns/op 0 B/op 0 allocs/opBenchmarkBuffer-12 1246887 1020 ns/op 10240 B/op 1 allocs/opPASSok main/pool 3.510s
這個(gè)例子創(chuàng)建了一個(gè) bytes.Buffer 對(duì)象池,每次只執(zhí)行 Write 操作,及做一次數(shù)據(jù)拷貝,耗時(shí)幾乎可以忽略。而內(nèi)存分配和回收的耗時(shí)占比較多,因此對(duì)程序整體的性能影響更大。從測(cè)試結(jié)果也可以看出,使用了 Pool 復(fù)用對(duì)象,每次操作不再有內(nèi)存分配。
4.5 在標(biāo)準(zhǔn)庫(kù)中的應(yīng)用
Go 標(biāo)準(zhǔn)庫(kù)也大量使用了 sync.Pool,例如 fmt 和 encoding/json。以 fmt 包為例,我們看下其是如何使用 sync.Pool 的。
我們可以看一下最常用的標(biāo)準(zhǔn)格式化輸出函數(shù) Printf() 函數(shù)。
// Printf formats according to a format specifier and writes to standard output.// It returns the number of bytes written and any write error encountered.func Printf(format string, a ...interface{}) (n int, err error) { return Fprintf(os.Stdout, format, a...)}
繼續(xù)看 Fprintf() 的定義。
// Fprintf formats according to a format specifier and writes to w.// It returns the number of bytes written and any write error encountered.func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) { p := newPrinter() p.doPrintf(format, a) n, err = w.Write(p.buf) p.free() return}
Fprintf() 函數(shù)的參數(shù)是一個(gè) io.Writer,Printf() 傳的是 os.Stdout,相當(dāng)于直接輸出到標(biāo)準(zhǔn)輸出。這里的 newPrinter 用的就是 sync.Pool。
// go version go1.17 darwin/amd64// pp is used to store a printer's state and is reused with sync.Pool to avoid allocations.type pp struct { buf buffer ...}var ppFree = sync.Pool{ New: func() interface{} { return new(pp) },}// newPrinter allocates a new pp struct or grabs a cached one.func newPrinter() *pp { p := ppFree.Get().(*pp) p.panicking = false p.erroring = false p.wrapErrs = false p.fmt.init(&p.buf) return p}// free saves used pp structs in ppFree; avoids an allocation per invocation.func (p *pp) free() { // Proper usage of a sync.Pool requires each entry to have approximately // the same memory cost. To obtain this property when the stored type // contains a variably-sized buffer, we add a hard limit on the maximum buffer // to place back in the pool. // // See https://golang.org/issue/23199 if cap(p.buf) > 64<<10 { return } p.buf = p.buf[:0] p.arg = nil p.value = reflect.Value{} p.wrappedErr = nil ppFree.Put(p)}
fmt.Printf() 的調(diào)用是非常頻繁的,利用 sync.Pool 復(fù)用 pp 對(duì)象能夠極大地提升性能,減少內(nèi)存占用,同時(shí)降低 GC 壓力。
并發(fā)編程
1.關(guān)于鎖
1.1 無(wú)鎖化
加鎖是為了避免在并發(fā)環(huán)境下,同時(shí)訪問(wèn)共享資源產(chǎn)生的安全問(wèn)題。那么,在并發(fā)環(huán)境下,是否必須加鎖?答案是否定的。并非所有的并發(fā)都需要加鎖。適當(dāng)?shù)亟档玩i的粒度,甚至采用無(wú)鎖化的設(shè)計(jì),更能提升并發(fā)能力。
無(wú)鎖化主要有兩種實(shí)現(xiàn),無(wú)鎖數(shù)據(jù)結(jié)構(gòu)和串行無(wú)鎖。
1.1.1 無(wú)鎖數(shù)據(jù)結(jié)構(gòu)
利用硬件支持的原子操作可以實(shí)現(xiàn)無(wú)鎖的數(shù)據(jù)結(jié)構(gòu),原子操作可以在 lock-free 的情況下保證并發(fā)安全,并且它的性能也能做到隨 CPU 個(gè)數(shù)的增多而線性擴(kuò)展。很多語(yǔ)言都提供 CAS 原子操作(如 Go 中的 atomic 包和 C 11 中的 atomic 庫(kù)),可以用于實(shí)現(xiàn)無(wú)鎖數(shù)據(jù)結(jié)構(gòu),如無(wú)鎖鏈表。
我們以一個(gè)簡(jiǎn)單的線程安全單向鏈表的插入操作來(lái)看下無(wú)鎖編程和普通加鎖的區(qū)別。
package listimport ( "fmt" "sync" "sync/atomic" "golang.org/x/sync/errgroup")// Node 鏈表節(jié)點(diǎn)type Node struct { Value interface{} Next *Node}//// 有鎖單向鏈表的簡(jiǎn)單實(shí)現(xiàn)//// WithLockList 有鎖單向鏈表type WithLockList struct { Head *Node mu sync.Mutex}// Push 將元素插入到鏈表的首部func (l *WithLockList) Push(v interface{}) { l.mu.Lock() defer l.mu.Unlock() n := &Node{ Value: v, Next: l.Head, } l.Head = n}// String 有鎖鏈表的字符串形式輸出func (l WithLockList) String() string { s := "" cur := l.Head for { if cur == nil { break } if s != "" { s = "," } s = fmt.Sprintf("%v", cur.Value) cur = cur.Next } return s}//// 無(wú)鎖單向鏈表的簡(jiǎn)單實(shí)現(xiàn)//// LockFreeList 無(wú)鎖單向鏈表type LockFreeList struct { Head atomic.Value}// Push 有鎖func (l *LockFreeList) Push(v interface{}) { for { head := l.Head.Load() headNode, _ := head.(*Node) n := &Node{ Value: v, Next: headNode, } if l.Head.CompareAndSwap(head, n) { break } }}// String 有鎖鏈表的字符串形式輸出func (l LockFreeList) String() string { s := "" cur := l.Head.Load().(*Node) for { if cur == nil { break } if s != "" { s = "," } s = fmt.Sprintf("%v", cur.Value) cur = cur.Next } return s}
上面的實(shí)現(xiàn)有幾點(diǎn)需要注意一下:
(1)無(wú)鎖單向鏈表實(shí)現(xiàn)時(shí)在插入時(shí)需要進(jìn)行 CAS 操作,即調(diào)用CompareAndSwap()方法進(jìn)行插入,如果插入失敗則進(jìn)行 for 循環(huán)多次嘗試,直至成功。
(2)為了方便打印鏈表內(nèi)容,實(shí)現(xiàn)一個(gè)String()方法遍歷鏈表,且使用值作為接收者,避免打印對(duì)象指針時(shí)無(wú)法生效。
- If an operand implements method String() string, that method will be invoked to convert the object to a string, which will then be formatted as required by the verb (if any).
我們分別對(duì)兩種鏈表做一個(gè)并發(fā)寫(xiě)入的操作驗(yàn)證一下其功能。
package mainimport ( "fmt" "main/list")// ConcurWriteWithLockList 并發(fā)寫(xiě)入有鎖鏈表func ConcurWriteWithLockList(l *WithLockList) { var g errgroup.Group // 10 個(gè)協(xié)程并發(fā)寫(xiě)入鏈表 for i := 0; i < 10; i { i := i g.Go(func() error { l.Push(i) return nil }) } _ = g.Wait()}// ConcurWriteLockFreeList 并發(fā)寫(xiě)入無(wú)鎖鏈表func ConcurWriteLockFreeList(l *LockFreeList) { var g errgroup.Group // 10 個(gè)協(xié)程并發(fā)寫(xiě)入鏈表 for i := 0; i < 10; i { i := i g.Go(func() error { l.Push(i) return nil }) } _ = g.Wait()}func main() { // 并發(fā)寫(xiě)入與遍歷打印有鎖鏈表 l1 := &list.WithLockList{} list.ConcurWriteWithLockList(l1) fmt.Println(l1) // 并發(fā)寫(xiě)入與遍歷打印無(wú)鎖鏈表 l2 := &list.LockFreeList{} list.ConcurWriteLockFreeList(l2) fmt.Println(l2)}
注意,多次運(yùn)行上面的main()函數(shù)的結(jié)果可能會(huì)不相同,因?yàn)椴l(fā)是無(wú)序的。
8,7,6,9,5,4,3,1,2,09,8,7,6,5,4,3,2,0,1
下面再看一下鏈表 Push 操作的基準(zhǔn)測(cè)試,對(duì)比一下有鎖與無(wú)鎖的性能差異。
func BenchmarkWriteWithLockList(b *testing.B) { l := &WithLockList{} for n := 0; n < b.N; n { l.Push(n) }}BenchmarkWriteWithLockList-8 14234166 83.58 ns/opfunc BenchmarkWriteLockFreeList(b *testing.B) { l := &LockFreeList{} for n := 0; n < b.N; n { l.Push(n) }}BenchmarkWriteLockFreeList-8 15219405 73.15 ns/op
可以看出無(wú)鎖版本比有鎖版本性能高一些。
1.1.2 串行無(wú)鎖
串行無(wú)鎖是一種思想,就是避免對(duì)共享資源的并發(fā)訪問(wèn),改為每個(gè)并發(fā)操作訪問(wèn)自己獨(dú)占的資源,達(dá)到串行訪問(wèn)資源的效果,來(lái)避免使用鎖。不同的場(chǎng)景有不同的實(shí)現(xiàn)方式。比如網(wǎng)絡(luò) I/O 場(chǎng)景下將單 Reactor 多線程模型改為主從 Reactor 多線程模型,避免對(duì)同一個(gè)消息隊(duì)列鎖讀取。
這里我介紹的是后臺(tái)微服務(wù)開(kāi)發(fā)經(jīng)常遇到的一種情況。我們經(jīng)常需要并發(fā)拉取多方面的信息,匯聚到一個(gè)變量上。那么此時(shí)就存在對(duì)同一個(gè)變量互斥寫(xiě)入的情況。比如批量并發(fā)拉取用戶(hù)信息寫(xiě)入到一個(gè) map。此時(shí)我們可以將每個(gè)協(xié)程拉取的結(jié)果寫(xiě)入到一個(gè)臨時(shí)對(duì)象,這樣便將并發(fā)地協(xié)程與同一個(gè)變量解綁,然后再將其匯聚到一起,這樣便可以不用使用鎖。即獨(dú)立處理,然后合并。
為了模擬上面的情況,簡(jiǎn)單地寫(xiě)個(gè)示例程序,對(duì)比下性能。
import ( "sync" "golang.org/x/sync/errgroup")// ConcurWriteMapWithLock 有鎖并發(fā)寫(xiě)入 mapfunc ConcurWriteMapWithLock() map[int]int { m := make(map[int]int) var mu sync.Mutex var g errgroup.Group // 10 個(gè)協(xié)程并發(fā)寫(xiě)入 map for i := 0; i < 10; i { i := i g.Go(func() error { mu.Lock() defer mu.Unlock() m[i] = i * i return nil }) } _ = g.Wait() return m}// ConcurWriteMapLockFree 無(wú)鎖并發(fā)寫(xiě)入 mapfunc ConcurWriteMapLockFree() map[int]int { m := make(map[int]int) // 每個(gè)協(xié)程獨(dú)占一 value values := make([]int, 10) // 10 個(gè)協(xié)程并發(fā)寫(xiě)入 map var g errgroup.Group for i := 0; i < 10; i { i := i g.Go(func() error { values[i] = i * i return nil }) } _ = g.Wait() // 匯聚結(jié)果到 map for i, v := range values { m[i] = v } return m}
看下二者的性能差異:
func BenchmarkConcurWriteMapWithLock(b *testing.B) { for n := 0; n < b.N; n { _ = ConcurWriteMapWithLock() }}BenchmarkConcurWriteMapWithLock-8 218673 5089 ns/opfunc BenchmarkConcurWriteMapLockFree(b *testing.B) { for n := 0; n < b.N; n { _ = ConcurWriteMapLockFree() }}BenchmarkConcurWriteMapLockFree-8 316635 4048 ns/op
1.2 減少鎖競(jìng)爭(zhēng)
如果加鎖無(wú)法避免,則可以采用分片的形式,減少對(duì)資源加鎖的次數(shù),這樣也可以提高整體的性能。
比如 Golang 優(yōu)秀的本地緩存組件 bigcache 、go-cache、freecache 都實(shí)現(xiàn)了分片功能,每個(gè)分片一把鎖,采用分片存儲(chǔ)的方式減少加鎖的次數(shù)從而提高整體性能。
以一個(gè)簡(jiǎn)單的示例,通過(guò)對(duì)map[uint64]struct{}分片前后并發(fā)寫(xiě)入的對(duì)比,來(lái)看下減少鎖競(jìng)爭(zhēng)帶來(lái)的性能提升。
var ( num = 1000000 m0 = make(map[int]struct{}, num) mu0 = sync.RWMutex{} m1 = make(map[int]struct{}, num) mu1 = sync.RWMutex{})// ConWriteMapNoShard 不分片寫(xiě)入一個(gè) map。func ConWriteMapNoShard() { g := errgroup.Group{} for i := 0; i < num; i { g.Go(func() error { mu0.Lock() defer mu0.Unlock() m0[i] = struct{}{} return nil }) } _ = g.Wait()}// ConWriteMapTwoShard 分片寫(xiě)入兩個(gè) map。func ConWriteMapTwoShard() { g := errgroup.Group{} for i := 0; i < num; i { g.Go(func() error { if i&1 == 0 { mu0.Lock() defer mu0.Unlock() m0[i] = struct{}{} return nil } mu1.Lock() defer mu1.Unlock() m1[i] = struct{}{} return nil }) } _ = g.Wait()}
看下二者的性能差異:
func BenchmarkConWriteMapNoShard(b *testing.B) { for i := 0; i < b.N; i { ConWriteMapNoShard() }}BenchmarkConWriteMapNoShard-12 3 472063245 ns/opfunc BenchmarkConWriteMapTwoShard(b *testing.B) { for i := 0; i < b.N; i { ConWriteMapTwoShard() }}BenchmarkConWriteMapTwoShard-12 4 310588155 ns/op
可以看到,通過(guò)對(duì)分共享資源的分片處理,減少了鎖競(jìng)爭(zhēng),能明顯地提高程序的并發(fā)性能??梢灶A(yù)見(jiàn)的是,隨著分片粒度地變小,性能差距會(huì)越來(lái)越大。當(dāng)然,分片粒度不是越小越好。因?yàn)槊恳粋€(gè)分片都要配一把鎖,那么會(huì)帶來(lái)很多額外的不必要的開(kāi)銷(xiāo)。可以選擇一個(gè)不太大的值,在性能和花銷(xiāo)上尋找一個(gè)平衡。
1.3 優(yōu)先使用共享鎖而非互斥鎖
如果并發(fā)無(wú)法做到無(wú)鎖化,優(yōu)先使用共享鎖而非互斥鎖。
所謂互斥鎖,指鎖只能被一個(gè) Goroutine 獲得。共享鎖指可以同時(shí)被多個(gè) Goroutine 獲得的鎖。
Go 標(biāo)準(zhǔn)庫(kù) sync 提供了兩種鎖,互斥鎖(sync.Mutex)和讀寫(xiě)鎖(sync.RWMutex),讀寫(xiě)鎖便是共享鎖的一種具體實(shí)現(xiàn)。
1.3.1 sync.Mutex
互斥鎖的作用是保證共享資源同一時(shí)刻只能被一個(gè) Goroutine 占用,一個(gè) Goroutine 占用了,其他的 Goroutine 則阻塞等待。
sync.Mutex 提供了兩個(gè)導(dǎo)出方法用來(lái)使用鎖。
Lock() // 加鎖Unlock() // 釋放鎖
我們可以通過(guò)在訪問(wèn)共享資源前前用 Lock 方法對(duì)資源進(jìn)行上鎖,在訪問(wèn)共享資源后調(diào)用 Unlock 方法來(lái)釋放鎖,也可以用 defer 語(yǔ)句來(lái)保證互斥鎖一定會(huì)被解鎖。在一個(gè) Go 協(xié)程調(diào)用 Lock 方法獲得鎖后,其他請(qǐng)求鎖的協(xié)程都會(huì)阻塞在 Lock 方法,直到鎖被釋放。
1.3.2 sync.RWMutex
讀寫(xiě)鎖是一種共享鎖,也稱(chēng)之為多讀單寫(xiě)鎖 (multiple readers, single writer lock)。在使用鎖時(shí),對(duì)獲取鎖的目的操作做了區(qū)分,一種是讀操作,一種是寫(xiě)操作。因?yàn)橥粫r(shí)刻允許多個(gè) Gorouine 獲取讀鎖,所以是一種共享鎖。但寫(xiě)鎖是互斥的。
一般來(lái)說(shuō),有如下幾種情況:
- 讀鎖之間不互斥,沒(méi)有寫(xiě)鎖的情況下,讀鎖是無(wú)阻塞的,多個(gè)協(xié)程可以同時(shí)獲得讀鎖。
- 寫(xiě)鎖之間是互斥的,存在寫(xiě)鎖,其他寫(xiě)鎖阻塞。
- 寫(xiě)鎖與讀鎖是互斥的,如果存在讀鎖,寫(xiě)鎖阻塞,如果存在寫(xiě)鎖,讀鎖阻塞。
sync.RWMutex 提供了五個(gè)導(dǎo)出方法用來(lái)使用鎖。
Lock() // 加寫(xiě)鎖Unlock() // 釋放寫(xiě)鎖RLock() // 加讀鎖RUnlock() // 釋放讀鎖RLocker() Locker // 返回讀鎖,使用 Lock() 和 Unlock() 進(jìn)行 RLock() 和 RUnlock()
讀寫(xiě)鎖的存在是為了解決讀多寫(xiě)少時(shí)的性能問(wèn)題,讀場(chǎng)景較多時(shí),讀寫(xiě)鎖可有效地減少鎖阻塞的時(shí)間。
1.3.3 性能對(duì)比
大部分業(yè)務(wù)場(chǎng)景是讀多寫(xiě)少,所以使用讀寫(xiě)鎖可有效提高對(duì)共享數(shù)據(jù)的訪問(wèn)效率。最壞的情況,只有寫(xiě)請(qǐng)求,那么讀寫(xiě)鎖頂多退化成互斥鎖。所以?xún)?yōu)先使用讀寫(xiě)鎖而非互斥鎖,可以提高程序的并發(fā)性能。
接下來(lái),我們測(cè)試三種情景下,互斥鎖和讀寫(xiě)鎖的性能差異。
- 讀多寫(xiě)少(讀占 80%)
- 讀寫(xiě)一致(各占 50%)
- 讀少寫(xiě)多(讀占 20%)
首先根據(jù)互斥鎖和讀寫(xiě)鎖分別實(shí)現(xiàn)對(duì)共享 map 的并發(fā)讀寫(xiě)。
// OpMapWithMutex 使用互斥鎖讀寫(xiě) map。// rpct 為讀操作占比。func OpMapWithMutex(rpct int) { m := make(map[int]struct{}) mu := sync.Mutex{} var wg sync.WaitGroup for i := 0; i < 100; i { i := i wg.Add(1) go func() { defer wg.Done() mu.Lock() defer mu.Unlock() // 寫(xiě)操作。 if i >= rpct { m[i] = struct{}{} time.Sleep(time.Microsecond) return } // 讀操作。 _ = m[i] time.Sleep(time.Microsecond) }() } wg.Wait()}// OpMapWithRWMutex 使用讀寫(xiě)鎖讀寫(xiě) map。// rpct 為讀操作占比。func OpMapWithRWMutex(rpct int) { m := make(map[int]struct{}) mu := sync.RWMutex{} var wg sync.WaitGroup for i := 0; i < 100; i { i := i wg.Add(1) go func() { defer wg.Done() // 寫(xiě)操作。 if i >= rpct { mu.Lock() defer mu.Unlock() m[i] = struct{}{} time.Sleep(time.Microsecond) return } // 讀操作。 mu.RLock() defer mu.RUnlock() _ = m[i] time.Sleep(time.Microsecond) }() } wg.Wait()}
入?yún)?rpct 用來(lái)調(diào)節(jié)讀操作的占比,來(lái)模擬讀寫(xiě)占比不同的場(chǎng)景。rpct 設(shè)為 80 表示讀多寫(xiě)少(讀占 80%),rpct 設(shè)為 50 表示讀寫(xiě)一致(各占 50%),rpct 設(shè)為 20 表示讀少寫(xiě)多(讀占 20%)。
func BenchmarkMutexReadMore(b *testing.B) { for i := 0; i < b.N; i { OpMapWithMutex(80) }}func BenchmarkRWMutexReadMore(b *testing.B) { for i := 0; i < b.N; i { OpMapWithRWMutex(80) }}func BenchmarkMutexRWEqual(b *testing.B) { for i := 0; i < b.N; i { OpMapWithMutex(50) }}func BenchmarkRWMutexRWEqual(b *testing.B) { for i := 0; i < b.N; i { OpMapWithRWMutex(50) }}func BenchmarkMutexWriteMore(b *testing.B) { for i := 0; i < b.N; i { OpMapWithMutex(20) }}func BenchmarkRWMutexWriteMore(b *testing.B) { for i := 0; i < b.N; i { OpMapWithRWMutex(20) }}
執(zhí)行當(dāng)前包下的所有基準(zhǔn)測(cè)試,結(jié)果如下:
dablelv@DABLELV-MB0 mutex % go test -bench=.goos: darwingoarch: amd64pkg: main/mutexcpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHzBenchmarkMutexReadMore-12 2462 485917 ns/opBenchmarkRWMutexReadMore-12 8074 145690 ns/opBenchmarkMutexRWEqual-12 2406 498673 ns/opBenchmarkRWMutexRWEqual-12 4124 303693 ns/opBenchmarkMutexWriteMore-12 1906 532350 ns/opBenchmarkRWMutexWriteMore-12 2462 432386 ns/opPASSok main/mutex 9.532s
可見(jiàn)讀多寫(xiě)少的場(chǎng)景,使用讀寫(xiě)鎖并發(fā)性能會(huì)更優(yōu)??梢灶A(yù)見(jiàn)的是如果寫(xiě)占比更低,那么讀寫(xiě)鎖帶的并發(fā)效果會(huì)更優(yōu)。
這里需要注意的是,因?yàn)槊看巫x寫(xiě) map 的操作耗時(shí)很短,所以每次睡眠一微秒(百萬(wàn)分之一秒)來(lái)增加耗時(shí),不然對(duì)共享資源的訪問(wèn)耗時(shí),小于鎖處理的本身耗時(shí),那么使用讀寫(xiě)鎖帶來(lái)的性能優(yōu)化效果將變得不那么明顯,甚至?xí)档托阅堋?/span>
2.限制協(xié)程數(shù)量
2.1 協(xié)程數(shù)過(guò)多的問(wèn)題
2.1.1 程序崩潰
Go 程(goroutine)是由 Go 運(yùn)行時(shí)管理的輕量級(jí)線程。通過(guò)它我們可以輕松實(shí)現(xiàn)并發(fā)編程。但是當(dāng)我們無(wú)限開(kāi)辟協(xié)程時(shí),將會(huì)遇到致命的問(wèn)題。
func main() { var wg sync.WaitGroup for i := 0; i < math.MaxInt32; i { wg.Add(1) go func(i int) { defer wg.Done() fmt.Println(i) time.Sleep(time.Second) }(i) } wg.Wait()}
這個(gè)例子實(shí)現(xiàn)了 math.MaxInt32 個(gè)協(xié)程的并發(fā),2^31 – 1 約為 20 億個(gè),每個(gè)協(xié)程內(nèi)部幾乎沒(méi)有做什么事情。正常的情況下呢,這個(gè)程序會(huì)亂序輸出 0 ~ 2^31-1 個(gè)數(shù)字。
程序會(huì)像預(yù)期的那樣順利的運(yùn)行嗎?
go run main.go...1086681142025panic: too many concurrent operations on a single file or socket (max 1048575)goroutine 1158408 [running]:internal/poll.(*fdMutex).rwlock(0xc0000ae060, 0x0) /usr/local/go/src/internal/poll/fd_mutex.go:147 0x11binternal/poll.(*FD).writeLock(...) /usr/local/go/src/internal/poll/fd_mutex.go:239internal/poll.(*FD).Write(0xc0000ae060, {0xc12cadf690, 0x8, 0x8}) /usr/local/go/src/internal/poll/fd_unix.go:262 0x72os.(*File).write(...) /usr/local/go/src/os/file_posix.go:49os.(*File).Write(0xc0000ac008, {0xc12cadf690, 0x1, 0xc12ea62f50}) /usr/local/go/src/os/file.go:176 0x65fmt.Fprintln({0x10c00e0, 0xc0000ac008}, {0xc12ea62f90, 0x1, 0x1}) /usr/local/go/src/fmt/print.go:265 0x75fmt.Println(...) /usr/local/go/src/fmt/print.go:274main.main.func1(0x0) /Users/dablelv/work/code/test/main.go:16 0x8f...
運(yùn)行的結(jié)果是程序直接崩潰了,關(guān)鍵的報(bào)錯(cuò)信息是:
panic: too many concurrent operations on a single file or socket (max 1048575)
對(duì)單個(gè) file/socket 的并發(fā)操作個(gè)數(shù)超過(guò)了系統(tǒng)上限,這個(gè)報(bào)錯(cuò)是 fmt.Printf 函數(shù)引起的,fmt.Printf 將格式化后的字符串打印到屏幕,即標(biāo)準(zhǔn)輸出。在 Linux 系統(tǒng)中,標(biāo)準(zhǔn)輸出也可以視為文件,內(nèi)核(Kernel)利用文件描述符(File Descriptor)來(lái)訪問(wèn)文件,標(biāo)準(zhǔn)輸出的文件描述符為 1,錯(cuò)誤輸出文件描述符為 2,標(biāo)準(zhǔn)輸入的文件描述符為 0。
簡(jiǎn)而言之,系統(tǒng)的資源被耗盡了。
那如果我們將 fmt.Printf 這行代碼去掉呢?那程序很可能會(huì)因?yàn)閮?nèi)存不足而崩潰。這一點(diǎn)更好理解,每個(gè)協(xié)程至少需要消耗 2KB 的空間,那么假設(shè)計(jì)算機(jī)的內(nèi)存是 4GB,那么至多允許 4GB/2KB = 1M 個(gè)協(xié)程同時(shí)存在。那如果協(xié)程中還存在著其他需要分配內(nèi)存的操作,那么允許并發(fā)執(zhí)行的協(xié)程將會(huì)數(shù)量級(jí)地減少。
2.1.2 協(xié)程的代價(jià)
前面的例子過(guò)于極端,一般情況下程序也不會(huì)無(wú)限開(kāi)辟協(xié)程,旨在說(shuō)明協(xié)程數(shù)量是有限制的,不能無(wú)限開(kāi)辟。
如果我們開(kāi)辟很多協(xié)程,但不會(huì)導(dǎo)致程序崩潰,可以嗎?如果真要這么做的話(huà),我們應(yīng)該清楚地知道,協(xié)程雖然輕量,但仍有開(kāi)銷(xiāo)。
Go 的開(kāi)銷(xiāo)主要是三個(gè)方面:創(chuàng)建(占用內(nèi)存)、調(diào)度(增加調(diào)度器負(fù)擔(dān))和刪除(增加 GC 壓力)。
- 內(nèi)存開(kāi)銷(xiāo)
空間上,一個(gè) Go 程占用約 2K 的內(nèi)存,在源碼 src/runtime/runtime2.go里面,我們可以找到 Go 程的結(jié)構(gòu)定義type g struct。
- 調(diào)度開(kāi)銷(xiāo)
時(shí)間上,協(xié)程調(diào)度也會(huì)有 CPU 開(kāi)銷(xiāo)。我們可以利用runntime.Gosched()讓當(dāng)前協(xié)程主動(dòng)讓出 CPU 去執(zhí)行另外一個(gè)協(xié)程,下面看一下協(xié)程之間切換的耗時(shí)。
const NUM = 10000func cal() { for i := 0; i < NUM; i { runtime.Gosched() }}func main() { // 只設(shè)置一個(gè) Processor runtime.GOMAXPROCS(1) start := time.Now().UnixNano() go cal() for i := 0; i < NUM; i { runtime.Gosched() } end := time.Now().UnixNano() fmt.Printf("total %vns per %vns", end-start, (end-start)/NUM)}
運(yùn)行輸出:
total 997200ns per 99ns
可見(jiàn)一次協(xié)程的切換,耗時(shí)大概在 100ns,相對(duì)于線程的微秒級(jí)耗時(shí)切換,性能表現(xiàn)非常優(yōu)秀,但是仍有開(kāi)銷(xiāo)。
- GC 開(kāi)銷(xiāo) 創(chuàng)建 Go 程到運(yùn)行結(jié)束,占用的內(nèi)存資源是需要由 GC 來(lái)回收,如果無(wú)休止地創(chuàng)建大量 Go 程后,勢(shì)必會(huì)造成對(duì) GC 的壓力。
package mainimport ( "fmt" "runtime" "runtime/debug" "sync" "time")func createLargeNumGoroutine(num int, wg *sync.WaitGroup) { wg.Add(num) for i := 0; i < num; i { go func() { defer wg.Done() }() }}func main() { // 只設(shè)置一個(gè) Processor 保證 Go 程串行執(zhí)行 runtime.GOMAXPROCS(1) // 關(guān)閉GC改為手動(dòng)執(zhí)行 debug.SetGCPercent(-1) var wg sync.WaitGroup createLargeNumGoroutine(1000, &wg) wg.Wait() t := time.Now() runtime.GC() // 手動(dòng)GC cost := time.Since(t) fmt.Printf("GC cost %v when goroutine num is %vn", cost, 1000) createLargeNumGoroutine(10000, &wg) wg.Wait() t = time.Now() runtime.GC() // 手動(dòng)GC cost = time.Since(t) fmt.Printf("GC cost %v when goroutine num is %vn", cost, 10000) createLargeNumGoroutine(100000, &wg) wg.Wait() t = time.Now() runtime.GC() // 手動(dòng)GC cost = time.Since(t) fmt.Printf("GC cost %v when goroutine num is %vn", cost, 100000)}
運(yùn)行輸出:
GC cost 0s when goroutine num is 1000GC cost 2.0027ms when goroutine num is 10000GC cost 30.9523ms when goroutine num is 100000
當(dāng)創(chuàng)建的 Go 程數(shù)量越多,GC 耗時(shí)越大。
上面的分析目的是為了盡可能地量化 Goroutine 的開(kāi)銷(xiāo)。雖然官方宣稱(chēng)用 Golang 寫(xiě)并發(fā)程序的時(shí)候隨便起個(gè)成千上萬(wàn)的 Goroutine 毫無(wú)壓力,但當(dāng)我們起十萬(wàn)、百萬(wàn)甚至千萬(wàn)個(gè) Goroutine 呢?Goroutine 輕量的開(kāi)銷(xiāo)將被放大。
2.2 限制協(xié)程數(shù)量
系統(tǒng)地資源是有限,協(xié)程是有代價(jià)的,為了保護(hù)程序,提高性能,我們應(yīng)主動(dòng)限制并發(fā)的協(xié)程數(shù)量。
可以利用信道 channel 的緩沖區(qū)大小來(lái)實(shí)現(xiàn)。
func main() { var wg sync.WaitGroup ch := make(chan struct{}, 3) for i := 0; i < 10; i { ch <- struct{}{} wg.Add(1) go func(i int) { defer wg.Done() log.Println(i) time.Sleep(time.Second) <-ch }(i) } wg.Wait()}
上例中創(chuàng)建了緩沖區(qū)大小為 3 的 channel,在沒(méi)有被接收的情況下,至多發(fā)送 3 個(gè)消息則被阻塞。開(kāi)啟協(xié)程前,調(diào)用ch <- struct{}{},若緩存區(qū)滿(mǎn),則阻塞。協(xié)程任務(wù)結(jié)束,調(diào)用 <-ch 釋放緩沖區(qū)。
sync.WaitGroup 并不是必須的,例如 Http 服務(wù),每個(gè)請(qǐng)求天然是并發(fā)的,此時(shí)使用 channel 控制并發(fā)處理的任務(wù)數(shù)量,就不需要 sync.WaitGroup。
運(yùn)行結(jié)果如下:
2022/03/06 20:37:02 02022/03/06 20:37:02 22022/03/06 20:37:02 12022/03/06 20:37:03 32022/03/06 20:37:03 42022/03/06 20:37:03 52022/03/06 20:37:04 62022/03/06 20:37:04 72022/03/06 20:37:04 82022/03/06 20:37:05 9
從日志中可以很容易看到,每秒鐘只并發(fā)執(zhí)行了 3 個(gè)任務(wù),達(dá)到了協(xié)程并發(fā)控制的目的。
2.3 協(xié)程池化
上面的例子只是簡(jiǎn)單地限制了協(xié)程開(kāi)辟的數(shù)量。在此基礎(chǔ)之上,基于對(duì)象復(fù)用的思想,我們可以重復(fù)利用已開(kāi)辟的協(xié)程,避免協(xié)程的重復(fù)創(chuàng)建銷(xiāo)毀,達(dá)到池化的效果。
協(xié)程池化,我們可以自己寫(xiě)一個(gè)協(xié)程池,但不推薦這么做。因?yàn)橐呀?jīng)有成熟的開(kāi)源庫(kù)可供使用,無(wú)需再重復(fù)造輪子。目前有很多第三方庫(kù)實(shí)現(xiàn)了協(xié)程池,可以很方便地用來(lái)控制協(xié)程的并發(fā)數(shù)量,比較受歡迎的有:
- Jeffail/tunny
- panjf2000/ants
下面以 panjf2000/ants 為例,簡(jiǎn)單介紹其使用。
ants 是一個(gè)簡(jiǎn)單易用的高性能 Goroutine 池,實(shí)現(xiàn)了對(duì)大規(guī)模 Goroutine 的調(diào)度管理和復(fù)用,允許使用者在開(kāi)發(fā)并發(fā)程序的時(shí)候限制 Goroutine 數(shù)量,復(fù)用協(xié)程,達(dá)到更高效執(zhí)行任務(wù)的效果。
package mainimport ( "fmt" "time" "github.com/panjf2000/ants")func main() { // Use the common pool for i := 0; i < 10; i { i := i ants.Submit(func() { fmt.Println(i) }) } time.Sleep(time.Second)}
使用 ants,我們簡(jiǎn)單地使用其默認(rèn)的協(xié)程池,直接將任務(wù)提交并發(fā)執(zhí)行。默認(rèn)協(xié)程池的缺省容量 math.MaxInt32。
如果自定義協(xié)程池容量大小,可以調(diào)用 NewPool 方法來(lái)實(shí)例化具有給定容量的池,如下所示:
// Set 10000 the size of goroutine poolp, _ := ants.NewPool(10000)
2.4 小結(jié)
Golang 為并發(fā)而生。Goroutine 是由 Go 運(yùn)行時(shí)管理的輕量級(jí)線程,通過(guò)它我們可以輕松實(shí)現(xiàn)并發(fā)編程。Go 雖然輕量,但天下沒(méi)有免費(fèi)的午餐,無(wú)休止地開(kāi)辟大量 Go 程勢(shì)必會(huì)帶來(lái)性能影響,甚至程序崩潰。所以,我們應(yīng)盡可能的控制協(xié)程數(shù)量,如果有需要,請(qǐng)復(fù)用它。
3.使用 sync.Once 避免重復(fù)執(zhí)行
3.1 簡(jiǎn)介
sync.Once 是 Go 標(biāo)準(zhǔn)庫(kù)提供的使函數(shù)只執(zhí)行一次的實(shí)現(xiàn),常應(yīng)用于單例模式,例如初始化配置、保持?jǐn)?shù)據(jù)庫(kù)連接等。作用與 init 函數(shù)類(lèi)似,但有區(qū)別。
- init 函數(shù)是當(dāng)所在的 package 首次被加載時(shí)執(zhí)行,若遲遲未被使用,則既浪費(fèi)了內(nèi)存,又延長(zhǎng)了程序加載時(shí)間。
- sync.Once 可以在代碼的任意位置初始化和調(diào)用,因此可以延遲到使用時(shí)再執(zhí)行,并發(fā)場(chǎng)景下是線程安全的。
在多數(shù)情況下,sync.Once 被用于控制變量的初始化,這個(gè)變量的讀寫(xiě)滿(mǎn)足如下三個(gè)條件:
- 當(dāng)且僅當(dāng)?shù)谝淮卧L問(wèn)某個(gè)變量時(shí),進(jìn)行初始化(寫(xiě));
- 變量初始化過(guò)程中,所有讀都被阻塞,直到初始化完成;
- 變量?jī)H初始化一次,初始化完成后駐留在內(nèi)存里。
3.2 原理
sync.Once 用來(lái)保證函數(shù)只執(zhí)行一次。要達(dá)到這個(gè)效果,需要做到兩點(diǎn):
- 計(jì)數(shù)器,統(tǒng)計(jì)函數(shù)執(zhí)行次數(shù);
- 線程安全,保障在多 Go 程的情況下,函數(shù)仍然只執(zhí)行一次,比如鎖。
3.2.1 源碼
下面看一下 sync.Once 結(jié)構(gòu),其有兩個(gè)變量。使用 done 統(tǒng)計(jì)函數(shù)執(zhí)行次數(shù),使用鎖 m 實(shí)現(xiàn)線程安全。果不其然,和上面的猜想一致。
// Once is an object that will perform exactly one action.//// A Once must not be copied after first use.type Once struct { // done indicates whether the action has been performed. // It is first in the struct because it is used in the hot path. // The hot path is inlined at every call site. // Placing done first allows more compact instructions on some architectures (amd64/386), // and fewer instructions (to calculate offset) on other architectures. done uint32 m Mutex}
sync.Once 僅提供了一個(gè)導(dǎo)出方法 Do(),參數(shù) f 是只會(huì)被執(zhí)行一次的函數(shù),一般為對(duì)象初始化函數(shù)。
// go version go1.17 darwin/amd64// Do calls the function f if and only if Do is being called for the// first time for this instance of Once. In other words, given// var once Once// if once.Do(f) is called multiple times, only the first call will invoke f,// even if f has a different value in each invocation. A new instance of// Once is required for each function to execute.//// Do is intended for initialization that must be run exactly once. Since f// is niladic, it may be necessary to use a function literal to capture the// arguments to a function to be invoked by Do:// config.once.Do(func() { config.init(filename) })//// Because no call to Do returns until the one call to f returns, if f causes// Do to be called, it will deadlock.//// If f panics, Do considers it to have returned; future calls of Do return// without calling f.//func (o *Once) Do(f func()) { // Note: Here is an incorrect implementation of Do: // // if atomic.CompareAndSwapUint32(&o.done, 0, 1) { // f() // } // // Do guarantees that when it returns, f has finished. // This implementation would not implement that guarantee: // given two simultaneous calls, the winner of the cas would // call f, and the second would return immediately, without // waiting for the first's call to f to complete. // This is why the slow path falls back to a mutex, and why // the atomic.StoreUint32 must be delayed until after f returns. if atomic.LoadUint32(&o.done) == 0 { // Outlined slow-path to allow inlining of the fast-path. o.doSlow(f) }}func (o *Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() if o.done == 0 { defer atomic.StoreUint32(&o.done, 1) f() }}
拋去大段的注釋?zhuān)梢钥吹?sync.Once 實(shí)現(xiàn)非常簡(jiǎn)潔。Do() 函數(shù)中,通過(guò)對(duì)成員變量 done 的判斷,來(lái)決定是否執(zhí)行傳入的任務(wù)函數(shù)。執(zhí)行任務(wù)函數(shù)前,通過(guò)鎖保證任務(wù)函數(shù)的執(zhí)行和 done 的修改是一個(gè)互斥操作。在執(zhí)行任務(wù)函數(shù)前,對(duì) done 做一個(gè)二次判斷,來(lái)保證任務(wù)函數(shù)只會(huì)被執(zhí)行一次,done 只會(huì)被修改一次。
3.2.2 done 為什么是第一個(gè)字段
從字段 done 前有一段注釋?zhuān)f(shuō)明了done 為什么是第一個(gè)字段。
done 在熱路徑中,done 放在第一個(gè)字段,能夠減少 CPU 指令,也就是說(shuō),這樣做能夠提升性能。
熱路徑(hot path)是程序非常頻繁執(zhí)行的一系列指令,sync.Once 絕大部分場(chǎng)景都會(huì)訪問(wèn) o.done,在熱路徑上是比較好理解的。如果 hot path 編譯后的機(jī)器碼指令更少,更直接,必然是能夠提升性能的。
為什么放在第一個(gè)字段就能夠減少指令呢?因?yàn)榻Y(jié)構(gòu)體第一個(gè)字段的地址和結(jié)構(gòu)體的指針是相同的,如果是第一個(gè)字段,直接對(duì)結(jié)構(gòu)體的指針解引用即可。如果是其他的字段,除了結(jié)構(gòu)體指針外,還需要計(jì)算與第一個(gè)值的偏移(calculate offset)。在機(jī)器碼中,偏移量是隨指令傳遞的附加值,CPU 需要做一次偏移值與指針的加法運(yùn)算,才能獲取要訪問(wèn)的值的地址。因?yàn)?,訪問(wèn)第一個(gè)字段的機(jī)器代碼更緊湊,速度更快。
參考 What does “hot path” mean in the context of sync.Once? – StackOverflow
3.3 性能差異
我們以一個(gè)簡(jiǎn)單示例,來(lái)說(shuō)明使用 sync.Once 保證函數(shù)只會(huì)被執(zhí)行一次和多次執(zhí)行,二者的性能差異。
考慮一個(gè)簡(jiǎn)單的場(chǎng)景,函數(shù) ReadConfig 需要讀取環(huán)境變量,并轉(zhuǎn)換為對(duì)應(yīng)的配置。環(huán)境變量在程序執(zhí)行前已經(jīng)確定,執(zhí)行過(guò)程中不會(huì)發(fā)生改變。ReadConfig 可能會(huì)被多個(gè)協(xié)程并發(fā)調(diào)用,為了提升性能(減少執(zhí)行時(shí)間和內(nèi)存占用),使用 sync.Once 是一個(gè)比較好的方式。
type Config struct { GoRoot string GoPath string}var ( once sync.Once config *Config)func ReadConfigWithOnce() *Config { once.Do(func() { config = &Config{ GoRoot: os.Getenv("GOROOT"), GoPath: os.Getenv("GOPATH"), } }) return config}func ReadConfig() *Config { return &Config{ GoRoot: os.Getenv("GOROOT"), GoPath: os.Getenv("GOPATH"), }}
我們看下二者的性能差異。
func BenchmarkReadConfigWithOnce(b *testing.B) { for i := 0; i < b.N; i { _ = ReadConfigWithOnce() }}func BenchmarkReadConfig(b *testing.B) { for i := 0; i < b.N; i { _ = ReadConfig() }}
執(zhí)行測(cè)試結(jié)果如下:
go test -bench=. main/oncegoos: darwingoarch: amd64pkg: main/oncecpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHzBenchmarkReadConfigWithOnce-12 670438965 1.732 ns/opBenchmarkReadConfig-12 13339154 87.46 ns/opPASSok main/once 3.006s
sync.Once 中保證了 Config 初始化函數(shù)僅執(zhí)行了一次,避免了多次重復(fù)初始化,在并發(fā)環(huán)境下很有用。
4.使用 sync.Cond 通知協(xié)程
4.1 簡(jiǎn)介
sync.Cond 是基于互斥鎖/讀寫(xiě)鎖實(shí)現(xiàn)的條件變量,用來(lái)協(xié)調(diào)想要訪問(wèn)共享資源的那些 Goroutine,當(dāng)共享資源的狀態(tài)發(fā)生變化的時(shí)候,sync.Cond 可以用來(lái)通知等待條件發(fā)生而阻塞的 Goroutine。
sync.Cond 基于互斥鎖/讀寫(xiě)鎖,它和互斥鎖的區(qū)別是什么呢?
互斥鎖 sync.Mutex 通常用來(lái)保護(hù)共享的臨界資源,條件變量 sync.Cond 用來(lái)協(xié)調(diào)想要訪問(wèn)共享資源的 Goroutine。當(dāng)共享資源的狀態(tài)發(fā)生變化時(shí),sync.Cond 可以用來(lái)通知被阻塞的 Goroutine。
4.2 使用場(chǎng)景
sync.Cond 經(jīng)常用在多個(gè) Goroutine 等待,一個(gè) Goroutine 通知(事件發(fā)生)的場(chǎng)景。如果是一個(gè)通知,一個(gè)等待,使用互斥鎖或 channel 就能搞定了。
我們想象一個(gè)非常簡(jiǎn)單的場(chǎng)景:
有一個(gè)協(xié)程在異步地接收數(shù)據(jù),剩下的多個(gè)協(xié)程必須等待這個(gè)協(xié)程接收完數(shù)據(jù),才能讀取到正確的數(shù)據(jù)。在這種情況下,如果單純使用 chan 或互斥鎖,那么只能有一個(gè)協(xié)程可以等待,并讀取到數(shù)據(jù),沒(méi)辦法通知其他的協(xié)程也讀取數(shù)據(jù)。
這個(gè)時(shí)候,就需要有個(gè)全局的變量來(lái)標(biāo)志第一個(gè)協(xié)程數(shù)據(jù)是否接受完畢,剩下的協(xié)程,反復(fù)檢查該變量的值,直到滿(mǎn)足要求?;蛘邉?chuàng)建多個(gè) channel,每個(gè)協(xié)程阻塞在一個(gè) channel 上,由接收數(shù)據(jù)的協(xié)程在數(shù)據(jù)接收完畢后,逐個(gè)通知??傊?,需要額外的復(fù)雜度來(lái)完成這件事。
Go 語(yǔ)言在標(biāo)準(zhǔn)庫(kù) sync 中內(nèi)置一個(gè) sync.Cond 用來(lái)解決這類(lèi)問(wèn)題。
4.3 原理
sync.Cond 內(nèi)部維護(hù)了一個(gè)等待隊(duì)列,隊(duì)列中存放的是所有在等待這個(gè) sync.Cond 的 Go 程,即保存了一個(gè)通知列表。sync.Cond 可以用來(lái)喚醒一個(gè)或所有因等待條件變量而阻塞的 Go 程,以此來(lái)實(shí)現(xiàn)多個(gè) Go 程間的同步。
sync.Cond 的定義如下:
// Cond implements a condition variable, a rendezvous point// for goroutines waiting for or announcing the occurrence// of an event.//// Each Cond has an associated Locker L (often a *Mutex or *RWMutex),// which must be held when changing the condition and// when calling the Wait method.//// A Cond must not be copied after first use.type Cond struct { noCopy noCopy // L is held while observing or changing the condition L Locker notify notifyList checker copyChecker}
每個(gè) Cond 實(shí)例都會(huì)關(guān)聯(lián)一個(gè)鎖 L(互斥鎖 *Mutex,或讀寫(xiě)鎖 *RWMutex),當(dāng)修改條件或者調(diào)用 Wait 方法時(shí),必須加鎖。
sync.Cond 的四個(gè)成員函數(shù)定義如下:
// NewCond returns a new Cond with Locker l.func NewCond(l Locker) *Cond { return &Cond{L: l}}
NewCond 創(chuàng)建 Cond 實(shí)例時(shí),需要關(guān)聯(lián)一個(gè)鎖。
// Wait atomically unlocks c.L and suspends execution// of the calling goroutine. After later resuming execution,// Wait locks c.L before returning. Unlike in other systems,// Wait cannot return unless awoken by Broadcast or Signal.//// Because c.L is not locked when Wait first resumes, the caller// typically cannot assume that the condition is true when// Wait returns. Instead, the caller should Wait in a loop://// c.L.Lock()// for !condition() {// c.Wait()// }// ... make use of condition ...// c.L.Unlock()//func (c *Cond) Wait() { c.checker.check() t := runtime_notifyListAdd(&c.notify) c.L.Unlock() runtime_notifyListWait(&c.notify, t) c.L.Lock()}
Wait 用于阻塞調(diào)用者,等待通知。調(diào)用 Wait 會(huì)自動(dòng)釋放鎖 c.L,并掛起調(diào)用者所在的 goroutine。如果其他協(xié)程調(diào)用了 Signal 或 Broadcast 喚醒了該協(xié)程,那么 Wait 方法在結(jié)束阻塞時(shí),會(huì)重新給 c.L 加鎖,并且繼續(xù)執(zhí)行 Wait 后面的代碼。
對(duì)條件的檢查,使用了 for !condition() 而非 if,是因?yàn)楫?dāng)前協(xié)程被喚醒時(shí),條件不一定符合要求,需要再次 Wait 等待下次被喚醒。為了保險(xiǎn)起,使用 for 能夠確保條件符合要求后,再執(zhí)行后續(xù)的代碼。
// Signal wakes one goroutine waiting on c, if there is any.//// It is allowed but not required for the caller to hold c.L// during the call.func (c *Cond) Signal() { c.checker.check() runtime_notifyListNotifyOne(&c.notify)}// Broadcast wakes all goroutines waiting on c.//// It is allowed but not required for the caller to hold c.L// during the call.func (c *Cond) Broadcast() { c.checker.check() runtime_notifyListNotifyAll(&c.notify)}
Signal 只喚醒任意 1 個(gè)等待條件變量 c 的 goroutine,無(wú)需鎖保護(hù)。Broadcast 喚醒所有等待條件變量 c 的 goroutine,無(wú)需鎖保護(hù)。
4.4 使用示例
我們實(shí)現(xiàn)一個(gè)簡(jiǎn)單的例子,三個(gè)協(xié)程調(diào)用 Wait() 等待,另一個(gè)協(xié)程調(diào)用 Broadcast() 喚醒所有等待的協(xié)程。
var done = falsefunc read(name string, c *sync.Cond) { c.L.Lock() for !done { c.Wait() } log.Println(name, "starts reading") c.L.Unlock()}func write(name string, c *sync.Cond) { log.Println(name, "starts writing") time.Sleep(time.Second) done = true log.Println(name, "wakes all") c.Broadcast()}func main() { cond := sync.NewCond(&sync.Mutex{}) go read("reader1", cond) go read("reader2", cond) go read("reader3", cond) write("writer", cond) time.Sleep(time.Second * 3)}
- done 即多個(gè) Goroutine 阻塞等待的條件。
- read() 調(diào)用 Wait() 等待通知,直到 done 為 true。
- write() 接收數(shù)據(jù),接收完成后,將 done 置為 true,調(diào)用 Broadcast() 通知所有等待的協(xié)程。
- write() 中的暫停了 1s,一方面是模擬耗時(shí),另一方面是確保前面的 3 個(gè) read 協(xié)程都執(zhí)行到 Wait(),處于等待狀態(tài)。main 函數(shù)最后暫停了 3s,確保所有操作執(zhí)行完畢。
運(yùn)行輸出:
go run main.go2022/03/07 17:20:09 writer starts writing2022/03/07 17:20:10 writer wakes all2022/03/07 17:20:10 reader3 starts reading2022/03/07 17:20:10 reader1 starts reading2022/03/07 17:20:10 reader2 starts reading
更多關(guān)于 sync.Cond 的討論可參考 How to correctly use sync.Cond? – StackOverflow。
4.5 注意事項(xiàng)
- sync.Cond 不能被復(fù)制
sync.Cond 不能被復(fù)制的原因,并不是因?yàn)槠鋬?nèi)部嵌套了 Locker。因?yàn)?NewCond 時(shí)傳入的 Mutex/RWMutex 指針,對(duì)于 Mutex 指針復(fù)制是沒(méi)有問(wèn)題的。
主要原因是 sync.Cond 內(nèi)部是維護(hù)著一個(gè) Goroutine 通知隊(duì)列 notifyList。如果這個(gè)隊(duì)列被復(fù)制的話(huà),那么就在并發(fā)場(chǎng)景下導(dǎo)致不同 Goroutine 之間操作的 notifyList.wait、notifyList.notify 并不是同一個(gè),這會(huì)導(dǎo)致出現(xiàn)有些 Goroutine 會(huì)一直阻塞。
- 喚醒順序
從等待隊(duì)列中按照順序喚醒,先進(jìn)入等待隊(duì)列,先被喚醒。
- 調(diào)用 Wait() 前要加鎖
調(diào)用 Wait() 函數(shù)前,需要先獲得條件變量的成員鎖,原因是需要互斥地變更條件變量的等待隊(duì)列。在 Wait() 返回前,會(huì)重新上鎖。