[Golang] goroutines, channels, and concurrency
此篇為各筆記之整理,非原創內容,資料來源可見文後參考資料。
TL;DR
- 從一個 goroutine 切換到另一個 goroutine 的時機點是「當正在執行的 goroutine 阻塞時,就會交給其他 goroutine 做事」
- unbuffered channel 指的是 buffer size 為 0 的 channel
- 對於 unbuffered channel 來說,不論是從 channel 讀資料(需等到被寫入),或把資料寫入 channel 中時(需等到被讀出),都會阻塞該 goroutine
- 對於 buffered channel 來說:
- 從 channel 讀值時若是 empty buffer 時才會阻塞,否則都是 non-blocking
- 把資料寫入 channel 中時,寫入 channel 中的 value 數目(n + 1)需要超過 buffer size(n),也就是溢出(overflow)時才會使得該 goroutine 被阻塞;而且一旦 buffer channel 中的值開始被讀取,就會被全部讀完
// go routine
go f(x, y, z)
// channels
// 和 maps, slices, channels 一樣需要在使用前被建立,這裡表示定義的 chan 會回傳 int
ch := make(chan int)
ch <- v // Send v to channel ch.
v, ok := <-ch // Receive from ch, and
// assign value to v.
概念釐清
goroutines vs threads
- goroutines 是由 Go runtime 所管理的輕量化的 thread
- goroutines 會在相同的 address space 中執行,因此要存取共享的記憶體必須要是同步的(synchronized)。
- 當我們在執行 Go 程式時,Go runtime 會建立許多 threads,當某一個 goroutine 的 thread 被阻塞時,它會切換去其它 thread 執行其他的 goroutine,這個過程很類似 thread scheduling,但它是由 go runtime 來處理,而且速度更快
- 傳統的 Apache 伺服器來說,當每分鐘需要處理 1000 個請求時,每個請求如果都要 concurrently 的運作,將會需要建立 1000 個 threads 或者分派到不同的 process 去做,如果 OS 的每個 thread 都需要使用 1MB 的 stack size 的話,就會需要 1GB 的記憶體才能撐得住這樣的流量。但相對於 goroutine 來說,因為 stack size 可以動態增長,因此可以擴充到 1000 個 goroutines,每個 goroutine 只需要 2KB(Go 1.4 之後)的 stack size。
- 在 Go 1.5 之後,Golang 預設會使用的 CPU 的數目(
GOMAXPROCS
)將會根據電腦實體 CPU 的數目來決 定 - 使用越多的 CPU 來執行不見得會有更好的效能,因為不同 CPU 之間需要更多時間來進行溝通和資料交換,透過
runtime.GOMAXPROCS(n)
可以改變 go runtime 使用的處理器數目
OS thread | goroutine |
---|---|
由 OS kernel 管理,相依於硬體 | goroutines 是由 go runtime 管理,不依賴於硬體 |
OS threads 一般有固定 1-2 MB 的 stack size | goroutines 的 stack size 約 8KB(自從 Go 1.4 開始為 2KB) |
在編譯的時候就決定了 stack 的大小,並且不能增長 | 由於是在 run-time 管理 stack size,透過分配和釋放 heap storage 可以增長到 1GB |
不同 thread 之間沒有簡易的溝通媒介,並且溝通時易有延遲 | goroutine 使用 channels 來和其它的 goroutine 溝通,且低延遲 |
thread 有 identity,透過 TID 可以辨別 process 中的不同 thread | goroutine 沒有 identity |
Thread 有需要 setup 和 teardown cost,需要向 OS 請求資源並在完成時還回去 | goroutine 是在 go 的 runtime 中建立和摧毀,和 OS threads 相比非常容易,因為 go runtime 已經為 goroutines 建立了 thread pools,因此 OS 並不會留意到 coroutines |
threads 需要先被 scheduled,在不同 thread 間切換時的消耗很高,因為 scheduler 需要儲存和還原 |
資料來源:threads vs goroutines @ gist
concurrency vs parallelism
- Concurrency 指的是開啟很多的 threads 在執行程式碼,但它們並不是「同時」執行,而是透過快速切換來執行(只有一個 CPU 在負責)。
- Parallelism 指的是開啟很多 threads 「同時」執行程式碼,需要倚靠多個 CPU。
"concurrency is dealing with multiple things at once, parallelism is doing multiple things at once"(Achieving concurrency in Go)
Goroutines
- 每個 Go 程式預設都會建立一個 goroutine,這被稱作是 main goroutine,也就是函式
main
中執行的內容 - 所有的 goroutines 都是沒有名稱的(anonymous),因為 goroutine 並沒有 identity
- 在下面這段程式中,當 main goroutine 開始執行時,go 排程器(scheduler)並不會將控制權交給
printHello
這個 goroutine,因此當 goroutine 執行完畢後,程式會立即中止,而排程器並沒有機會把printHello
這個 goroutine 加入排程中。
func printHello() {
fmt.Println("Hello World")
}
func main() {
fmt.Println("main execution started")
// call function
go printHello()
fmt.Println("main execution stopped")
}
但我們知道,當 goroutine 被阻塞的時候,就會把控制權交給其他的 goroutine,因此這裡可以試著用 time.Sleep()
來把它阻塞:
func printHello() {
fmt.Println("Hello World")
}
func main() {
fmt.Println("main execution started")
// call function
go printHello()
// block here
time.Sleep(10 * time.Millisecond)
fmt.Println("main execution stopped")
}
anonymous goroutine
func main() {
fmt.Println("main() started")
c := make(chan string)
// anonymous goroutine
go func(c chan string) {
fmt.Println("Hello " + <-c + "!")
}(c)
c <- "John"
fmt.Println("main() ended")
}
Channels
var zeroC chan int // channel 的 zero value 是 nil
unbufferedC := make(chan int) // unbuffered channel 的 buffered size 是 0
bufferedC := make(chan int, 3) // capacity 為 3 的 buffered channel
Unbuffered Channels
func main() {
// channel 的 zero value 是 nil
var zeroC chan int
fmt.Println(zeroC)
// 一般建立 channel 的方式
c := make(chan int) // unbuffered channel
fmt.Printf("type of c is %T\n", c) // type of c is chan int
fmt.Printf("value of c is %v\n", c) // value of c is 0xc000062060
}
- 所有的 unbuffered channel 操作預設都是 blocking 的
- 當有資料要寫入 channel 時,goroutine 會阻塞住,直到有其他的 goroutine 從該 channel 把值讀出來
- 當有資料要讀取 channel 中的值時,goroutine 也會阻塞,直到其他 goroutine 把值寫入 channel 中
- 也就是說,當我們是的把資料寫入 channel 或從 channel 中取出資料時,該 goroutine 都會阻塞住,並且將控制權交給其他可以運行的 goroutines
// 程式來源:https://medium.com/rungo/anatomy-of-channels-in-go-concurrency-in-go-1ec336086adb
func greet(c chan string) {
fmt.Println("Hello " + <-c + "!")
}
func main() {
fmt.Println("main() started")
c := make(chan string)
go greet(c)
// block here (把控制權交給其他 goroutine,這裡也就是 greet)
c <- "John"
fmt.Println("main() stopped")
}
⚠️ 雖然讀取值的時候會 blocking 等到有值出來,但並不表示來得及被 print 出來,以下面的程式為例:
func greet(c chan string) {
fmt.Println(<-c)
fmt.Println(<-c)
}
func main() {
fmt.Println("main() started")
c := make(chan string)
go greet(c)
c <- "John"
c <- "Mike"
fmt.Println("main() stopped")
}
// main() started
// John
// main() stopped
輸出結果只會看到 John
,這是因為雖然第二個 <-c
一樣會 blocking 並等待值送入 channel,但是當 Mike 的值送入 channel 中,而 greet goroutine 收到值要 print 出來時,main goroutine 已經執行結束了,因此最終我們看不到 Mike(若想看到 Mike 可以在 main goroutine 使用 time.Sleep(time.Millisecond)
)
Close Channel(關閉頻道)
c := make(chan string)
close(c) // 關閉 channel
val, ok := c // ok 如果是 false 表示 channel 已經被關閉
- 當 channel 已經被關閉時,
ok
會是false
,value
則會是 zero value - ⚠️ 只有 sender 可以使用
close
,receiver 使用的話會發生 panic。
Deadlock
由於 channel 的資料在讀/寫時,goroutine 會阻塞,並且將控制權交給其他可以運行的 goroutines,因此若沒有其他可以運行的 goroutines 時,就會發生 deadlock
的情況,整個程式則會 crash。
也就是說,如果你試著從 channel 中讀資料,但 channel 中並沒有可以被讀取的值時,它會使得當前的 goroutine 阻塞,並期待其他 goroutine 會 把值塞入這個 channel,此時「讀取資料」的操作會阻塞。相似地,如果你想要傳送資料到某一個 channel 中,它同樣會阻塞當前的 goroutine,並期待其他的 goroutine 有人去讀取這個值,這時候「寫入資料(send operation)」的操作會被阻塞。
- 從 channel 中讀不到資料 -> 讀取資料的操作會阻塞 -> deadlock
- 寫入資料到 channel -> 沒人讀取此 channel 的值 -> 寫入資料的操作會阻塞 -> deadlock
// 只是寫入資料但沒有 channel 讀取 -> deadlock
func main() {
fmt.Println("main() started")
// 只是寫入資料但沒有 channel 讀取
c := make(chan string)
c <- "John"
fmt.Println("main() stopped")
}
相似地:
// 有 goroutine 要讀取 channel 但沒 goroutine 寫入資料到 channel -> deadlock
func greet(c chan string) {
// 要讀取 channel 但沒人寫入資料
fmt.Println("Hello " + <-c + "!")
}
func main() {
fmt.Println("main() started")
c := make(chan string)
// c <- "John"
greet(c)
fmt.Println("main() stopped")
}
Buffered channel
buffered channel 寫值時,需要在 overflow 時才會 block goroutine
- unbuffered channel 指的是 buffered size 為 0 的 channel。
- unbuffered channel 不論是「從 channel 讀值」(需等到值被其他 goroutine 寫入),或「把值寫入 channel」(需等到值被其他 goroutine 讀出)都會阻塞當下的 goroutine。
- 當 buffer size 不是 0 的話,就屬於 buffered channel
- 「從 channel 讀值」時,只有在 buffered 是空的時才會 blocking
- 「把值寫入 channel」時,該 goroutine 並不會被阻塞,除非該 buffer 已經填滿(full)且溢出(overflow)。當 buffer 已經填滿(full)時,再把新的一筆資料傳入 channel 時會造成溢出(overflow),此時 goroutine 才會被阻塞。
- 讀值的動作一旦開始,就會一直到 buffer 變成 empty 為止才會結束。也就是說,讀取 channel 的那個 goroutine 需到等到 buffer 完全清空後才會阻塞。
舉例來說,在建立一個 buffered channel 後:
// 這個 chan 只能接收兩個長度的 buffer
// channel := make(chan, [type], [size])
ch := make(chan, int, 2)