十. 內核線程

實現線程函數

觀察一下這段c語言代碼

void threadFunc(void *arg){ printf("thread function
");}int main(){ printf("main function
"); _beginthread(threadFunc, 0, NULL); return 0;}

這裡只是舉個簡單的例子,沒有考慮錯誤處理和資源回收。

在程序中,在main函數中輸出了一句話,然後開啟一個線程,然後讓這個線程也輸出一句話。通過這個簡單的例子差不多就可以建立線程最初的印象了,線程就是運行一段函數的載體。

我在剛開始接觸線程的時候對它的理解是這樣的:

在一個程序中,至少是有一條線程的,那就是主線程。這個主線程和普通線程也是類似的。它也是運行一段函數的載體,但是它是入口函數運行的載體。這個入口函數放到c語言裡面來講就是平時所用的main函數。

線程的生命周期就是它執行的函數的生命周期,當函數執行完成之後,這條線程的使命也就完成了。當然,線程的使命結束了不代表它就消亡了,這條線程也可能被掛起,留待將來有需要時再將其喚醒。

線程才是真正的執行體,進程只是為程序的執行提供資源。

線程的執行時間和順序根據他的調度策略來。

主線程的存亡代表整個進程的存亡。主線程執行完成之後,代表整個進程要乾的活都已經全部幹完了,那麼此時進程已經沒有存在的必要了,它就會被操作系統回收。

線程函數和普通函數的區別

對線程有了一個基本的理解之後,那麼來了解一下,線程執行的函數和平常的函數調用有什麼區別?

int funca(int num){ funcb(num);}int funcb(int num){ printf("%d", num);}

普通的函數之間發生函數調用的時候,主要的故事都發生在被調用函數的棧中。

首先,備份主調函數的棧,在被調函數的棧頂存放主調函數的返回地址。

然後,根據函數調用約定,在被調函數的棧中存放好要傳遞的參數。

最後,執行被調函數。

一般的函數調用是隨著此函數所在的調度單元一塊在處理器上運行的,這個調度單元可能是整個進程,也可能是某個線程。因為線程用也可以發生函數調用嘛。在這個調度單元中,就會保存它所依賴的上下文環境。如寄存器,棧等。

線程是一套機制,它能夠為一段代碼也就是線程函數創造它所依賴的上下文環境,從而讓其具有獨立性,可以單獨的在處理器上執行。線程函數與普通函數的區別應該就是在這個上下文環境中了。普通函數的上下文環境依賴於執行它的執行流,線程函數想要執行就得創建它的上下文環境。

下面就通過代碼簡單的模擬一下線程的創建執行過程

// 線程的狀態typedef enum tag_task_status{ TASK_RUNNING, TASK_READY, TASK_BLOCKED, TASK_WAITING, TASK_HANGING, TASK_DIED}task_status;// 線程棧typedef struct tag_thread_stack{ uint32_t ebp; uint32_t ebx; uint32_t edi; uint32_t esi; void (*eip)(thread_func *func, void *func_arg); void *unused_retaddr; thread_func *function; void *func_arg;}thread_stack;typedef struct tag_task_struct{ uint32_t *self_kstack; // 內核線程的棧頂地址 task_status status; // 當前線程的狀態 char name[16]; uint8_t priority; uint32_t stack_magic; // 棧的邊界標記,用來檢測棧溢出}task_struct;

目前只是簡單的模擬一下線程的創建執行過程,所以過程比較簡單,這裡是用到了ret指令的一個小技巧。

平常發生的函數調用都是通過彙編指令call實現的,這個是屬於我們主動去調用這個函數。而線程函數並不是我們主動調用的,它是由操作系統的調度器來調用的,時間片分配到了哪個線程上,哪個線程就佔有CPU執行代碼。這裡會通過ret指令來實現偽被動調用,是一種欺騙CPU的辦法。

CPU執行哪條指令是通過EIP的指向來決定的,而ret指令在返回的時候,當前的棧頂就會被當做是返回地址,這個返回地址會被賦值給EIP。也就是說,我們可以把某個函數的地址放在棧頂,通過這個函數來執行線程函數。那麼在ret返回的時候,就會進入我們指定的函數當中,這個函數就會來調用線程函數。

// 初始化線程基本信息void init_thread(task_struct* pthread, char* name, int prio) { memset(pthread, 0, sizeof(*pthread)); strcpy(pthread->name, name); pthread->status = TASK_RUNNING; pthread->priority = prio; pthread->ticks = prio; pthread->elapsed_ticks = 0; pthread->pgdir = NULL; // self_kstack是線程自己在內核態下使用的棧頂地址 pthread->self_kstack = (uint32_t*)((uint32_t)pthread + PG_SIZE); pthread->stack_magic = 0x19971234; // 自定義的魔數,檢查棧溢出}void thread_create(task_struct* pthread, thread_func function, void* func_arg){ // 先預留中斷使用棧的空間 pthread->self_kstack -= sizeof(intr_stack); // 留出線程棧空間 pthread->self_kstack -= sizeof(thread_stack); thread_stack* kthread_stack = (thread_stack*)pthread->self_kstack; kthread_stack->eip = kernel_thread; kthread_stack->function = function; kthread_stack->func_arg = func_arg; kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0; }

這兩個函數主要是對線程的信心進程初始化。由於棧是向下增長的,所以在init中,首先讓其指向了分配的線程空間(也就是一頁大小的空間,下面可以看到)的最高地址處,然後在create_thread函數中,首先留出了一塊空間,這個是將來線程調度時會使用到的中斷棧,再留出線程棧的空間,方便從下往上填充數據。此時棧的數據如下。

static void kernel_thread(thread_func *function, void *func_arg){ function(func_arg);}// 創建一優先順序為prio的線程,線程名為name,線程所執行的函數是function(func_arg) task_struct* thread_start(char* name, int prio, thread_func function, void* func_arg) { // pcb都位於內核空間,包括用戶進程的pcb也是在內核空間 task_struct* thread = get_kernel_pages(1); init_thread(thread, name, prio); thread_create(thread, function, func_arg); asm volatile ("movl %0, %%esp; pop %%ebp; pop %%ebx; pop %%edi; pop %%esi; ret" : : "g" (thread->self_kstack) : "memory"); return thread; }

threadstart就是開啟線程的介面函數,在調用的時候主要是通過裡面的內聯彙編進入到線程函數中,該內聯彙編的主要作用是準備好數據之後執行ret,此時會從棧頂會得到返回地址,該地址也就是上面賦值的eip,也就是kernelthread的地址,然後執行該函數,kernel_thread從棧中得到參數,也就是棧頂+4的真正要執行的線程函數地址,和棧頂+8的線程函數所需的參數。

線程調度

上面簡單的模擬了一下線程的創建,但是沒有實現線程的調度工作,所以創建線程之後,CPU只會在該線程上執行,所以表現出來還是單線程。

這裡會採用輪詢演算法實現多線程的調度工作。每產生一個時鐘中斷就算是一個時鐘周期,當前線程的時鐘周期執行完了之後,就會將該線程放到隊尾,換下一個線程執行。

每一個線程所能執行的時鐘周期由它的優先順序決定。

用了一個雙向鏈表來存儲線程的節點信息,該雙向鏈表結構如下

struct list_elem{ struct list_elem *prev; struct list_elem *next;};struct list{ struct list_elem head; struct list_elem tail;};

初始化線程信息

typedef struct tag_task_struct{ uint32_t *self_kstack; // 內核線程的棧頂地址 task_status status; // 當前線程的狀態 char name[16]; uint8_t priority; uint8_t ticks; // 線程執行的時間 uint32_t elapsed_ticks; // 線程已經執行的時間 struct list_elem general_tag; // 就緒線程節點 struct list_elem all_list_tag; // 所有線程的節點 uint32_t stack_magic; // 棧的邊界標記,用來檢測棧溢出}task_struct;// 初始化線程基本信息void init_thread(task_struct* pthread, char* name, int prio) { memset(pthread, 0, sizeof(task_struct)); strcpy(pthread->name, name); if (pthread == main_thread) { pthread->status = TASK_RUNNING; } else { pthread->status = TASK_READY; } pthread->priority = prio; pthread->ticks = prio; pthread->elapsed_ticks = 0; pthread->pgdir = NULL; // self_kstack是線程自己在內核態下使用的棧頂地址 pthread->self_kstack = (uint32_t*)((uint32_t)pthread + PG_SIZE); pthread->stack_magic = 0x19971234; // 自定義的魔數,檢查棧溢出}

創建線程時將線程添加進鏈表中

// 創建一優先順序為prio的線程,線程名為name,線程所執行的函數是function(func_arg) task_struct* thread_start(char* name, int prio, thread_func function, void* func_arg) { // pcb都位於內核空間,包括用戶進程的pcb也是在內核空間 task_struct* thread = get_kernel_pages(1); init_thread(thread, name, prio); thread_create(thread, function, func_arg); /* 確保之前不在隊列中 */ ASSERT(!elem_find(&thread_ready_list, &thread->general_tag)); /* 加入就緒線程隊列 */ list_append(&thread_ready_list, &thread->general_tag); /* 確保之前不在隊列中 */ ASSERT(!elem_find(&thread_all_list, &thread->all_list_tag)); /* 加入全部線程隊列 */ list_append(&thread_all_list, &thread->all_list_tag); return thread; }

初始化內核主線程。

在當初寫內核載入器的時候,內核的棧就已將預留出來了。當時內核的入口地址是0xc009f000。內核的棧地址在0xc009e000處,這塊空間時不需要額外進行分配的。由於目前已知運行在內核上,可以通過running_thread()直接獲取到該頁的起始地址。

task_struct *running_thread(){ uint32_t esp; asm ("mov %%esp, %0" : "=g" (esp)); return (task_struct *)(esp & 0xfffff000);}static void make_main_thread(){ main_thread = running_thread(); init_thread(main_thread, "main", 31); ASSERT(!elem_find(&thread_all_list, &main_thread->all_list_tag)); list_append(&thread_all_list, &main_thread->all_list_tag);}

線程調度器

該調度器會在線程的時間片用完之後被調用。如果該線程是在運行態因為時間片用完的情況下,直接將其加入到就緒隊列的隊尾。因為其他原因被掛起的情況暫不處理。

然後會從就緒隊列中取出下一個節點。該節點是就緒線程的節點地址。也就是task_struct中的general_tag地址,需要通過該地址找到task_struct的首地址。這裡通過宏來實現,實現過程也很簡單。用該節點的地址 - general_tag在task_struct中的偏移即可。

#define offset(struct_type, member) (int)(&((struct_type*)0)->member)#define elem2entry(struct_type, struct_member_name, elem_ptr) (struct_type*)((int)elem_ptr - offset(struct_type, struct_member_name))void schedule(){ ASSERT(intr_get_status() == INTR_OFF); task_struct* cur = running_thread(); if (cur->status == TASK_RUNNING) { // 若此線程只是cpu時間片到了,將其加入到就緒隊列尾 ASSERT(!elem_find(&thread_ready_list, &cur->general_tag)); list_append(&thread_ready_list, &cur->general_tag); cur->ticks = cur->priority; // 重新將當前線程的ticks再重置為其priority; cur->status = TASK_READY; } else { /* 若此線程需要某事件發生後才能繼續上cpu運行, 不需要將其加入隊列,因為當前線程不在就緒隊列中。*/ } ASSERT(!list_empty(&thread_ready_list)); thread_tag = NULL; // thread_tag清空 /* 將thread_ready_list隊列中的第一個就緒線程彈出,準備將其調度上cpu. */ thread_tag = list_pop(&thread_ready_list); task_struct* next = elem2entry(task_struct, general_tag, thread_tag); next->status = TASK_RUNNING; switch_to(cur, next);}

switch_to函數用來實現線程的切換,它需要做的就是保存當前環境,進入到另一個線程當中,進入的方式就是通過前面介紹的ret指令

[bits 32] section .textglobal switch_toswitch_to: ;棧中此處是返回地址 push esi push edi push ebx push ebp mov eax, [esp + 20] ; 得到棧中的參數cur, cur = [esp+20] mov [eax], esp ; 保存棧頂指針esp. task_struct的self_kstack欄位, ; self_kstack在task_struct中的偏移為0, ; 所以直接往thread開頭處存4位元組便可。;------------------ 以上是備份當前線程的環境,下面是恢復下一個線程的環境 ---------------- mov eax, [esp + 24] ; 得到棧中的參數next, next = [esp+24] mov esp, [eax] ; pcb的第一個成員是self_kstack成員,用來記錄0級棧頂指針, ; 用來上cpu時恢復0級棧,0級棧中保存了進程或線程所有信息,包括3級棧指針 pop ebp pop ebx pop edi pop esi ret ; 返回到上面switch_to下面的那句注釋的返回地址, ; 未由中斷進入,第一次執行時會返回到kernel_thread

時鐘中斷號是0x20,沒發生一次時鐘中斷,就要將當前線程的可執行時間片減一,直到他時間片用完,進行線程調度。下面是時鐘中斷處理函數。

static void intr_timer_handler(){ task_struct *cur_thread = running_thread(); ASSERT(cur_thread->stack_magic == 0x19971234); cur_thread->elapsed_ticks++; ticks++; if(cur_thread->ticks == 0) { schedule(); } else { cur_thread->ticks--; }}

線程調度總算是差不多了。個人感覺還有很多地方理解不到位,裡面如果有錯誤,還望指出。

運行一下看看結果如何


推薦閱讀:

CountDownLatch實現計算線程阻塞
使用Spring ThreadPoolTaskExecutor實現多線程任務
我們為何要使用多線程,它有什麼優點?
多線程效率測試

TAG:多線程 | Linux內核 | 操作系統 |