Go Routine原理分析與探討

協程

什麼是協程

協程(coroutine)通常也叫用戶態線程,關於coroutine術語的解釋最初發表在論文http://melconway.com/Home/pdf/compiler.pdf。在wiki上,可以找到各種語言實現的協程庫程序。這篇文章主要講明白爲什麼會有協程,協程工作原理以及關於協程問題的探討。

首先大家需要明白用戶態和內核態以及其工作原理,這是理解這篇文章的基礎,在這裏不再介紹,大家可以很容易找到許多資料。

再徹底搞明白協程之前,我們從線程切換開始,當然這裏說的線程是操作系統的線程,線程是操作系統調度的單元,有自己的用戶態棧和內核態棧、運行上下文,那麼線程什麼情況下會發生線程切換呢?

  1. 等待資源而掛起自己,切換到其他線程,這是非搶佔式的切換,就是主動讓出cpu,或者稱爲自發性上下文切換。
  2. 如果cpu時間片用完或者存在優先級更高的線程,時鐘中斷程序將正在運行的線程切換到其他線程,強行剝奪其cpu使用權,調度一個新線程讓其獲得cpu,從而新線程可以被運行,這是搶佔式的切換,強行剝奪cpu,或者稱爲非自發性上下文切換。

線程切換的具體流程如下:

  1. 從用戶態切換到內核態
  2. 保存自己的運行上下文
  3. 運行調度算法選擇下一個線程(這時候還是運行在當前線程的上下文中,但是處於內核態,可以思考一下爲什麼沒有問題)
  4. 恢復下一個線程的運行上下文(這時可能導致整個地址空間的切換)

從線程切換流程可以看看線程切換的代價:

  1. 用戶態內核態的切換
  2. 調度算法(當前linux系統的調度算法時間複雜度爲O(1),效率很高,這個花銷很低)
  3. 上下文切換花銷(這個花銷很低)
  4. 緩存失效

如果當前線程正處於內核態,進行線程切換就沒有用戶態內核態的切換。

線程切換很大的花銷在用戶態內核態的切換上,既然切換開銷這麼大,大膽想一想,爲什麼線程在用戶態不直接做線程切換呢?當然這個問題有點傻,系統上運行的所有線程數據都由操作系統維護,那意味着只有在內核態纔可以訪問這些數據,如果處於用戶態,當前線程處於自己的上下文,根本沒法訪問線程數據,更不要說運行調度算法選擇下一個線程了。如果能直接訪問線程數據,那麼操作系統又有何用呢?

由於線程數據由操作系統維護,線程切換時需要到內核態訪問線程數據來執行切換。再大膽想一想,如果線程數據不由操作系統維護,而由應用程序自己維護呢?這樣線程切換就不需要到內核態了。

這就是用戶態線程或者協程。

協程和線程

和線程一樣,協程也有自己的棧和運行上下文。很明顯,協程並不是操作系統調度的單元,操作系統調度的仍然是線程,協程是在線程的上下文中運行,協程可以訪問線程的地址空間,也就是該進程的地址空間。

協程切換在線程中運行,線程也有被操作系統切換。在思考協程的一些設計原理時還需要考慮線程的切換。

協程的切換流程如下:

  1. 保存當前協程的運行上下文
  2. 運行調度算法選擇下一個協程
  3. 恢復下一個線程的運行上下文

協程切換並沒有切換地址空間,切換前後兩個協程是在同一個地址空間中運行,也就沒有緩存失效的問題。那是不是協程就比線程效率高呢?如果單純看協程和線程切換,毫無疑問,協程花銷要小很多。但實際情況是你的應用程序的線程是確定的(是否在應用層使用協程又影響到你應用程序的線程策略,我們暫且認爲線程確定),只是在應用程序層是否要使用協程,好了,直覺告訴我們,不使用協程效率更高,因爲使用協程多了協程開銷,無論多少,效率肯定比無協程低。這種直覺好像又不對,如果在應用程序中使用了協程,那麼很多資源等待導致的掛起不會引起線程級別的切換,只是協程級別的切換,使用協程的應用程序可以減少線程的切換次數,效率比無協程應用程序要高。但是,無協程應用程序存在很多可以避免的線程切換是應用開發問題,協程應用程序可以避免的線程線程切換,無協程應用程序照樣可以避免。這樣看,貌似無協程應用程序效率更高,其實,深究到這一步已經沒有意義了。

回頭來看,比較協程應用程序和無協程應用程序的效率是徒勞的。協程並不是爲了應用程序的效率而出現,而是爲併發編程而出現,在沒有協程的應用程序中,併發網絡請求時,需要設計異步機制來實現併發,而有了協程,可以使用多個協程來實現併發,網絡請求通過簡單的同步機制就可以完成。這纔是協程的魅力。

協程調度

協程的切換是線程來完成的,協程在什麼情況下會切換呢?

  1. 協程等待資源而掛起自己,調度下一個協程運行,這是非搶佔式調度,和線程類似,如等待timer、等待io、等待競爭資源......
  2. 協程有沒有搶佔式調度呢?如果有搶佔式調度,誰來中斷當前正在運行的協程從而進行調度呢?我們知道在線程的搶佔式調度中,這個是cpu時鐘中斷來完成的。

對於非搶佔式調度,很容易理解,不再解釋,下面重點探討協程的搶佔式調度。

運行當前協程的線程在協程主動讓出線程時可以執行協程切換,這是非搶佔式調度,那麼如果沒有協程主動讓出線程呢,該如何照樣切換協程呢?

第一個方案,在所有的函數調用處,執行一次協程時間片檢查,如果時間片用完,則執行協程切換,這本質上也是非搶佔式調度,需要編譯器支持在所有的函數調用處插入協程時間片檢查程序。每個函數調用處都執行協程時間片檢查的代價非常高。

第二個方案,在協程的棧上增加一個標記位stackguard,同時存在一個獨立的線程sysmon,sysmon會檢查協程的時間片,如果時間片用完,則將棧的stackguard標記爲StackPreempt,當協程發生函數調用時,會檢查棧的stackguard標記,如果爲StackPreempt則執行協程調度。這是僞搶佔式調度,本質上調度還是在當前協程中完成,當前協程主動放棄線程執行切換。

第三個方案,在第二個方案中,如果協程沒有函數調用,那麼這個協程會一直佔用當前的線程,即使時間片用完,也無法協程切換。我們想一想線程如何實現搶佔式的?對的,時鐘中斷導致cpu直接去運行中斷程序,我們可以切換線程。那麼有沒有機制可以導致當前線程直接去跳轉運行另一個程序,這樣我們就可以在這個程序中切換協程。有的,那就是用於處理異步事件的信號。

信號可以中斷當前程序進而跳轉執行信號處理程序,在信號處理程序中,如果當前協程時間片用完,則執行協程切換,厲害的搶佔式調度就完成了。

信號方案有明顯的缺陷:

  1. 信號方案的協程切換只有在線程切換髮生時纔會觸發,一次信號觸發的協程切換都有一次線程的切換
  2. 信號方案的代價明顯要大一些

第三個方案雖然功能強大,但效率不高,他退化爲了線程切換來完成協程切換,實際情況比線程切換代價還要大一些。在系統中單一使用第三個方案是不可取的,往往是和第二個方案一起使用,系統中一部分是非搶佔式調度,另一部分調度發生的是第二種方案的僞搶佔式調度,第三種方案的信號搶佔式調度很少,大部分系統根本不會被觸發。這也是爲什麼到go 1.14纔有第三種方案的協程調度。

這裏有許多細節沒有描述,大家可以再想一想:

  1. 信號是發送給進程的,如何讓一個指定的線程收到信號呢?
  2. 信號處理程序是在線程從內核態切換會用戶態時調用的,可以再細想一下協程搶佔式調度的工作流。

協程問題探討

我們通過四個go案例來一窺go的協程機制。

案例一

啓動兩個roroutine,可以正確調度 這是一個正常的case,這兩個routine可能會被GMP調度在不同的線程上運行。

func TestSchedule1(t *testing.T) {
	go func() {
		for true {
			fmt.Printf("BBB\n")
			time.Sleep(1 * time.Second)
		}
	}()
	for true {
		fmt.Printf("AAA\n")
		time.Sleep(1 * time.Second)
	}
}

案例二

限制啓動一個線程,那麼這兩個routine只能被GMP調度在同一個線程上運行。 sleep和printf是函數調用,可以做僞搶佔式調度,上面的二個方案可以實現,這兩個routine都可以被調度執行

func TestSchedule2(t *testing.T) {
	runtime.GOMAXPROCS(1)
	go func() {
		for true {
			fmt.Printf("BBB\n")
			time.Sleep(1 * time.Second)
		}
	}()
	for true {
		fmt.Printf("AAA\n")
		time.Sleep(1 * time.Second)
	}
}

案例三

限制啓動一個線程,那麼這兩個routine只能被GMP調度在同一個線程上運行。 沒有函數調用情況下,用戶態的協程只能在搶佔式調度下被切換,上面的第三個方式可以實現,這兩個routine照樣可以被調度執行。 只有go 1.14有信號實現的搶佔式調度,所以以下代碼在go 1.14在,兩個routine可以被調度,會panic,而go 1.13和之前版本沒有信號實現的搶佔式調度,不會panic。

func TestSchedule3(t *testing.T) {
	runtime.GOMAXPROCS(1)
	go func() {
		panic("Can not here?")
	}()
	for true {
		continue
	}
}

案例四

啓動二個線程,那麼這兩個routine可能會被GMP調度在二個線程上運行。 沒有函數調用情況下,沒有搶佔式調度的情況下,這兩個routine可以執行,因爲他們可以被分配在兩個線程上運行,線程調度是操作系統完成的,有搶佔式調度機制的。 以下代碼在go 1.13下也會panic

func TestSchedule4(t *testing.T) {
	runtime.GOMAXPROCS(1)
	go func() {
		panic("Can not here?")
	}()
	for true {
		continue
	}
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章