論go語言中goroutine的使用
go中的goroutine是go語言在語言級別支援併發的一種特性。初接觸go的時候對go的goroutine的歡喜至極,實現併發簡便到簡直bt的地步。但是在專案過程中,越來越發現goroutine是一個很容易被大家濫用的東西。goroutine是一把雙面刃。這裡列舉一下goroutine使用的幾宗罪:
1 goroutine的指標傳遞是不安全的
1
2
3
4
5
6
7
8
|
fun main() { request := request.NewRequest() //這裡的NewRequest()是傳遞迴一個type Request的指標
go saveRequestToRedis1(request)
go saveReuqestToRedis2(request)
select {}
} |
非常符合邏輯的程式碼:
主routine開一個routine把request傳遞給saveRequestToRedis1,讓它把請求儲存到redis節點1中
同時開另一個routine把request傳遞給saveReuqestToRedis2,讓它把請求儲存到redis節點2中
然後主routine就進入迴圈(不結束程式)
問題現在來了,saveRequestToRedis1和saveReuqestToRedis2兩個函式其實不是我寫的,而是團隊另一個人寫的,我對其中的實現一無所知,也不想去仔細看內部的具體實現。但是根據函式名,我想當然地把request指標傳遞進入。
好了,實際上saveRequestToRedis1和saveRequestToRedis2 是這樣實現的:
1
2
3
4
5
6
7
|
func saveRequestToRedis1(request *Request){ …
request.ToUsers = [] int {1,2,3} //這裡是一個賦值操作,修改了request指向的資料結構
…
redis.Save(request)
return
} |
這樣有什麼問題?saveRequestToRedis1和saveReuqestToRedis2兩個goroutine修改了同一個共享資料結構,但是由於routine的執行是無序的,因此我們無法保證request.ToUsers設定和redis.Save()是一個原子操作,這樣就會出現實際儲存redis的資料錯誤的bug。
好吧,你可以說這個saveRequestToRedis的函式實現的有問題,沒有考慮到會是使用go routine呼叫。請再想一想,這個saveRequestToRedis的具體實現是沒有任何問題的,它不應該考慮上層是怎麼使用它的。那就是我的goroutine的使用有問題,主routine在開一個routine的時候並沒有確認這個routine裡面的任何一句程式碼有沒有修改了主routine中的資料。對的,主routine確實需要考慮這個情況。但是按照這個思路,所以呢?主goroutine在啟用go routine的時候需要閱讀子routine中的每行程式碼來確定是否有修改共享資料??這在實際專案開發過程中是多麼降低開發速度的一件事情啊!
go語言使用goroutine是想減輕併發的開發壓力,卻不曾想是在另一方面增加了開發壓力。
上面說的那麼多,就是想得出一個結論:
gorotine的指標傳遞是不安全的!!
如果上一個例子還不夠隱蔽,這裡還有一個例子:
1
2
3
4
5
6
7
8
|
fun ( this *Request)SaveRedis() {
redis1 := redis.NewRedisAddr( "xxxxxx" )
redis2 := redis.NewRedisAddr( "xxxxxx" )
go this .saveRequestToRedis(redis1)
go this .saveRequestToRedis(redis2)
select {}
} |
很少人會考慮到this指標指向的物件是否會有問題,這裡的this指標傳遞給routine應該說是非常隱蔽的。
2 goroutine增加了函式的危險係數
這點其實也是源自於上面一點。上文說,往一個go函式中傳遞指標是不安全的。那麼換個角度想,你怎麼能保證你要呼叫的函式在函式實現內部不會使用go呢?如果不去看函式體內部具體實現,是沒有辦法確定的。
例如我們將上面的典型例子稍微改改
1
2
3
4
5
6
|
func main() { request := request.NewRequest()
saveRequestToRedis1(request)
saveRequestToRedis2(request)
select {}
} |
這下我們沒有使用併發,就一定不會出現這問題了吧?追到函式裡面去,傻眼了:
1
2
3
4
5
6
7
8
9
|
func saveReqeustToRedis1(request *Request) { …
go func() {
…
request.ToUsers = []{1,2,3}
….
redis.Save(request)
}
} |
我勒個去啊,裡面起了一個goroutine,並修改了request指標指向的物件。這裡就產生了錯誤了。好吧,如果在呼叫函式的時候,不看函式內部的具體實現,這個問題就無法避免。所以說呢?所以說,從最壞的思考角度出發,每個呼叫函式理論上來說都是不安全的!試想一下,這個呼叫函式如果不是自己開發組的人編寫的,而是使用網路上的第三方開原始碼…確實無法想象找出這個bug要花費多少時間。
3 goroutine的濫用陷阱
看一下這個例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
func main() { go saveRequestToRedises(request)
} func saveRequestToRedieses(request *Request) { for _, redis := range Redises {
go redis.saveRequestToRedis(request)
}
} func saveRequestToRedis(request *Request) { ….
go func() {
request.ToUsers = []{1,2,3}
…
redis.Save(request)
}
} |
神奇啊,go無處不在,好像眨眨眼就在哪裡冒出來了。這就是go的濫用,到處都見到go,但是卻不是很明確,哪裡該用go?為什麼用go?goroutine確實會有效率的提升麼?
c語言的併發比go語言的併發複雜和繁瑣地多,因此我們在使用之前會深思,考慮使用併發獲得的好處和壞處。go呢?幾乎不。
處理方法
下面說幾個我處理這些問題的方法:
1 當啟動一個goroutine的時候,如果一個函式必須要傳遞一個指標,但是函式層級很深,在無法保證安全的情況下,傳遞這個指標指向物件的一個克隆,而不是直接傳遞指標
1
2
3
4
5
6
7
8
|
fun main() { request := request.NewRequest()
go saveRequestToRedis1(request.Clone())
go saveReuqestToRedis2(request.Clone())
select {}
} |
Clone函式需要另外寫。可以在結構體定義之後簡單跟上這個方法。比如:
1
2
3
4
5
6
|
func ( this *Request)Clone(){
newRequest := NewRequst()
newRequest.ToUsers = make([] int , len( this .ToUsers))
copy(newRequest.ToUsers, this .ToUsers)
} |
其實從效率角度考慮這樣確實會產生不必要的Clone的操作,耗費一定記憶體和CPU。但是在我看來,首先,為了安全性,這個嘗試是值得的。其次,如果專案對效率確實有很高的要求,那麼你不妨在開發階段遵照這個原則使用clone,然後在專案優化階段,作為一種優化手段,將不必要的Clone操作去掉。這樣就能在保證安全的前提下做到最好的優化。
2 什麼時候使用go的問題
有兩種思維邏輯會想到使用goroutine:
1 業務邏輯需要併發
比如一個伺服器,接收請求,阻塞式的方法是一個請求處理完成後,才開始第二個請求的處理。其實在設計的時候我們一定不會這麼做,我們會在一開始就已經想到使用併發來處理這個場景,每個請求啟動一個goroutine為它服務,這樣就達到了並行的效果。這種goroutine直接按照思維的邏輯來使用goroutine
2 效能優化需要併發
一個場景是這樣:需要給一批使用者傳送訊息,正常邏輯會使用
1
2
3
4
|
for _, user := range users {
sendMessage(user)
} |
1
|
|
但是在考慮到效能問題的時候,我們就不會這樣做,如果users的個數很大,比如有1000萬個使用者?我們就沒必要將1000萬個使用者放在一個routine中執行處理,考慮將1000萬使用者分成1000份,每份開一個goroutine,一個goroutine分發1萬個使用者,這樣在效率上會提升很多。這種是效能優化上對goroutine的需求
按照專案開發的流程角度來看。在專案開發階段,第一種思路的程式碼實現會直接影響到後續的開發實現,因此在專案開發階段應該馬上實現。但是第二種,專案中是由很多小角落是可以使用goroutine進行優化的,但是如果在開發階段對每個優化策略都考慮到,那一定會直接打亂你的開發思路,會讓你的開發週期延長,而且很容易埋下潛在的不安全程式碼。因此第二種情況在開發階段絕不應該直接使用goroutine,而該在專案優化階段以優化的思路對專案進行重構。
總結
總結下,文章寫了這麼多,並不是想讓你對goroutine的使用產生畏懼,而是想強調一個觀點:
goroutine的使用應該是保守型的。
在你敲下go這兩個字母之前請仔細思考是否應該使用goroutine這柄利刃。
後續
在你看完這篇以後,也建議看看stevewang的這篇吧:
http://blog.sina.com.cn/s/blog_9be3b8f10101dsr6.html
相關文章
- Go 語言中的 collect 使用Go
- Go 語言中使用 ETCDGo
- Go 語言中的方法Go
- Go語言中的InterfaceGo
- go語言學習-goroutineGo
- Go 語言中的外掛Go
- Go 語言中的 切片 --sliceGo
- Go語言中切片slice的宣告與使用Go
- Go語言 | CSP併發模型與Goroutine的基本使用Go模型
- Go 語言中 defer 使用時有哪些陷阱?Go
- 在Go語言中,怎樣使用Json的方法?GoJSON
- Go語言中使用正則提取匹配的字串Go字串
- GO 語言中的物件導向Go物件
- Go語言中的併發模式Go模式
- 在 Go 語言中,我為什麼使用介面Go
- 在Go語言中使用 Protobuf-RPCGoRPC
- Go語言中的單元測試Go
- 認識 Go 語言中的陣列Go陣列
- Go語言中的變數作用域Go變數
- 使用 Go Channel 及 Goroutine 時機Go
- 如何在 Go 語言中使用 Redis 連線池GoRedis
- Go 語言中的格式化輸出Go
- Go 語言中的兩種 slice 表示式Go
- 詳細解讀go語言中的chnanelGoNaN
- hash 表在 go 語言中的實現Go
- Go語言中時間輪的實現Go
- Go語言中defer的一些坑Go
- 聊聊Go語言中的陣列與切片Go陣列
- 9.Go語言中的流程控制Go
- 【Go學習筆記7】go語言中的模組(包)Go筆記
- 【Go併發程式設計】Goroutine的基本使用Go程式設計
- Go語言之Goroutine與通道、異常處理Go
- Go語言goroutine排程器初始化Go
- Go 語言中常見的幾種反模式Go模式
- go 語言中的 rune,獲取字元長度Go字元
- go 語言中預設的型別識別Go型別
- Go 語言中 strings 包常用方法Go
- Go語言排程器之盜取goroutine(17)Go