當 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 | go fmt.Println("I am a goroutine") |
可以看到,我們可以基於已有的具名函數或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 | func spawn(f func() error) <-chan error { |
這個示例中,在 main goroutine 和子 goroutine 之間建立了一個返回類型為 error 的 channel,子 goroutine 在退出時會將其函數執行的錯誤結果發送到這個 channel 中,main goroutine 通過讀取 channel 的值來獲取子 goroutine 的退出狀態。
Go 也支持傳統的基於共享記憶體的併發模型,提供了基本的低級別同步方法(主要是 sync 包中的互斥鎖、條件變量、讀寫鎖和原子操作等)。
總結
- Go 語言原生支持併發,其設計簡單且資源佔用低,他是user space層面的多執行序而不需要作業系統介入。
- 通過 go 關鍵字創建的 goroutine 提供了比傳統執行緒更高的性能。
- 內置的 channel 為 goroutine 之間提供了高效的通信方式,簡化了併發程式的設計和數據共享問題。