一、聊聊并發這件事
在基礎系列我們學習了Go的并發編程,對并發的概念已經有了一定的了解。在各種現代高級語言中,對并發的支持已經是標配,但Go的并發無論在開發效率還是在性能上都有相當的優越性。Go有什么獨特的設計讓其在并發編程領域獨步江湖?這得益于Go的并發調度器。
我們知道,軟件在系統運行的基本單元是進程,由進程開辟出多條線程,而線程則是CPU調度的基本單位。我們常說千級并發、萬級并發。甚至百萬級并發,是那么多線程真正同一時間執行嗎?其實并不是,程序同一時間的并發數完全由物理上的CPU核心數決定,即同一時間單位一個CPU核心只能執行一條線程的計算,其多線程的并發完全由調度器決定。
二、并發模型概述
各種高級語言的調度器設計各不相同,但基本可以分成三種模型設計,第一種為內核級并發模型(1:1),即內核線程和用戶線程綁定;第二種為用戶級并發模型(N:1),由用戶維護線程的管理,其內核線程和用戶線程的調度由調度器實現;第三種為兩級調度的混合并發模型(N:M),這種集前兩種模型的優勢,完全由語言運行時的調度器控制,是實現最為復雜的一種。下面我們分別了解一下這三種并發模型:
1.內核級并發模型(1:1)
所謂內核線程,即物理線程,可以被操作系統內核調度器調度的對象實體,是操作系統內核的最小調度單元。這里用戶開辟的每一個線程和內核線程綁定(1:1),線程的調度完全由系統內核管理,應用程序對線程幾乎沒有管理,大部分語言的線程庫,如JAVA,都是對操作系統內核線程的封裝。
優點:
- 實現簡單,直接扔給操作系統內核管理;
- 線程切換由CPU實現,可以有效利用CPU的多核;
缺點:
- CPU管理的線程切換涉及線程的上下文、資源調度,因此切換成本很高;
- 不宜開辟過多的并發量,內核線程的堆棧空間在 Windows 下默認 2M,Linux 下默認 8M,過多的并發量導致的切換開銷對性能影響是很大的。
2.用戶級并發模型(N:1)
所謂用戶級并發模型,是指單個進程內部管理的多條線程,線程的調度完全由用戶進程決定,一個進程中的所有線程都只和一個CPU內核線程在運行時動態綁定(N:1)。即CPU內核線程的調度器對用戶進程內部的多條線程是無感知的,內核線程只知道用戶進程。像python、nodejs等語言的并發實現就是這種模型,簡單來說這是一種偽并發,因為同一時間內允許的線程只有一個。
優點:
- 線程調度在用戶層面,CPU不需要在用戶態和內核態切換,資源開銷很??;
- 由于沒有上下文切換帶來的開銷,其用戶線程比較輕量化,可以開啟較多的用戶線程。
缺點:
- 并不能做到真正意義上的并發,其本質還是單核計算,對于IO阻塞的任務還是會被中斷;
- 需要線程庫把IO阻塞的操作重新封裝為完全的非阻塞形式,然后在以前要阻塞的點上,主動讓出自己,并通過某種方式通知或喚醒其他待執行的用戶線程在內核線程上運行
3.混合型并發模型(N:M)
以上兩種調度器模型都有優缺點,有沒有可能取長補短設計新的調度器模型呢?混合型并發模型就是博采眾長之后的產物,充分吸收前兩種線程模型的優點且盡量規避它們的缺點。混合型并發模型是讓內核線程和用戶線程建立多對多的關系(N:M),即一個用戶進程可以和多個內核線程關聯,相當于用戶進程內部開辟的多個用戶線程可以動態綁定多個內核線程。這種模型避免了內核級模型中的完全靠操作系統調度的并發性能問題,也避免了用戶級模型中的偽并發問題,它是用戶自身調度器和系統調度器協同工作的設計。
優點:
- 充分博采眾長,實現真正意義上的高效能并發;
- 對用戶友好,用戶只需管理業務層面的并發任務。
缺點:
- 由于這種模型需用用戶級調度和系統級調度協同工作,所以這種調度器實現都相當復雜。
三、Go并發調度器解析
Go調度器中的三種結構G、P、M
系統線程固定2M,且維護一堆上下文,對需求多變的并發應用并不友好,有可能造成內存浪費或內存不夠用。Go將并發的單位下降到線程以下,由其設計的goroutine初始空間非常小,僅2kb,但支持動態擴容到最大1G,這就是go自己的并發單元——goroutine協程。
實際上系統最小的執行單元仍然是線程,go運行時執行的協程也是掛載到某一系統線程之上的,這種協程與系統線程的調度分配由Go的并發調度器承擔,Go的并發調度器是屬于混合的二級調度并發模型,其內部設計有G、P、M三種抽象結構,我們來看一下它們分別是什么:
G-P-M模型抽象結構:
- G: 表示Goroutine,每個Goroutine對應一個G結構體,G存儲Goroutine的運行堆棧、狀態以及任務函數,可重用。G運行隊列是一個棧結構,分全局隊列和P綁定的局部隊列,每個G不能獨立運行,它需要綁定到P才能被調度執行。
- P: Processor,表示邏輯處理器, 對G來說,P相當于CPU核,G只有綁定到P(在P的local runq中)才能被調度。對M來說,P提供了相關的執行環境(Context),如內存分配狀態(mcache),任務隊列(G)等,P的數量決定了系統內最大可并行的G的數量(前提:物理CPU核數 >= P的數量),P的數量由用戶設置的GOMAXPROCS決定,但是不論GOMAXPROCS設置為多大,P的數量最大為256。
- M: Machine,系統物理線程,代表著真正執行計算的資源,在綁定有效的P后,進入schedule循環;而schedule循環的機制大致是從Global隊列、P的Local隊列以及wait隊列中獲取G,切換到G的執行棧上并執行G的函數,調用goexit做清理工作并回到M,如此反復。M并不保留G狀態,這是G可以跨M調度的基礎,M的數量是不定的,由Go Runtime調整,為了防止創建過多OS線程導致系統調度不過來,目前默認最大限制為10000個。
關于P這個設計,是在Go1.0之后才實現的,起初的Go并發性能并不十分亮眼,協程和系統線程的調度比較粗暴,導致很多性能問題,如全局資源鎖、M的內存過高等造成許多性能損耗,加入P的設計后實現了一個叫做 work-stealing 的調度算法:由P來維護Goroutine隊列并選擇一個適當的M綁定。
G-P-M模型調度
我們來看看go關鍵字創建一個協程后其調度器是怎么工作的:
- go關鍵字創建goroutine(G),優先加入某個P維護的局部隊列(當局部隊列已滿時才加入全局隊列);
- P需要持有或者綁定一個M,而M會啟動一個系統線程,不斷的從P的本地隊列取出G并執行;
- M執行完P維護的局部隊列后,它會嘗試從全局隊列尋找G,如果全局隊列為空,則從其他的P維護的隊列里竊取一般的G到自己的隊列;
- 重復以上知道所有的G執行完畢。
當然也有一些情況會造成Goroutine阻塞,如:
- 系統GC;
- 系統IO資源的調用,如文件讀寫;
- 網絡IO的延遲;
- 管道阻塞;
- 同步操作。
當遇到上述阻塞時,Go調度器也有相應的處理方式:
- 1.系統調度引起阻塞:
如系統GC,M會解綁P,出讓控制權給其他M,讓該P維護的G運行隊列不至于阻塞。
- 2.用戶態的阻塞:
當goroutine因為管道操作或者系統IO、網絡IO而阻塞時,對應的G會被放置到某個等待隊列,該G的狀態由運行時變為等待狀態,而M會跳過該G嘗試獲取并執行下一個G,如果此時沒有可運行的G供M運行,那么M將解綁P,并進入休眠狀態;當阻塞的G被另一端的G2喚醒時,如管道通知,G又被標記為可運行狀態,嘗試加入G2所在P局部隊列的隊頭,然后再是G全局隊列。
- 3.當存在空閑的P時,竊取其他隊列的G:
當P維護的局部隊列全部運行完畢,它會嘗試在全局隊列獲取G,直到全局隊列為空,再向其他局部隊列竊取一般的G。
至此Go的調度器模型解析完畢?;贕o調度器的優越設計,它號稱能實現百萬級并發,即使日常很難達到這種并發量,我們也應該對并發的使用要心存敬畏,真正的并發依賴于物理核心,啟動并發是需要系統開銷的,雖然在Go的運行時它看起來很小,但量變引起質變,當業務啟動的并發到十萬級、百萬級甚至千萬級時,其性能開銷還是非常巨大的。可以通過一定的手段控制并發數量以防止系統奔潰,如實現一個協程池,通過worker機制控制并發數。
Ok,希望學完這一專題你會對Go的并發有更深刻的了解。