這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。相信作為服務端開發尤其是高效能服務開發的猿們,曾經面試都曾經被問到進程,線程之類的問題,作為作業系統最核心的概念,這些X程就像我們的一個個工具,是我們在開發過程中經常接觸的概念,對於這些概念的不清晰我們便發現寫的代碼功能是對的,代碼是渣的,將直接體現在我們代碼的低效率,高bug率並附帶問題出現都不知到問題出在哪裡,作為新時代的猿我們原不需要那麼多時間去解bug,我們需要更多時間陪女票,不是嗎?
不過協程一般不會被問到,但在golang開發的過程中相信大家最經常接觸的就是go協程,但對於什麼才是協程,什麼才是go協程,很多有經驗的開發很可能會說go出去的就是協程....僅僅停留在這個層面認識,不僅會給我們項目帶來持續的問題和宕機,對我們自身也是一種時間和精力損耗,作為開發猿,我們的願望無非是代碼穩一點,跑的快一點,bug少一點這點願望。在此我從作業系統的角度來對進程,線程,協程進行介紹,並試著說明協程和goruntine到底是不是一回事。
協程的概念其實比線程還要早,不過是這幾年才被大家熟知,線程在實現上可以說是一個特化的1:N協程。協程的核心機制是什嗎?學過彙編的童鞋應該記得實模式編程下,理論上作業系統只能載入一個進程,那個時候進程要使用系統服務的方法非常簡單,就是手工產生一個中斷,然後我們就知道了會觸發CPU的中斷處理機制,會保護好發起中斷的現場,然後會將當前執行地址設定為對應的中斷處理函數的地址,處理完以後回到剛剛儲存的現場。其實這個過程,本質上就是協程的核心流程了。是不是覺得很熟悉?這不就是調用函數的call/return嘛,但這是一種和call/return不同的邏輯路徑跳轉方式,區別是基於call/return方式系統進入處理函數,被調用函數會繼續使用調用函數的context就是棧,返回的時候就會釋放棧資源;而基於中斷的方式,發起方和處理方可以使用自己的context,系統通過中斷的方法來達到提供系統服務的目的,一個很重要的原因就是可以保障在很多情況下,都能讓系統處理函數至少能有一個可用的context(屬於系統的資源),這樣當使用者進程的context資源耗盡的情況下,也能調用一些系統服務。假設調用 go func(1,2,3) ,func函數會在一個新的go線程中運行,顯然新的goroutine不能和當前go線程用同一個棧,否則會相互覆蓋。所以對go關鍵字的調用協議與普通函數調用是不同的。不像常規的C語言調用是push參數後直接call func,上面代碼彙編之後會是:
- 參數
- push func
- push 12
- call runtime.newproc
- pop
- pop
12是參數佔用的大小。在runtime.newproc中,會建立一個棧空間,將棧參數的12個位元組拷貝到新棧空間並讓棧指標指向參數。
這時的線程狀態有點像當被調度器剝奪CPU後一樣,pc,sp會被存到類型於類似於進程式控制制塊的一個結構體struct G內。func被存放在了struct G的entry域,後面進行調度時調度器會讓goroutine從func開始執行。defer關鍵字調用過程類似於go,不同的是call的是runtime.deferproc,函數返回時,如果其中包含了defer語句,不是調用add xx SP, return,而是call runtime.deferreturn,add 48 sp,return
可以說,協程與線程主要區別是它將不再被核心調度,而是交給了程式自己而線程是將自己交給核心調度,所以也不難理解golang中調度器的存在。所以我們可以看出,協程的概念並不是與線程對應的,應該說和函數調用 call/return對應(也不難理解為什麼會把golang中的goruntine當作一個以函數為單位的執行單元)。它們的區別在於協程允許一個函數有多個入口、出口(邏輯上的),並且在切換到另一個函數執行時,允許使用一個新的context(包括調用棧)。正是有了這個機制基礎,再加上CPU支援了保護模式,作業系統就可以接著實現進程、線程了。
那麼協程明白了原理,進程和線程就更好理解了。我覺得進程與線程其實最核心的是隔離與並行。進程可看作為分配資源的基本單位,比如你new出了一塊記憶體,就是作業系統將一塊實體記憶體映射到你的進程地址空間上(進程建立必須分配一個完整的獨立地址空間),這塊記憶體就屬於這個進程,進程內的所有線程都可以訪問這塊記憶體,其他進程就訪問不了,其他類型的資源也是同理。所以進程是分配資源的基本單位,也是我們說的隔離。線程作為獨立運行和獨立調度的基本單位,進而我們可以認為線程是進程的一個執行流,獨立執行它自己的程式碼。線程上下文一般只包含CPU上下文及其他的線程管理資訊,線程建立的開銷主要取決於為線程堆棧的建立而分配記憶體的開銷,這些開銷並不大。線程還分為系統層級和使用者級線程,使用者層級線程對引起阻塞的系統調用的調用會立即阻塞該線程所屬的整個進程,而核心實現線程則會導致線程環境切換的開銷跟進程一樣大,所以經常的折衷的方法是輕量級進程(Lightweight)。在 Linux 中,一個線程組基本上就是實現了多線程應用的一組輕量級進程。線程的作用就在於充分使用硬體CPU,也就是我們說的並行。
從我們應用角度來說,我們一般將協程理解為使用者態輕量級線程,是對核心透明的,也就是系統並不知道有協程的存在,是完全由使用者的程式自己調度的,因為是由使用者程式自己控制,那麼就很難像搶佔式調度那樣做到強制的CPU控制權切換到其他進程/線程,通常只能進行協作式調度,需要協程自己主動把控制權轉讓出去之後,其他協程才能被執行到。但我們以上說的協程和golang中的協程是不一樣的。就像開頭說的很多人將go的協程理解為我們常說的協程,但深究它們的名稱不難看出,一個是goruntine,另一個是Coroutine,是不一樣的。golang語言作者Rob Pike也說,“Goroutine是一個與其他goroutines 並發運行在同一地址空間的Go函數或方法。一個啟動並執行程式由一個或更多個goroutine組成。它與線程、協程、進程等不同。它是一個goroutine“。 Go 協程意味著並行,協程一般來說不是這樣的;Go 協程通過通道來通訊而協程通過讓出和恢複操作來通訊;而且Go 協程比協程更強大。因為Golang 在 runtime、系統調用等多方面對 goroutine 調度進行了封裝和處理,也就是Golang 有自己的調度器,工作方式基本上是協作式,而不是搶佔式,但也不是完全的協作式調度,例如在系統調用的函數入口處會有搶佔。當遇到長時間執行或者進行系統調用時,會主動把當前 goroutine 的CPU (P) 轉讓出去,讓其他 goroutine 能被調度並執行,也就是我們為什麼說 Golang 從語言層面支援了協程。簡單的說就是golang自己實現了協程並叫做goruntine。
在golang中進程和線程概念基本和我們常說的一致,大多調用系統的API實現,例如os 包及其子包 os/exec 提供了建立進程的方法,在 Unix 中,建立一個進程,通過系統調用 fork 實現(及其一些變種,如 vfork、clone),在windows中通過系統調用CreateProcess等。相信熟悉golang的都用過GOMAXPROCS,很多人都簡單地理解為這個是限制進程數量,這樣理解顯然不僅是望文生義還有就是對進程和線程理解不夠,官方解釋就很準確: GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously。很清楚,就是限制cpu數,限制cpu數,本質上是什麼,就是限制並行數,並行數即同時執行數量,執行單元即線程,即限制最大並行線程數量。
goruntine的優勢在於並行和非常低的資源使用,體現在記憶體消耗方面和切換(調度)開銷方面,每個 goroutine (協程) 預設佔用記憶體遠比 Java 、C 的線程少,只有2KB,而線程則需要8MB;線程切換涉及模式切換(從使用者態切換到核心態)、16個寄存器、PC、SP...等寄存器的重新整理等;而goroutine 只有三個寄存器的值修改 - PC / SP / DX。
說的不對還請狂噴!