并發和并行
Go是并發語言,而不是并行語言。(Go is a concurrent language and not a parallel one.)
并發(Concurrency)的關鍵在于你有處理多個任務的能力,不一定要同時
并行(Parallellism)的關鍵是你有同時處理多個任務的能力
最關鍵的點就是:是否是『同時』
下面舉個例子來說明:
假設我們正在編寫一個web瀏覽器。web瀏覽器有各種組件。其中兩個是web頁面呈現區域和下載文件從internet下載的下載器。假設我們以這樣的方式構建了瀏覽器的代碼,這樣每個組件都可以獨立地執行。
當這個瀏覽器運行在單個核處理器中時,處理器將在瀏覽器的兩個組件之間進行上下文切換。它可能會下載一個文件一段時間,然后它可能會切換到呈現用戶請求的網頁的html。這就是所謂的并發性。并發進程從不同的時間點開始,它們的執行周期重疊。在這種情況下,下載和呈現從不同的時間點開始,它們的執行重疊。
假設同一瀏覽器運行在多核處理器上。在這種情況下,文件下載組件和HTML呈現組件可能同時在不同的內核中運行。這就是所謂的并行性。
需要說明的是:并行性Parallelism不會總是導致更快的執行時間,這是因為并行運行的組件可能需要相互通信。
進程,線程和協程
進程(Process)
進程是可并發執行的程序在某個數據集合上的一次計算活動,也是操作系統進行資源分配和調度的基本單位。進程一般由程序、數據集、進程控制塊三部分組成。進程的局限是創建、撤銷和切換的開銷比較大。線程(Thread)
線程是操作系統進程中能夠并發執行的實體,是處理器調度和分派的基本單位。每個進程內可包含多個可并發執行的線程。線程的優點是減小了程序并發執行時的開銷,提高了操作系統的并發性能,缺點是線程自己基本不擁有系統資源,只擁有少量必不可少的資源:程序計數器、一組寄存器、棧。同屬一個進程的線程共享進程所擁有的主存空間和資源。-
協程(Coroutine)
協程是一種用戶態的輕量級線程,又稱微線程,英文名Coroutine,協程的調度完全由用戶控制。人們通常將協程和子程序(函數)比較著理解。 子程序調用總是一個入口,一次返回,一旦退出即完成了子程序的執行。Go語言對于并發的實現是靠協程:Goroutine。
Go的并發調度:G-P-M模型
在操作系統提供的內核線程之上,Go搭建了一個特有的兩級線程模型。goroutine機制實現了M : N的線程模型,goroutine機制是協程(coroutine)的一種實現,golang內置的調度器,可以讓多核CPU中每個CPU執行一個協程。
1. 調度器如何工作
// 用go關鍵字加上一個函數(這里用了匿名函數)
// 調用就做到了在一個新的“線程”并發執行任務
go func() {
// do something in one new goroutine
}()
調度器的實現主要包括4個結構:M,P,G,Sched,前三個定義在runtime.h中,Sched定義在proc.c中。
* Sched結構就是調度器,它維護有存儲M和G的隊列以及調度器的一些狀態信息等。
* M結構是Machine,系統線程,它由操作系統管理的,goroutine就是跑在M之上的;M是一個很大的結構,里面維護小對象內存cache(mcache)、當前執行的goroutine、隨機數發生器等等非常多的信息。
* P結構是Processor,處理器,它的主要用途就是用來執行goroutine的,它維護了一個goroutine隊列,即runqueue。Processor是讓我們從N:1調度到M:N調度的重要部分。
* G是goroutine實現的核心結構,它包含了棧,指令指針,以及其他對調度goroutine很重要的信息,例如其阻塞的channel。
說明:Processor的數量是在啟動時被設置為環境變量GOMAXPROCS的值,也可以通過運行時調用函數runtime.GOMAXPROCS()進行設置。Processor數量固定意味著任意時刻只有GOMAXPROCS個線程在運行go代碼。
在單核處理器的場景下,所有goroutine運行在同一個M系統線程中,每一個M系統線程維護一個Processor,任何時刻,一個Processor中只有一個goroutine,其他goroutine在runqueue中等待。一個goroutine運行完自己的時間片后,讓出上下文,回到runqueue中。 多核處理器的場景下,為了運行goroutines,每個M系統線程會持有一個Processor。
2. 線程阻塞
正常情況下,Go調度器會按照上面的流程進行調度,但當發生阻塞時,比如Goroutine進行系統調用,Go調度器會再創建一個線程(或者從線程池取),當前的M線程放棄了它的Processor,P轉到新的線程中去運行。
3. runqueue執行完成
當其中一個Processor的runqueue為空,沒有goroutine可以調度。它會從另外一個上下文偷取一半的goroutine。
Go原生支持并發:Go調度器負責將并發任務分配到不同的內核線程上運行,然后內核調度器接管內核線程在CPU上的執行與調度。
共享資源安全問題
如果兩個或者多個 goroutine 在沒有互相同步的情況下,訪問某個共享的資源,并試圖同時 讀和寫這個資源,就處于相互競爭的狀態,這種情況被稱作競爭狀態(race candition)。如果這種競爭狀態處理不當,可能會出現安全問題。
package main
import (
"fmt"
"runtime"
"sync"
)
var (
count int32
wg sync.WaitGroup
)
func main() {
wg.Add(2)
go incCount()
go incCount()
wg.Wait()
fmt.Println(count)
}
func incCount() {
defer wg.Done()
for i := 0; i < 2; i++ {
value := count
runtime.Gosched()
value++
count = value
}
}
//輸出:2
鎖住共享資源
- 原子函數
原子函數能夠以很底層的加鎖機制來同步訪問整型變量和指針。
sync/atomic - 互斥鎖
互斥鎖用于在代碼上創建一個臨界區,保證同一時間只有一個 goroutine 可以 執行這個臨界區代碼。
package main
import (
"fmt"
"runtime"
"sync"
)
var (
count int32
wg sync.WaitGroup//聲明互斥鎖
mutex sync.Mutex
)
func main() {
wg.Add(2)
go incCount()
go incCount()
wg.Wait()
fmt.Println(count)
}
func incCount() {
defer wg.Done()
for i := 0; i < 2; i++ {
mutex.Lock()//臨界區上鎖
value := count
runtime.Gosched()
value++
count = value
mutex.Unlock()//解鎖
}
}
Channel
使用通道,通過發送和接收需要共享的資源,在 goroutine 之間做同步。聲明通道時,需要指定將要被共享的數據的類型。通道分為有緩沖通道和無緩沖通道。
// 無緩沖的整型通道
unbuffered := make(chan int)
// 有緩沖的字符串通道
buffered := make(chan string, 10)
//定義單向管道
var send chan<- int //只能發送
var receive <-chan int //只能接收
// 有緩沖的字符串通道
buffered := make(chan string, 10)
// 通過通道發送一個字符串
buffered <- "Gopher"
// 從通道接收一個字符串
value := <-buffered
對通道的操作行為總結:
操作 | unbuffered channel | closed channel | not-closed buffered channel |
---|---|---|---|
close | 成功close | panic | 成功 close |
寫 ch <- | 阻塞 | panic | 通道滿:阻塞;通道未滿:成功寫入數據 |
讀 <- ch | 阻塞 | 通道內有數據可讀:成功讀取數據;通道內沒有數據可讀:返回對應類型的零值 | 通道空:阻塞;通道非空:成功讀取數據 |
讀取一個已關閉的 channel 時,總是能讀取到對應類型的零值,為了和讀取非空未關閉 channel 的行為區別,可以使用兩個接收值來加以判斷:
// ok is false when ch is closed
v, ok := <-ch
優雅的關閉Channel
關閉channel應遵循以下準則:
- 不要在讀取端關閉 channel ,因為寫入端無法知道 channel 是否已經關閉,往已關閉的 channel 寫數據會 panic ;
- 有多個寫入端時,不要再寫入端關閉 channle ,因為其他寫入端無法知道 channel 是否已經關閉,關閉已經關閉的 channel 會發生 panic ;
- 如果只有一個寫入端,可以在這個寫入端放心關閉 channel ;
具體分析:
- 一寫多讀
這種場景下這個唯一的寫入端可以關閉 channel 用來通知讀取端所有數據都已經寫入完成了。讀取端只需要用 for range 把 channel 中數據遍歷完就可以了,當? channel 關閉時,for range 仍然會將 channel 緩沖中的數據全部遍歷完然后再退出循環:
package main
import (
"fmt"
"sync"
)
func main() {
wg := &sync.WaitGroup{}
ch := make(chan int, 100)
send := func() {
for i := 0; i < 100; i++ {
ch <- i
}
// signal sending finish
close(ch)
}
recv := func(id int) {
defer wg.Done()
for i := range ch {
fmt.Printf("receiver #%d get %d\n", id, i)
}
fmt.Printf("receiver #%d exit\n", id)
}
wg.Add(3)
go recv(0)
go recv(1)
go recv(2)
send()
wg.Wait()
}
- 多寫一讀
這種場景下雖然可以用 sync.Once 來解決多個寫入端重復關閉 channel 的問題,但更優雅的辦法設置一個額外的 channel ,由讀取端通過關閉來通知寫入端任務完成不要再繼續再寫入數據了:
package main
import (
"fmt"
"sync"
)
func main() {
wg := &sync.WaitGroup{}
ch := make(chan int, 100)
done := make(chan struct{})
send := func(id int) {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-done:
// get exit signal
fmt.Printf("sender #%d exit\n", id)
return
case ch <- id*1000 + i:
}
}
}
recv := func() {
count := 0
for i := range ch {
fmt.Printf("receiver get %d\n", i)
count++
if count >= 1000 {
// signal recving finish
close(done)
return
}
}
}
wg.Add(3)
go send(0)
go send(1)
go send(2)
recv()
wg.Wait()
}
- 多寫多讀
這種場景稍微復雜,和上面的例子一樣,也需要設置一個額外 channel 用來通知多個寫入端和讀取端。另外需要起一個額外的協程來通過關閉這個 channel 來廣播通知:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
wg := &sync.WaitGroup{}
ch := make(chan int, 100)
done := make(chan struct{})
send := func(id int) {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-done:
// get exit signal
fmt.Printf("sender #%d exit\n", id)
return
case ch <- id*1000 + i:
}
}
}
recv := func(id int) {
defer wg.Done()
for {
select {
case <-done:
// get exit signal
fmt.Printf("receiver #%d exit\n", id)
return
case i := <-ch:
fmt.Printf("receiver #%d get %d\n", id, i)
time.Sleep(time.Millisecond)
}
}
}
wg.Add(6)
go send(0)
go send(1)
go send(2)
go recv(0)
go recv(1)
go recv(2)
time.Sleep(time.Second)
// signal finish
close(done)
// wait all sender and receiver exit
wg.Wait()
}