GO語言併發
理解併發和並行
併發:同時管理多件事情。
並行:同時做多件事情。表示同時發生了多件事情,透過時間片切換,哪怕只有單一的核心,也可以實現“同時做多件事情”這個效果。
預熱——使用者級執行緒和核心級執行緒
執行緒被分為兩類:使用者級執行緒(User Level Thread)和核心級執行緒(Kernal Level Thread)
使用者級執行緒
· 使用者級執行緒只存在於使用者空間,有關它的建立、排程和管理工作都由使用者級執行緒庫來支援。使用者級執行緒庫是用於使用者級執行緒管理的例程包,支援執行緒的建立、終止,以及排程執行緒的執行並儲存和恢復執行緒的上下文,這些操作都在使用者空間執行,無需核心的支援。
· 由於核心無法感知使用者級執行緒的存在,因此核心是以程式為單位進行排程的。當核心排程一個程式執行時,使用者級執行緒庫排程該程式的一個執行緒執行,如果時間片允許,該程式的其他執行緒也可能被執行。即該程式的多個執行緒共享該程式的執行時間片。
· 若該程式的一個執行緒進行IO操作,則該執行緒呼叫系統呼叫進入核心,啟動IO裝置後,核心會把該程式阻塞,並把CPU交給其他程式。即使被阻塞的程式的其他執行緒可以執行,核心也不會發現這一情況。在該程式的狀態變為就緒前,核心不會排程它執行。屬於該程式的執行緒都不可能執行,因而使用者級執行緒的並行性會受到一定的限制。
核心級執行緒
· 核心級執行緒的所有建立、排程及管理操作都由作業系統核心完成,核心儲存執行緒的狀態及上下文資訊。
· 當一個執行緒引起阻塞的系統呼叫時,核心可以排程程式的其他執行緒執行,多處理器系統上,核心分派屬於同一程式的多個執行緒在多個處理器上執行,提升程式的並行度。
· 核心管理執行緒效率比使用者態管理執行緒慢的多。
作業系統的三種執行緒模型
1.多對一模型
多對一模型
允許將多個使用者級執行緒對映到一個核心執行緒。執行緒管理是在使用者空間進行的,效率比較高。如果有一個執行緒執行了阻塞系統呼叫,那麼整個程式就會阻塞。所以任意時刻只允許一個執行緒訪問核心,這樣多個執行緒不能並行執行在多處理器上。雖然多對一模型對建立使用者級執行緒的數目並沒有限制,但這些執行緒在同一時刻只能有一個被執行。
2.一對一模型
一對一模型
每個使用者執行緒對映到一個核心執行緒。當一個執行緒執行阻塞系統呼叫,該模型允許另一個執行緒繼續執行。這樣提供了更好的併發功能。該模型也允許多個執行緒執行在多核處理器上。一對一模型可以獲得高併發性,但因耗費資源而使執行緒數會受到限制。
3.多對多模型
多對多模型
在多對一模型和一對一模型中取了個折中,克服了多對一模型的併發度不高的缺點,又克服了一對一模型的一個使用者程式佔用太多核心級執行緒,開銷太大的缺點。又擁有多對一模型和一對一模型各自的優點,可謂集兩者之所長。
GO併發排程模型——G-P-M模型
GO可以使用如下方式建立一個"執行緒"(GO語言中所謂的goroutine)。
go func(paramName paramType, ...){ //函式體}(param, ...)
等價於Java程式碼
new java.lang.Thread(() -> { // do something in one new thread }).start();
G-P-M模型圖解
G-P-M模型
其圖中的G, P和M都是Go語言執行時系統(其中包括記憶體分配器,併發排程器,垃圾收集器等元件,可以想象為Java中的JVM)抽象出來概念和資料結構物件。
G:G就是goroutine,透過go關鍵字建立,封裝了所要執行的程式碼邏輯,可以稱為是使用者執行緒。屬於使用者級資源,對OS透明,具備輕量級,可以大量建立,上下文切換成本低等特點。
P:Processor即邏輯處理器,預設GO執行時的Processor數量等於CPU數量,也可以透過GOMAXPROCS函式指定P的數量。P的主要作用是管理G執行,每個P擁有一個本地佇列,併為G在M上的執行提供本地化資源。
M:是作業系統建立的系統執行緒,作用就是執行G中包裝的併發任務,被稱為物理處理器。其屬於OS資源,可建立的數量上也受限了OS,通常情況下G的數量都多於活躍的M的。Go執行時排程器將G公平合理的安排到多個M上去執行。
·G和M的關係:G是要執行的邏輯,M具體執行G的邏輯。Java中Thread實際上就是對M的封裝,透過指定run()函式指定要執行的邏輯。GO語言中講二者分開,透過P建立G和M的聯絡從而執行。
·G和P的關係:P是G的管理者,P將G交由M執行,並管理一定系統資源供G使用。一個P管理儲存在其本地佇列的所有G。P和G是1:n的關係。
·P和M的關係:P和M是1:1的關係。P將管理的G交由M具體執行,當遇到阻塞時,P可以與M解綁,並找到空閒的M進行繫結繼續執行佇列中其他可執行的G。
問題:為什麼要有P?
G是對需要執行的程式碼邏輯的封裝,M具體執行G,P存在的意義是什麼?
Go語言執行時系統早期(Go1.0)的實現中並沒有P的概念,Go中的排程器直接將G分配到合適的M上執行。但這樣帶來了很多問題,例如,不同的G在不同的M上併發執行時可能都需向系統申請資源(如堆記憶體),由於資源是全域性的,將會由於資源競爭造成很多系統效能損耗。
Go 1.1起執行時系統加入了P,讓P去管理G物件,M要想執行G必須先與一個P繫結,然後才能執行該P管理的G。P物件中預先申請一些系統資源作為本地資源,G需要的時候先向自己的P申請(無需鎖保護),如果不夠用或沒有再向全域性申請,而且從全域性拿的時候會多拿一部分,以供後面高效的使用。
P的存在解耦了G和M,當M執行的G被阻塞時,P可以繫結到其他M上繼續執行其管理的G,提升併發效能。
GO排程過程
①建立一個goroutine,排程器會將其放入全域性佇列。
②排程器為每個goroutine分配一個邏輯處理器。並放到邏輯處理器的本地佇列中。
③本地佇列中的goroutine會一直等待直到被邏輯處理器執行。
func task1() { go task2() go task3()}
假設現在task1在稱為G1的goroutine中執行,並在執行過程中建立兩個新的goroutine,新建立的兩個goroutine將會被放到全域性佇列中,排程器會再將他們分配給合適的P。
問題:如果遇到阻塞的情況怎麼處理?
假設正在執行的goroutine要執行一個阻塞的系統呼叫,如開啟一個檔案,在這種情況下,這個M將會被核心排程器排程出CPU並處於阻塞狀態。相應的與M相關聯的P的本地佇列中的其他G將無法被執行,但Go執行時系統的一個監控執行緒(sysmon執行緒)能探測到這樣的M,將M與P解綁,M將繼續被阻塞直到系統呼叫返回。P則會尋找新的M(沒有則建立一個)與之繫結並執行剩餘的G。
當之前被阻塞的M得到返回後,相應的G將會被放回本地佇列,M則會儲存好,等待再次使用。
goroutine阻塞處理
問題:GO有時間片概念嗎?
和作業系統按時間片排程執行緒不同,Go並沒有時間片的概念。如果一個G沒有發生阻塞的情況(如系統呼叫或阻塞在channel上),M是如何讓G停下來並排程下一個G的呢?
G是被搶佔排程的。GO語言執行時,會啟動一個名為sysmon的M,該M無需繫結P。sysmon每20us~10ms啟動一次,按照《Go語言學習筆記》中的總結,sysmon主要完成如下工作:
1.回收閒置超過5分鐘的span實體記憶體 2.如果超過2分鐘沒有垃圾回收,強制執行 3.向長時間執行的G任務發出搶佔排程 4.收回因syscall長時間阻塞的P 5.將長時間未處理的netpoll結果新增到任務佇列
注:go的記憶體分配也是基於兩種粒度的記憶體單位:span和object。span是連續的page,按page的數量進行歸類,比如分為2個page的span,4個page的span等。object是span中按預設大小劃分的塊,也是按大小分類。同一個span中,只有一種型別大小的object。
sysmom的大致工作思路:
//$GOROOT/src/runtime/proc.go// main方法func main() { ... ... systemstack(func() { newm(sysmon, nil) }) .... ... }func sysmon() { // 回收閒置超過5分鐘的span實體記憶體 scavengelimit := int64(5 * 60 * 1e9) ... ... if .... { ... ... // 如果P因syscall阻塞 //透過retake方法搶佔G if retake(now) != 0 { idle = 0 } else { idle++ } ... ... } }
搶佔方法retake
// forcePreemptNS是指定的時間片,超過這一時間會嘗試搶佔const forcePreemptNS = 10 * 1000 * 1000 // 10msfunc retake(now int64) uint32 { ... ... // 實施搶佔 t := int64(_p_.schedtick) if int64(pd.schedtick) != t { pd.schedtick = uint32(t) pd.schedwhen = now continue } if pd.schedwhen+forcePreemptNS > now { continue } preemptone(_p_) ... ... }
可以看出,如果一個G任務執行10ms,sysmon就會認為其執行時間太久而發出搶佔式排程的請求。
goroutine
1.gouroutine切換
package mainimport ( "runtime" "sync" "fmt")func main() { //指定排程器所能排程的邏輯處理器數量 runtime.GOMAXPROCS(1) //使用wg等待程式完成 var wg sync.WaitGroup //計數器+2,等待兩個goroutine wg.Add(2) fmt.Println("Start GoRoutine") //宣告一個匿名函式,使用go關鍵字建立goroutine go func() { //函式退出時透過Done通知main函式工作已經完成 defer wg.Done() for i := 1; i <= 3; i++ { for char := 'a'; char < 'a' + 26; char++ { fmt.Printf("%c ", char) } } }() go func() { defer wg.Done() for i := 1; i <= 3; i++ { for char := 'A'; char < 'A' + 26; char++ { fmt.Printf("%c ", char) } } }() //等待goroutine結束 fmt.Println("Waiting for finish") wg.Wait() fmt.Println("Program end") }
輸出:
Start GoRoutine Waiting for finish A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z Program end
這個程式給人的感覺是序列的,原因是當排程器還沒有準備切換列印小寫字母的goroutine時,列印大寫字母的goroutine就執行完了。
修改下,列印6000以內的素數。
package mainimport ( "sync" "runtime" "fmt")var wg sync.WaitGroup func main(){ runtime.GOMAXPROCS(1) wg.Add(2) go printPrime("A") go printPrime("B") fmt.Println("Wait for finish") wg.Wait() fmt.Println("Program End")} func printPrime(prefix string){ defer wg.Done() nextNum: for i := 2; i < 6000; i++ { for j := 2; j < i; j++ { if i % j == 0 { continue nextNum } } fmt.Printf("%s:%dn", prefix, i) } fmt.Printf("complete %sn", prefix) }
輸出結果:
Wait for finishB:2B:3B:5B:7B:11...B:457B:461B:463B:467A:2A:3A:5A:7...A:5981A:5987complete AB:5939B:5953B:5981B:5987complete BProgram End
在列印素數的過程中,goroutine的輸出是混在一起的,由於runtime .GOMAXPROCS(1),可以看出兩個G是在一個P上併發執行的。
package mainimport ( "runtime" "sync" "fmt")func main() { runtime.GOMAXPROCS(2) var wgp sync.WaitGroup wgp.Add(2) fmt.Println("Start Goroutines") //第一個goroutine列印3遍小寫字母 go func() { defer wgp.Done() for i := 0; i < 3; i++ { for char := 'a'; char < 'a'+26; char++ { fmt.Printf("%c ", char) } } }() //第二個goroutine列印三遍大寫字母 go func() { defer wgp.Done() for i := 0; i < 3; i++ { for char := 'A'; char < 'A'+26; char++ { fmt.Printf("%c ", char) } } }() fmt.Println("Waiting for finish") wgp.Wait() fmt.Println("nAll finish") }
輸出:
Start Goroutines Waiting for finish A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d M N O P Q R S T U V e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m W X Y Z n o p q r s t u v w x y z All finish
設定兩個P,短時間內會有類似上面列印0-6000所有素數的效果,證明兩個goroutine是並行執行的,但只有在多個P且每個可以同時讓每個G執行再一個可用的M上時,G才會達到並行的效果。
競爭狀態
多個goroutine再沒有同步的情況下,同時讀寫某個共享資源就會產生競爭狀態。對一個資源的讀寫必須是原子化的,即同一時刻只能有一個goroutine進行讀寫操作。
包含競爭狀態的例項:
package mainimport ( "sync" "runtime" "fmt")var ( counter int wgs sync.WaitGroup )func main(){ wgs.Add(2) go incrCounter() go incrCounter() fmt.Println("adding") wgs.Wait() fmt.Printf("now counter is %dn", counter)}//非原子操作加法func incrCounter() { defer wgs.Done() for i := 1; i <= 2000; i++ { val := counter //讓出處理器 runtime.Gosched() val++ counter = val } }
輸出:
adding now counter is 2000
每次讀取完count後存入副本,手動讓出M,再次輪到G執行時會將之前的副本的值進行增1的操作,覆蓋了另一個goroutine的操作。
goroutine同步的幾種方式
①原子函式
原子函式以作業系統底層的枷鎖機制同步訪問變數,使用原子鎖方式修改之前的程式碼:
package mainimport ( "sync" "fmt" "sync/atomic" "runtime")var ( counter2 int64 wg2 sync.WaitGroup )func main() { wg2.Add(2) go incrCounterAtomic() go incrCounterAtomic() fmt.Println("adding...") wg2.Wait() fmt.Printf("now counter is : %dn", counter2) fmt.Println("Program end")} func incrCounterAtomic() { defer wg2.Done() for i := 1; i <= 2000; i++ { atomic.AddInt64(&counter2, 1) runtime.Gosched() } }
輸出:
adding... now counter is : 4000Program end
互斥鎖
互斥鎖用於在程式碼當中建立一個臨界區,保證同一時間只有一個G執行臨界區的程式碼。
package mainimport ( "sync" "fmt" "runtime")var ( counter3 int wg3 sync.WaitGroup mutex sync.Mutex )func main() { wg3.Add(2) go incrCounterMutex() go incrCounterMutex() fmt.Println("Adding...") wg3.Wait() fmt.Printf("now counter is : %dn", counter3)} func incrCounterMutex() { defer wg3.Done() for i := 1; i <= 2000; i++ { //建立臨界區 mutex.Lock() { val := counter3 runtime.Gosched() val++ counter3 = val } mutex.Unlock() } }
通道
資源再goroutine之間共享時,可以使用通道實現同步。宣告通道時,需要指定將要被共享的資料型別,可以透過通道共享內建型別、命名型別、結構型別、引用型別的值或指標。GO語言使用make函式建立通道。
①無緩衝通道
無緩衝通道使用make(chan type)宣告,通道中存取訊息都是阻塞的。
樣例:
func main() { var messages chan string = make(chan string) go func(message string) { messages <- message // 存訊息 }("hello!") fmt.Println(<-messages) // 取訊息}
阻塞即無緩衝的通道道在取訊息和存訊息的時候都會掛起當前的goroutine,除非另一端已經準備好。例:
package mainimport ( "fmt" "strconv")var ch chan int = make(chan int)func main() { go input(0) <- ch fmt.Println("main finish")} func input(i int) { for i := 0; i < 10; i++ { fmt.Print(strconv.Itoa(i) + " ") } ch <- i }
嘗試註釋掉ch的輸入和輸出,對比執行結果
不註釋: 0 1 2 3 4 5 6 7 8 9 main finish 註釋: main finish
如果不用通道來阻塞主線的話,主線就會過早跑完,loop線將沒有機會執行。
無緩衝的通道永遠不會儲存資料,只負責資料的流通。
如果從無緩衝通道讀資料,必須要有資料寫入通道,否則讀取操作的goroutine將一直阻塞。
如果向無緩衝通道寫資料,必須要有其他goroutine讀取,否則該goroutine將一直被阻塞。
如果無緩衝通道中有資料,再向其寫入,或者從無流入的通道資料中讀取,則會形成死鎖。
死鎖
為什麼會死鎖?非緩衝通道上如果發生了流入無流出,或者流出無流入,也就導致了死鎖。或者這樣理解 Go啟動的所有goroutine裡的非緩衝通道一定要一個線裡存資料,一個線裡取資料,要成對才行 。
c, quit := make(chan int), make(chan int)go func() { c <- 1 // c通道的資料沒有被其他goroutine讀取走,堵塞當前goroutine quit <- 0 // quit始終沒有辦法寫入資料}() <- quit // quit 等待資料的寫
是否所有不成對向通道存取資料的情況都是死鎖?反例:
package main var ch chan int = make(chan int)func main() { go input(0)} func input(i int) { ch <- i }
通道ch中只有流入沒有流出,但執行不會報錯。原因是main沒等待其它goroutine,自己先跑完了, 所以沒有資料流入c通道,一共執行了一個goroutine, 並且沒有發生阻塞,所以沒有死鎖錯誤。
②緩衝通道
緩衝通道不僅可以流通資料,還可以快取資料。它是有容量的,存入一個資料的話 , 可以先放在通道里,不必阻塞當前線而等待該資料取走。但是當緩衝通道達到滿的狀態的時候,就會表現出阻塞了。
package main var ch chan int = make(chan int, 3)func main() { ch <- 1 ch <- 1 ch <- 1 //ch <- 1}
如果是非緩衝通道,這段程式碼會報死鎖。但是當有緩衝的通道時,程式碼正常執行。如果再加入一行資料流入,才會報死鎖。
通道可以看做是一個先進先出的佇列。
package mainimport "fmt"var ch chan int = make(chan int, 3)func main() { ch <- 1 ch <- 2 ch <- 3 fmt.Println(<- ch) fmt.Println(<- ch) fmt.Println(<- ch) }
會按照資料寫入的順序依次讀取
1 2 3
通道的其他基本操作
除了<-外,range也可以進行讀取。
package mainimport ( "fmt")var ch chan int = make(chan int, 3)func main() { ch <- 1 ch <- 2 ch <- 3 for v := range ch { fmt.Println(v) } }
輸出
1 2 3 fatal error: all goroutines are asleep - deadlock!
可以讀取但產生死鎖,原因是range不等到通道關閉不結束讀取,在沒有資料流入的情況下,產生死鎖。有兩種方式可以避免這一情況。
1.通道長度為0即結束
package mainimport ( "fmt")var ch chan int = make(chan int, 3)func main() { ch <- 1 ch <- 2 ch <- 3 for v := range ch { fmt.Println(v) if len(ch) == 0 { break } } }
2.手動關閉通道
關閉通道只是關閉了向通道寫入資料,但可以從通道讀取。
package mainimport ( "fmt")var ch chan int = make(chan int, 3)func main() { ch <- 1 ch <- 2 ch <- 3 close(ch) for v := range ch { fmt.Println(v) } }
有緩衝通道圖解:
作者:Chuck_Hu
連結:
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4686/viewspace-2819643/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 第09章 Go語言併發,Golang併發Golang
- Go語言併發程式設計Go程式設計
- 十九、Go語言基礎之併發Go
- Go語言是徹底的面向組合的併發語言Go
- Go語言專案實戰:併發爬蟲Go爬蟲
- GO 語言的併發模式你瞭解多少?Go模式
- Go語言 | CSP併發模型與Goroutine的基本使用Go模型
- Go語言併發程式設計簡單入門Go程式設計
- 《快學 Go 語言》第 13 課 —— 併發與安全Go
- Go 為什麼不在語言層面支援 map 併發?Go
- 《Go 語言併發之道》讀後感 - 第二章Go
- Go高效併發 11 | 併發模式:Go 語言中即學即用的高效併發模式Go模式
- 《Go 語言併發之道》讀後感 - 第一章Go
- 《Go 語言併發之道》讀後感 - 第三章Go
- 《Go 語言併發之道》讀後感 - 第四章Go
- Go語言之併發示例(Runner)Go
- Go語言中的併發模式Go模式
- go語言安卓開發Go安卓
- 【Go 語言入門專欄】Go 語言的起源與發展Go
- Go語言 | 併發設計中的同步鎖與waitgroup用法GoAI
- Go語言————1、初識GO語言Go
- Go語言之併發示例-Pool(二)Go
- Go語言之併發示例-Pool(一)Go
- Go 語言併發程式設計之互斥鎖詳解 sync.MutexGo程式設計Mutex
- 持續發燒,聊聊Dart語言的併發處理,能挑戰Go不?DartGo
- 優步爆Go語言容易發生的資料併發爭奪問題Go
- Rust語言神奇的併發模型Rust模型
- Go語言面試題分享:選擇題11道(2)go語言開發Go面試題
- GO語言Go
- GO 語言快速開發入門Go
- go語言開發有哪些工具Go
- Go語言開發環境搭建Go開發環境
- GO語言————2、GO語言環境安裝Go
- 第一次體驗併發語言 Go 和 PHP 的區別GoPHP
- 有Go語言實戰培訓班嗎?go語言開發環境搭建Go開發環境
- Go_go語言初探Go
- Scala,基於JVM的併發語言JVM
- 為什麼很多公司都轉型go語言開發?Go語言能做什麼Go