[Go] Go中最強的的魔法: 併發程式執行

當 Google 工程師設計 Go 語言時,他們明確將多核支援和原生併發支援定為其核心設計目標,同時將併發作為 Go 的設計哲學的一部分。Go 語言一經公開發布,其對併發的原生支援便立刻成為開發者們最為關注的特色之一。

想要掌握 Go 的併發特性,首先需要了解兩個方面:併發的基本概念和 Go 如何實現對併發的支持,包括 goroutine、channel 和 select 等語言特性。

本篇文章將首先介紹 goroutine 的基礎知識及其使用注意事項,讓讀者對 Go 的併發特性有一個初步的認識,之後將進一步深入探討。

併發是在設計和實現應用時必須考慮的問題。併發設計將應用分為多個可以獨立執行且互相協作的Component。

在傳統的多執行緒設計語言中,如 C 或 C++,應用通常是基於多執行緒模型設計的。然而,這些語言並不是為了支援併發而生,並沒有提供太多針對併發的幫助。這些語言主要使用作業系統的執行緒來執行分解後的程式碼片段,並由作業系統負責調度。這種傳統的支援方式存在許多缺點:

首先是複雜性。

執行緒的創建相對容易,但退出則困難重重。如果你有 C/C++ 的開發經驗,你會知道,儘管創建執行緒時需要傳入多個參數,這還算是可以接受的。但一旦涉及到執行緒的退出,就必須考慮是否要讓新執行緒與主執行緒分離(detach),或是讓主執行緒等待子執行緒結束並獲取其狀態(join)。另外,還可能需要在新執行緒中設置取消點(cancel point),以確保在被主執行緒取消時能順利終止。

此外,併發執行緒間的通信困難且易出錯。雖然執行緒間的通信有多種選擇,但使用起來往往複雜。一旦涉及到共享記憶體,就必須使用各種鎖和互斥機制,常常導致死鎖。此外,還需要設置執行緒stack的大小,開發者必須選擇是使用默認設定還是自定義。

第二是難於規模化(scaling)。

儘管執行緒的資源占用已經小於Process,但大量創建執行緒仍然不可行。每個執行緒的資源占用不小,作業系統在調度執行緒時也會消耗大量資源,大量的上下文切換(Context Switch)是導致系統Perfromnace降級的主要原因之一。

許多網絡服務程式由於無法大量創建執行緒,只能選擇在少量執行緒中實現網絡多路復用,使用 epoll/kqueue/IoCompletionPort 這類機制。雖然有 libevent 和 libev 這樣的第三方Library提供幫助,但編寫這類程式仍然非常困難,存在大量的webhook和callback,給開發者帶來了巨大的心理負擔。

Go 語言以其「原生支持併發」的特性而著稱。相對於基於執行緒的併發設計模型,Go 提供了哪些改進呢?接下來讓我們一探究竟。

Go 的併發解決方案:goroutine

與傳統的作業系統執行緒不同,Go 並沒有選擇它們作為程式的執行單元。而是實現了 goroutine,這是一種由 Go 的運行時(runtime)調度的輕量用戶級執行緒,它為併發程式設計提供了原生的支持。

goroutine 的優勢包括:

  • 資源占用少,每個 goroutine 的初始棧大小僅為 2KB;
  • 由 Go runtime 調度,而不是作業系統,實現了更低的上下文切換成本;
  • 不通過OS Kernel API,而是直接在語言層面支持。使用 go 關鍵字即可創建 goroutine,執行結束後自動回收,提供了更好的開發體驗;
  • 內置的 channel 作為 goroutine 間的通信機制,支撐強大的併發設計。

相對於傳統編程語言,Go 語言從設計之初就將併發作為一個核心考慮因素,讓併發設計的應用能夠更自然地適應環境變化和資源擴展。

接下來,讓我們進一步探索如何在 Go 中有效使用 goroutine。

goroutine 的基本用法

首先,我們通過 go 關鍵字加上函數或方法來創建 goroutine。創建後的 goroutine 會擁有獨立的程式碼執行workflow,並與其創建者一起由 Go 運行時進行調度。以下是一些創建 goroutine 的程式碼示例:

1
2
3
4
5
6
7
8
9
10
go fmt.Println("I am a goroutine")

var c = make(chan int)
go func(a, b int) {
c <- a + b
}(3,4)

// $GOROOT/src/net/http/server.go
c := srv.newConn(rw)
go c.serve(connCtx)

可以看到,我們可以基於已有的具名函數或Method創建 goroutine,也可以基於匿名函數創建。知道了如何創建,那我們怎麼終止 goroutine 呢?

goroutine 的運行成本很低,因此 Go 官方建議盡量多使用 goroutine。在大多數情況下,我們無需擔心控制 goroutine 的終止:一旦執行函數返回,goroutine 就會退出。

如果主 goroutine(main goroutine)結束了,那整個應用程式也會隨之結束。此外,即使 goroutine 執行的函數有返回值,Go 也會忽略這些返回值。因此,如果需要取得 goroutine 執行結果的返回值,則需考慮其他方法,例如通過 goroutine 間的通信實現。

接下來,讓我們探討一下 goroutine 間的通信方法。

goroutine 間的通信

傳統的程式語言(例如 C++、Java、Python 等)不是為了支持併發而設計的,因此它們的併發邏輯多是基於作業系統的執行緒。這些執行緒間的通信,主要利用作業系統提供的API和設計結構,如共享記憶體、Signal、Pipe、消息隊列、socket等。

在這些通信方式中,最常用也是最有效率的是基於共享記憶體的方式,通常還涉及到作業系統提供的執行緒同步機制(例如鎖和原子操作)。然而,傳統的基於共享記憶體的併發模型難以使用,容易出錯,尤其是在大型或複雜的程式中。開發人員在設計這類程式時不僅要建模執行緒間的通信方式,還要設計執行緒間的同步機制,並考慮多執行緒的記憶體管理、防止死鎖以及最不容易被debug的race-condition等問題。

這為開發人員帶來了巨大的壓力,使得基於這類傳統併發模型的程式難以編寫、閱讀、理解和維護。一旦程式出現問題,查找 Bug 的過程將非常漫長且艱難。

而 Go 語言從一開始就致力於解決這些問題,引入了基於 goroutine 的通信keyword——channel。goroutine 可以從 channel 接收輸入數據,處理後再通過 channel 發送結果數據。channel 使得連接和組織大型併發系統變得更簡單清晰,我們不需要再為傳統共享記憶體併發模型中的問題煩惱。

例如,我們可以使用 channel 來獲取 goroutine 的退出狀態:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func spawn(f func() error) <-chan error {
c := make(chan error)

go func() {
c <- f()
}()

return c
}

func main() {
c := spawn(func() error {
time.Sleep(2 * time秒)
return errors.New("timeout")
})
fmt.Println(<-c)
}

這個示例中,在 main goroutine 和子 goroutine 之間建立了一個返回類型為 error 的 channel,子 goroutine 在退出時會將其函數執行的錯誤結果發送到這個 channel 中,main goroutine 通過讀取 channel 的值來獲取子 goroutine 的退出狀態。

Go 也支持傳統的基於共享記憶體的併發模型,提供了基本的低級別同步方法(主要是 sync 包中的互斥鎖、條件變量、讀寫鎖和原子操作等)。

總結

  • Go 語言原生支持併發,其設計簡單且資源佔用低,他是user space層面的多執行序而不需要作業系統介入。
  • 通過 go 關鍵字創建的 goroutine 提供了比傳統執行緒更高的性能。
  • 內置的 channel 為 goroutine 之間提供了高效的通信方式,簡化了併發程式的設計和數據共享問題。