一、并發與并行
并發:同一時間段内執行多個任務
并行:同一時刻執行多個任務
Go語言的并發通過
goroutine
實作。
goroutine
類似于線程,屬于使用者态的線程,我們可以根據需要建立成千上萬個
goroutine
并發工作。
goroutine
是由Go語言的運作時(runtime)排程完成,而線程是由作業系統排程完成。
Go語言還提供
channel
在多個
goroutine
間進行通信。
goroutine
和
channel
是 Go 語言秉承的 CSP(Communicating Sequential Process)并發模式的重要實作基礎。
二、goroutine
Go語言中的
goroutine
就是這樣一種機制,
goroutine
的概念類似于線程,但
goroutine
是由Go的運作時(runtime)排程和管理的。Go程式會智能地将 goroutine 中的任務合理地配置設定給每個CPU。Go語言之是以被稱為現代化的程式設計語言,就是因為它在語言層面已經内置了排程和上下文切換的機制。
在Go語言程式設計中你不需要去自己寫程序、線程、協程,你的技能包裡隻有一個技能–
goroutine
,當你需要讓某個任務并發執行的時候,你隻需要把這個任務包裝成一個函數,開啟一個
goroutine
去執行這個函數就可以了,就是這麼簡單粗暴。
使用goroutine
Go語言中使用
goroutine
非常簡單,隻需要在調用函數的時候在前面加上
go
關鍵字,就可以為一個函數建立一個
goroutine
。
一個
goroutine
必定對應一個函數,可以建立多個
goroutine
去執行相同的函數。
啟動單個goroutine
啟動goroutine的方式非常簡單,隻需要在調用的函數(普通函數和匿名函數)前面加上一個
go
關鍵字。
舉個例子如下:
func hello() {
fmt.Println("Hello Goroutine!")
}
func main() {
hello()
fmt.Println("main goroutine done!")
}
這個示例中hello函數和下面的語句是串行的,執行的結果是列印完
Hello Goroutine!
後列印
main goroutine done!
。
接下來我們在調用hello函數前面加上關鍵字
go
,也就是啟動一個goroutine去執行hello這個函數。
func main() {
go hello() // 啟動另外一個goroutine去執行hello函數
fmt.Println("main goroutine done!")
}
這一次的執行結果隻列印了
main goroutine done!
,并沒有列印
Hello Goroutine!
。為什麼呢?
在程式啟動時,Go程式就會為
main()
函數建立一個預設的
goroutine
。
當main()函數傳回的時候該
goroutine
就結束了,所有在
main()
函數中啟動的
goroutine
會一同結束,
main
函數所在的
goroutine
就像是權利的遊戲中的夜王,其他的
goroutine
都是異鬼,夜王一死它轉化的那些異鬼也就全部GG了。
是以我們要想辦法讓main函數等一等hello函數,最簡單粗暴的方式就是
time.Sleep
了。
func main() {
go hello() // 啟動另外一個goroutine去執行hello函數
fmt.Println("main goroutine done!")
time.Sleep(time.Second)
}
執行上面的代碼你會發現,這一次先列印
main goroutine done!
,然後緊接着列印
Hello Goroutine!
。
首先為什麼會先列印
main goroutine done!
是因為我們在建立新的goroutine的時候需要花費一些時間,而此時main函數所在的
goroutine
是繼續執行的。
啟動多個goroutine
在Go語言中實作并發就是這樣簡單,我們還可以啟動多個
goroutine
。讓我們再來一個例子: (這裡使用了
sync.WaitGroup
來實作goroutine的同步)
var wg sync.WaitGroup
func hello(i int) {
defer wg.Done() // goroutine結束就登記-1
fmt.Println("Hello Goroutine!", i)
}
func main() {
for i := 0; i < 10; i++ {
wg.Add(1) // 啟動一個goroutine就登記+1
go hello(i)
}
wg.Wait() // 等待所有登記的goroutine都結束
}
多次執行上面的代碼,會發現每次列印的數字的順序都不一緻。這是因為10個
goroutine
是并發執行的,而
goroutine
的排程是随機的。
goroutine與線程
可增長的棧
OS線程(作業系統線程)一般都有固定的棧記憶體(通常為2MB),一個
goroutine
的棧在其生命周期開始時隻有很小的棧(典型情況下2KB),
goroutine
的棧不是固定的,他可以按需增大和縮小,
goroutine
的棧大小限制可以達到1GB,雖然極少會用到這麼大。是以在Go語言中一次建立十萬左右的
goroutine
也是可以的。
goroutine排程
GPM
是Go語言運作時(runtime)層面的實作,是go語言自己實作的一套排程系統。差別于作業系統排程OS線程。
-
很好了解,就是個goroutine的,裡面除了存放本goroutine資訊外 還有與所在P的綁定等資訊。G
-
管理着一組goroutine隊列,P裡面會存儲目前goroutine運作的上下文環境(函數指針,堆棧位址及位址邊界),P會對自己管理的goroutine隊列做一些排程(比如把占用CPU時間較長的goroutine暫停、運作後續的goroutine等等)當自己的隊列消費完了就去全局隊列裡取,如果全局隊列裡也消費完了會去其他P的隊列裡搶任務。P
-
是Go運作時(runtime)對作業系統核心線程的虛拟, M與核心線程一般是一一映射的關系, 一個groutine最終是要放到M上執行的;M(machine)
P與M一般也是一一對應的。他們關系是: P管理着一組G挂載在M上運作。當一個G長久阻塞在一個M上時,runtime會建立一個M,阻塞G所在的P會把其他的G 挂載在建立的M上。當舊的G阻塞完成或者認為其已經死掉時 回收舊的M。
P的個數是通過
runtime.GOMAXPROCS
設定(最大256),Go1.5版本之後預設為實體線程數。 在并發量大的時候會增加一些P和M,但不會太多,切換太頻繁的話得不償失。
單從線程排程講,Go語言相比起其他語言的優勢在于OS線程是由OS核心來排程的,
goroutine
則是由Go運作時(runtime)自己的排程器排程的,這個排程器使用一個稱為m:n排程的技術(複用/排程m個goroutine到n個OS線程)。 其一大特點是goroutine的排程是在使用者态下完成的, 不涉及核心态與使用者态之間的頻繁切換,包括記憶體的配置設定與釋放,都是在使用者态維護着一塊大的記憶體池, 不直接調用系統的malloc函數(除非記憶體池需要改變),成本比排程OS線程低很多。 另一方面充分利用了多核的硬體資源,近似的把若幹goroutine均分在實體線程上, 再加上本身goroutine的超輕量,以上種種保證了go排程方面的性能。
了解更多
GOMAXPROCS
Go運作時的排程器使用
GOMAXPROCS
參數來确定需要使用多少個OS線程來同時執行Go代碼。預設值是機器上的CPU核心數。例如在一個8核心的機器上,排程器會把Go代碼同時排程到8個OS線程上(GOMAXPROCS是m:n排程中的n)。
Go語言中可以通過
runtime.GOMAXPROCS()
函數設定目前程式并發時占用的CPU邏輯核心數。
Go1.5版本之前,預設使用的是單核心執行。Go1.5版本之後,預設使用全部的CPU邏輯核心數。
我們可以通過将任務配置設定到不同的CPU邏輯核心上實作并行的效果,這裡舉個例子:
func a() {
for i := 1; i < 10; i++ {
fmt.Println("A:", i)
}
}
func b() {
for i := 1; i < 10; i++ {
fmt.Println("B:", i)
}
}
func main() {
runtime.GOMAXPROCS(1)
go a()
go b()
time.Sleep(time.Second)
}
兩個任務隻有一個邏輯核心,此時是做完一個任務再做另一個任務。 将邏輯核心數設為2,此時兩個任務并行執行,代碼如下。
func a() {
for i := 1; i < 10; i++ {
fmt.Println("A:", i)
}
}
func b() {
for i := 1; i < 10; i++ {
fmt.Println("B:", i)
}
}
func main() {
runtime.GOMAXPROCS(2)
go a()
go b()
time.Sleep(time.Second)
}
Go語言中的作業系統線程和goroutine的關系:
- 一個作業系統線程對應使用者态多個goroutine。
- go程式可以同時使用多個作業系統線程。
- goroutine和OS線程是多對多的關系,即m:n。