線程和線程調度的簡單實現

提要

上一篇文章中講述了線程機制原理,這篇則是根據線程機制的原理簡單實現線程,並在初始化線程後實現簡單的線程調度。

非常簡單的構建線程,PCB的結構很簡單,線程棧也很小。線程調度的實現相對於大型操作系統來說也很簡單,是在現有的條件下實現的簡單線程調度。這裏沒有提到進程結構體,但用到了task_struct的命名方式,是因爲後續實現用戶進程也是通過線程實現的,到時候只是在現有的結構上增加結構體成員變量,進程和線程的區別只是有無資源。做這些的目的是爲了深入理解線程和線程調度的原理。

線程初始化

task_struct結構體中的成員:

該結構體中聲明瞭一些PCB的一些信息,用來記錄線程的信息。

初始化task_struct結構體中的成員:

此時PCB的情況如下圖所示:

初始化線程棧

線程棧結構體:

線程棧是構建線程非常重要的結構體。線程第一次運行時eip指向kernel_thread。在第一次之後,該eip記錄發生任務切換時指令地址,保證任務再次運行的時候能夠繼續運行。ebp,ebx,edi,esi是ABI要求的需要保存的寄存器(ABI介紹可查閱LINUX系統編程)。

僅第一次上CPU使用的內容是線程運行函數kernel_thead的參數,function是要封裝函數的地址,func_arg是function函數運行時的參數地址。組合起來就是funciton(func_arg),該函數就是我們真正想要運行的函數。

彙編中調用另一個程序段,通過call指令,通過ret指令返回。因爲使用C語言實現的內核,函數的參數由主調函數確定。這裏要說一下call指令必須要通過ret指令返回,但是ret指令並不需要綁定call使用。ret指令的作用就是讀取棧中的返回地址加載到eip,這樣程序計數器的方向就發生了改變。在後面kernel_thread(function, func_arg)中kernel_thread是直接通過switch_to函數返回的(直接執行ret指令,不是通過call指令調用),kernel_thread函數主要是要執行funciton(func_arg)函數,結構體中eip這個函數指針讓switch_to函數知道要去執行kernel_thread函數,該函數裏要去調用另外一個函數function,結構體中爲kernel_therad的棧空間構造好它需要的參數。因爲要符合C語言函數調用的標準這裏需要一個unused_ret作佔位用。可以理解爲在平時是程序員爲函數確定函數調用的參數,現在需要內核爲函數確定函數調用的參數。unused_ret是爲了符合C語言函數調用的規則,function和func_arg是函數調用的參數。

初始化線程棧:

函數說明:

初始化線程棧。任務調度是由中斷驅動的,現在PCB的頂端一定是中斷處理程序要保存的上下文,所以先給中斷棧留出位置。然後留出線程棧的空間,因爲棧內存中是高地址到低地址擴展的,結構體定義是從低地址到高地址定義。然後進行初始化,讓self_kstack指向現在的棧頂。eip指向要kernel_thread,這是線程要封裝的函數的入口。function和func_arg分別指向kernel_thread要調用的函數和函數的參數。然後初始化結構體中定義的寄存器的值,線程第一次運行時這些寄存器的值應該爲0。此時的PCB如下圖所示。intr_stack是中斷棧,這裏就不展開了,裏面保存了所有通用寄存器。圖中的頂端是爲線程PCB申請的一頁內存的頂端。

創建線程

函數說明:

PCB需要在內存存儲,這樣才能根據PCB進行任務調度,所以先申請一頁的內存用於保存線程的PCB,這裏的PCB比較小申請一頁就行了。然後調用上面介紹的函數,進行初始化,經過初始化後,線程已經構建完成了。現在將PCB保存在就緒隊列中和全部任務隊列中,根據任務隊列找到要調度的任務。就緒隊列保存可以上CPU運行的線程,全部任務隊列保存內核中所有的線程。只有就緒隊列中的線程纔可以直接上CPU運行。

時鐘中斷處理程序

函數說明:

這裏任務調度是通過時鐘中斷實現的,所以要註冊相應的時鐘中斷處理程序。時鐘中斷處理程序先獲取當前正在運行的現場,確定線程的PCB是完整的沒有被破壞。每次時鐘中斷是一個滴答,當前線程的elapsed_ticks用來記錄總共佔用CPU的時間。線程ticks記錄一次上CPU運行的時間,這個時間不爲0說明當前線程還可以繼續使用CPU,只需要-1即可,當這個時間爲0時,需要被換下處理器了,此時就調用schedule()函數。

調度函數schedule

函數說明:

該調度函數主要是將當前正在運行線程的狀態變爲就緒態,然後加入到就緒隊列中,然後從就緒隊列中出隊一個線程,進行線程調度。當前線程爲cur,馬上要上CPU運行的線程狀態變爲運行態,記錄在next中,然後調用switch_to(cur, next)完成調度。如果當前線程因爲某些原因不是運行態(比如線程阻塞),則不加入就緒隊列參與調度。

線程替換switch_to

switch_to是一個彙編程序,使用匯編是因爲只是爲了實現轉換沒有其他複雜的內容,就不使用內嵌彙編了。switch_to是通過schedule()調用的,參數爲cur,next。cur爲當前要被換下CPU的線程,next爲要換上CPU運行的線程。此時棧中的情況如下圖。只關心switch_to有關的內容,然後給出switch_to函數。

函數說明:

esp+20的位置存儲的是線程cur,esp+24的位置存儲的是線程next。現在將cur取出存入eax臨時保存,eax現在的值爲當前線程PCB的起始地址,PCB的起始地址爲self_stack,將當前esp的值存入當前線程的self_stack保存起來。

然後從棧esp+24中取出線程next存入eax,現在eax的值爲線程PCB的起始地址,PCB的起始地址保存着線程next的棧頂self_stack,將該棧頂存入esp,此時線程棧交換完成。

然後進行返回,如果線程正在運行的線程是第一次運行,將上面初始化的線程棧再次放到這以方便看。

這是交換棧後新線程(next)的棧,經過pop ebp,ebx,edi,esi後,esp指向了eip,然後switch_to執行最後一條指令ret,讀取eip的值,此時eip的值爲函數kernel_thread的地址,之前說過ret指令並不需要綁定call指令使用,ret指令將esp指向的內存保存進eip使程序計數器cs:eip的方向發生改變。現在eip的值爲kernel_thread的地址,就是說現在要去執行kernel_thread。將上面的kernel_thread角度的棧放到下面方便看:

開始執行kernel_thread函數。這樣next線程中我們要執行的function(func_arg)就得以執行了,實現多個線程運行。

kernel_thread函數:

如果當前線程不是第一次執行,那麼上面ret之前棧中的eip就是調用switch_to的返回地址,這裏是schedule()調用的switch_to,所以eip指向的返回地址在schedule中。

每當發生時鐘中斷時,就會進行上述的情況,往復進行實現了多線程調度。

參考書籍:

《操作系統真相還原》-- 鄭鋼

《LINUX系統編程》-- ROBERT LOVE

文章轉自:小組18級成員--胡慶偉

原文地址:https://blog.csdn.net/qq_43769572/article/details/105576485

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章