1. 程式人生 > >作業系統執行緒及執行緒排程

作業系統執行緒及執行緒排程

本文是《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去執行作業系統程式碼從而讓核心獲得控制權呢?一般說來在兩種情況下會從執行使用者程式程式碼轉去執行作業系統程式碼:

  1. 使用者程式使用系統呼叫進入作業系統核心;

  2. 硬體中斷。硬體中斷處理程式由作業系統提供,所以當硬體發生中斷時,就會執行作業系統程式碼。硬體中斷有個特別重要的時鐘中斷,這是作業系統能夠發起搶佔排程的基礎。

作業系統會在執行作業系統程式碼路徑上的某些點檢查是否需要排程,所以作業系統對執行緒的排程也會相應的發生在上述兩種情況之下。

下面來看一下在筆者的單核電腦上執行該程式的輸出:

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個重要內容:

  • 一組通用暫存器的值

  • 將要執行的下一條指令的地址

所以作業系統對執行緒的排程所做的事情可以簡單的理解為核心排程器對不同執行緒所使用的暫存器和棧的切換。

最後,我們對作業系統執行緒下一個簡單且不準確的定義:作業系統執行緒是由核心負責排程且擁有自己私有的一組暫存器值和棧的執行