本文是《go調度器源代碼情景分析》系列 第一章 預備知識的第8小節。
要深入理解goroutine的調度器,就需要對操作系統線程有個大致的了解,因為go的調度系統是建立在操作系統線程之上的,所以接下來我們對其做一個簡單的介紹。
很難對線程下一個准確且易於理解的定義,特別是對於從未接觸過多線程編程的讀者來說,要搞懂什么是線程可能並不是很容易,所以下面我們拋開定義直接從一個C語言的程序開始來直觀的看一下什么是線程。之所以使用C語言,是因為C語言中我們一般使用pthread線程庫,而使用該線程庫創建的用戶態線程其實就是Linux操作系統內核所支持的線程,它與go語言中的工作線程是一樣的,這些線程都由Linux內核負責管理和調度,然后go語言在操作系統線程之上又做了goroutine,實現了一個二級線程模型。
#include <stdio.h> #include <unistd.h> #include <pthread.h> #define N (1000 * 1000 * 1000) volatile int g=0; void* start(void*arg) { int i; for(i=0; i<N; i++) { g++; } return NULL; } int main(int argc, char* argv[]) { pthread_t tid; // 使用pthread_create函數創建一個新線程執行start函數 pthread_create(&tid, NULL, start, NULL); for(;;) { usleep(1000*100*5); printf("loop g: %d\n", g); if(g==N) { break; } } pthread_join(tid, NULL); // 等待子線程結束運行 return 0; }
該程序運行起來之后將會有2個線程,一個是操作系統把程序加載起來運行時創建的主線程,另一個是主線程調用pthread_create創建的start子線程,主線程在創建完子線程之后每隔500毫秒打印一下全局變量 g 的值直到 g 等於10億,而start線程啟動后就開始執行一個10億次的對 g 自增加 1 的循環,這兩個線程同時並發運行在系統中,操作系統負責對它們進行調度,我們無法精確預知某個線程在什么時候會運行。
關於操作系統對線程的調度,有兩個問題需要搞清楚:
-
什么時候會發生調度?
-
調度的時候會做哪些事情?
首先來看第一個問題,操作系統什么時候會發起調度呢?總體來說操作系統必須要得到CPU的控制權后才能發起調度,那么當用戶程序在CPU上運行時如何才能讓CPU去執行操作系統代碼從而讓內核獲得控制權呢?一般說來在兩種情況下會從執行用戶程序代碼轉去執行操作系統代碼:
-
用戶程序使用系統調用進入操作系統內核;
-
硬件中斷。硬件中斷處理程序由操作系統提供,所以當硬件發生中斷時,就會執行操作系統代碼。硬件中斷有個特別重要的時鍾中斷,這是操作系統能夠發起搶占調度的基礎。
操作系統會在執行操作系統代碼路徑上的某些點檢查是否需要調度,所以操作系統對線程的調度也會相應的發生在上述兩種情況之下。
下面來看一下在筆者的單核電腦上運行該程序的輸出:
bobo@ubuntu:~/study/c$ gccthread.c -othread -lpthread bobo@ubuntu:~/study/c$ ./thread loop g: 98938361 loop g: 198264794 loop g: 297862478 loop g: 396750048 loop g: 489684941 loop g: 584723988 loop g: 679293257 loop g: 777715939 loop g: 876083765 loop g: 974378774 loop g: 1000000000
從輸出可以看出,主線程和start線程在輪流着運行,這是操作系統對它們進行了調度的結果,操作系統一會兒把start線程調度起來運行,一會兒又把主線程調度起來運行。
從程序的輸出結果可以看到搶占調度的身影,因為主線程在start線程運行過程中得到了運行,而start線程執行的start函數根本沒有系統調用,並且這個程序又運行在單核系統中,沒有其它CPU來運行主線程,所以如果沒有中斷時發生的搶占調度,操作系統就無法獲取到CPU的控制權,也就不可能發生線程調度。
接下來我們再來看看操作系統在調度線程時會做哪些事情。
如上所述,操作系統會把不同的線程調度到同一個CPU上運行,而每個線程運行時又都會使用CPU的寄存器,但每個CPU卻只有一組寄存器,所以操作系統在把線程B調度到CPU上運行時需要首先把剛剛正在運行的線程A所使用到的寄存器的值全部保存在內存之中,然后再把保存在內存中的線程B的寄存器的值全部又放回CPU的寄存器,這樣線程B就能恢復到之前運行的狀態接着運行。
線程調度時操作系統需要保存和恢復的寄存器除了通用寄存器之外,還包括指令指針寄存器rip以及與棧相關的棧頂寄存器rsp和棧基址寄存器rbp,rip寄存器決定了線程下一條需要執行的指令,2個棧寄存器確定了線程執行時需要使用的棧內存。所以恢復CPU寄存器的值就相當於改變了CPU下一條需要執行的指令,同時也切換了函數調用棧,因此從調度器的角度來說,線程至少包含以下3個重要內容:
-
一組通用寄存器的值
-
將要執行的下一條指令的地址
-
棧
所以操作系統對線程的調度所做的事情可以簡單的理解為內核調度器對不同線程所使用的寄存器和棧的切換。
最后,我們對操作系統線程下一個簡單且不准確的定義:操作系統線程是由內核負責調度且擁有自己私有的一組寄存器值和棧的執行流。