Linux內核進程調度時機和過程

深度詳解Linux內核網絡結構及分佈
linux內核,進程調度器的實現,完全公平調度器 CFS

1、調度類型和時機

調度觸發有兩種類型,進程主動觸發的主動調度被動調度,被動調度又叫搶佔式調度

主動調度:進程主動觸發以下情況,然後陷入內核態,最終調用schedule函數,進行調度。

1、當進程發生需要等待IO的系統調用,如readwrite

2、進程主動調用sleep時。

3、進程等待佔用信用量或mutex時,注意spin鎖不會觸發調度,可能在空轉。

被動調度:當發生以下情況時會發生被動調度:

1、tick_clockcpu的時鐘中斷,一般是10ms一次,也有1ms一次的,取決於cpu的主頻,此時會通過cfs檢查進程隊列,如果當前佔用cpu的進程的vruntime不是最小時,且超過sched_min_granularity_ns(詳細可見前文調度算法),發生“被動調度”,此處有引號,原由下面說。

2、fork出新進程時,此時會通過cfs算法檢查進度隊列,如果當前佔用cpu的進程的vruntime不是最小時且超過sched_min_granularity_ns,發生“被動調度”,此處有引號,原由下面說。

爲什麼上面“被動調度”加引號了?因爲被動調度不是立即進行的。上面兩種情況僅僅是確認需要調度後給進程的打上標誌_TIF_NEED_RESCHED,然後會在以下時機會檢查_TIF_NEED_RESCHED標誌,如果標誌存在再調用schedule函數:

1、中斷結束返回用戶態或內核態之前。

2、開啓內核搶佔開關後。kernal2.5 引入內核搶佔,即在內核態也允許搶佔。但不是內核態運行全週期都允許去搶佔,所以thread_info.preempt_count用於標誌當前是否可以進行內核搶佔。當使用preempt_enable()開關打開時,會檢查_TIF_NEED_RESCHED,進行調度。

從上可以總結下:

1、所有調度的發生都是出於內核態,中斷也是出於內核態,不會有調度出現在用戶態。

2、所有調度的都在schedule函數中發生。

Linux、C/C++技術交流羣:【960994558】整理了一些個人覺得比較好的學習書籍技術教學視頻資料共享在裏面(包括C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK等等.),有需要的可以自行添加哦!~
在這裏插入圖片描述

2、調度代碼邏輯

代碼調用層次簡單提一下,方便需要擼源碼的同學理理思路。

schedule -> __schedule -> pick_next_task -> fail_sched_class.pick_next_task_fair 
-> update_curr, pick_next_entity, context_switch

schedule:通過preempt_disable()首先關閉內核搶佔,然後調用__schedule

__schedulesmp_processor_id()獲取當前運行的cpu idrq = cpu_rq(cpu_id),獲取當前cpu的調度隊列rq

pick_next_task:遍歷所有調度的sched_class,並調用sched_class.pick_next_task方法。實時進程的sched_class在鏈表前段,會被優先遍歷並且調用,以保證實時進程優先被調度。同時本函數進行優化,如果rq -> nr_running == rq -> cfs.h_nr_running,表示隊列中的進程數 == cfs調度器中的進程數,即所有進程都是普通進程,則直接使用cfs調度器。 ps:pick_next_task會完成進程調度,被調度出的進程會在此處暫時結束,當從pick_next_task返回的時候已經是下一次再將該進程調入cpu之後才執行,這塊會在context_switch中詳細講。

pick_next_task_fair:如果是公平調度器,則調用fail_sched_class.pick_next_task_fair,其包含update_curr, pick_next_entity, context_switch三個函數。

update_curr:更新當前進程的vruntime,然後更新紅黑樹和cfs_rq -> min_vruntime以及left_most

pick_next_entity:選擇紅黑樹的left_most,比較和當前進程和left_most是否是同一進程,如果不是則進行context_switch

3、context switch(上下文切換)

這是進程調度最難的部分,因爲涉及硬件,linux也會支持不同的硬件體系。不過搞懂了上下文切換,對於硬件和linux會有更深入的瞭解。

介紹上下文切換前,需要介紹下相關的數據結構:內核棧、thread_structtss

1、內核棧:進程進入內核態後使用內核棧,和用戶棧完全隔離,task_struct -> stack指向該進程的內核棧,大小一般爲8k。在這裏插入圖片描述

union thread_union {
   
   
    struct thread_info thread_info;
    unsigned long stack[THREAD_SIZE/sizeof(long)];
};

整個內核棧用union表示,thread_infostack共用一段存儲空間,thread_info佔用低地址。在pt_regsSTACK_END_MAGIC之間,就是內核代碼的運行棧。當內核棧增長超過STACK_END_MAGIC就會報內核棧溢出。

thread_info:存儲內核態運行的一些信息,如指向task_structtask指針,使得陷入內核態之後仍然能夠找到當前進程的task_struct,還包括是否允許內核中斷的preemt_count開關等等。

struct thread_info {
   
   
    unsigned long        flags;        /* low level flags */
    mm_segment_t        addr_limit;    /* address limit */
    struct task_struct    *task;        /* main task structure */
    int            preempt_count;    /* 0 => preemptable, <0 => bug */
    int            cpu;        /* cpu */
};

pt_regs:存儲用戶態的硬件上下文(ps:用戶態)。用戶態 -> 內核態後,由於使用的棧、內存地址空間、代碼段等都不同,所以用戶態的eipespebp等需要保存現場,內核態 -> 用戶態時再將棧中的信息恢復到硬件。由於進程調度一定會在內核態的schedule函數,用戶態的所有硬件信息都保存在pt_regs中了。SAVE_ALL指令就是將用戶態的cpu寄存器值保存如內核棧,RESTORE_ALL就是將pt_regs中的值恢復到寄存器中,這兩個指令在介紹中斷的時候還會提到。

TSS(task state segment):這是intel爲上層做進程切換提供的硬件支持,還有一個TR(task register)寄存器專門指向這個內存區域。當TR指針值變更時,intel會將當前所有寄存器值存放到當前進程的tss中,然後再講切換進程的目標tss值加載後寄存器中,其結構如下:在這裏插入圖片描述
這裏很多人都會有疑問,不是有內核棧的pt_regs存儲硬件上下文了嗎,爲什麼還要有tss?前文說過,進程切換都是在內核態,而pt_regs是保存的用戶態的硬件上下文,tss用於保存內核態的硬件上下文。

但是linux並沒有買賬使用tss,因爲linux實現進程切換時並不需要所有寄存器都切換一次,如果使用tr去切換tss就必須切換全部寄存器,性能開銷會很高。這也是intel設計的敗筆,沒有把這個功能做的更加的開放導致linux沒有用。linux使用的是軟切換,主要使用thread_struct,tss僅使用esp0這個值,用於進程在用戶態 -> 內核態時,硬件會自動將該值填充到esp寄存器。在初始化時僅爲每1個cpu僅綁定一個tss,然後tr指針一直指向這個tss,永不切換。

4、thread_struct:一個和硬件體系強相關的結構體,用來存儲內核態切換時的硬件上下文。

struct thread_struct {
   
   
    unsigned long    rsp0;
    unsigned long    rsp;
    unsigned long     userrsp;    /* Copy from PDA */ 
    unsigned long    fs;
    unsigned long    gs;
    unsigned short    es, ds, fsindex, gsindex;    
/* Hardware debugging registers */
....
/* fault info */
    unsigned long    cr2, trap_no, error_code;
/* floating point info */
    union i387_union    i387  __attribute__((aligned(16)));
/* IO permissions. the bitmap could be moved into the GDT, that would make
   switch faster for a limited number of ioperm using tasks. -AK */
    int        ioperm;
    unsigned long    *io_bitmap_ptr;
    unsigned io_bitmap_max;
/* cached TLS descriptors. */
    u64 tls_array[GDT_ENTRY_TLS_ENTRIES];
} __attribute__((aligned(16)));

5、進程切換邏輯主要分爲兩部分:1)switch__mm_irqs_off:切換進程內存地址空間,對於每個進程都有一個進程內存地址空間,是一個以進程隔離的虛擬內存地址空間,所以此處也需要切換,包括頁表等,後面後詳細講到。2)switch_to:切換寄存器和堆棧。

/*
 * context_switch - switch to the new MM and the new thread's register state.
 */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
	       struct task_struct *next, struct rq_flags *rf)
{
   
   
	struct mm_struct *mm, *oldmm;
......
	mm = next->mm;
	oldmm = prev->active_mm;
......
	switch_mm_irqs_off(oldmm, mm, next);
......
	/* Here we just switch the register state and the stack. */
	switch_to(prev, next, prev);
	barrier();
	return finish_task_switch(prev);
}

switch_to中直接調用匯編__switch_to_asm,進入__switch_to_asm前,eax存儲prev task(當前進程,即將被換出)的task_struct指針,edx存儲next task(即將被換入的進程)的task_struct指針。

/*
 * %eax: prev task
 * %edx: next task
 */
ENTRY(__switch_to_asm)
	/*
	 * Save callee-saved registers
	 * This must match the order in struct inactive_task_frame
	 */
	pushl	%ebp
	pushl	%ebx
	pushl	%edi
	pushl	%esi
	pushfl

	/* switch stack */
	movl	%esp, TASK_threadsp(%eax)
	movl	TASK_threadsp(%edx), %esp
....
	/* restore callee-saved registers */
	popfl
	popl	%esi
	popl	%edi
	popl	%ebx
	popl	%ebp

	jmp	__switch_to
END(__switch_to_asm)

1)將prev taskebpebxediesieflags寄存器值壓入prev task的內核棧。

2)TASK_threadsp是從task_struct -> thread_struct -> sp獲取esp指針。在switch stack階段,首先保存prev task內核棧的esp指針到thread_struct -> sp。然後將nextthread_struct -> sp恢復到esp寄存器,此後所有的操作都在next task的內核棧上運行。

只要完成了esp寄存器的切換,基本就完成了進程的切換最核心的一步。因爲通過esp找到next task的內核棧,然後就能在內核棧中找到其他寄存器的值(步驟1壓入的寄存器值)和通過thread_info找到task_struct.thread_struct

3)將next taskeflagsesiediebxebp pop到對應的寄存。和步驟1push的順序正好相反。

__switch_to

struct task_struct * __switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
   
   
	struct thread_struct *prev = &prev_p->thread;
	struct thread_struct *next = &next_p->thread;
......
	int cpu = smp_processor_id();
	struct tss_struct *tss = &per_cpu(cpu_tss, cpu);
......
	load_TLS(next, cpu);
......
	this_cpu_write(current_task, next_p);


	/* Reload esp0 and ss1.  This changes current_thread_info(). */
	load_sp0(tss, next);
......
	return prev_p;
}

1)load_TLS:加載next task的TLS(進程局部變量)到CPU的GDT(全局描述符表,global descriptor table)的TLS中,關於GDT和TLS後面中斷的時候會着重講這兩個結構。

2)load_sp0:將next taskesp0加載到tss中。esp和esp0的區別是前者是用戶態棧的esp,後者是內核棧的esp。當從用戶態進入內核態(ring0優先級)時,硬件會自動將esp = tss - > esp0。切換esp後,再進行彈棧等操作回覆其他的寄存器,如switch宏後半部分一樣。

內存虛擬空間、寄存器、內核棧都恢復了,還有一個重要的EIP(指令指針寄存器)還沒有恢復。但linux的做法是不恢復EIP寄存器。

1)當prev -> next內核棧完成切換後(假設prev是A進程,next是B進程),EIP仍然指向switch_to函數,因爲A進程是在執行到switch_to的時候結束的。此時對於進程B,因爲上次被換出的時候一定是在內核態且也是執行到switch_to函數,所以即使不切換EIP,EIP的指向也是正確的,對於next task就應該指向switch_to函數。只是內核棧變化了,執行內核代碼段的上下文變化了,而且內核態的代碼段是唯一的,各進程公用。

2)此時next_taskswitch_to函數繼續執行直到完成,然後內核棧進行彈棧操作,彈出switch_to的棧幀。同時彈出上一棧幀的EIP指針的值到EIP寄存器,恢復next_task的運行。如下,在進行函數調用時,需要壓入棧幀,壓入棧幀前需要先push EIP,當彈出棧幀的時候恢復到EIP。比如A進程中是a -> b -> c -> switch_to,此時彈出switch_to的棧幀後,會把c的EIP恢復到eip寄存器,恢復c函數的運行。

在這裏插入圖片描述
switch_to(prev, next, last):還有一個關鍵點,switch_to爲什麼是三個參數?而且被強制編譯爲寄存器傳遞參數。對於一次進程切換,A -> B,prev = A,next = B,但當再次切換回A時,就不一定是B了,可能是C。但是在再次切換回A時,A的內核棧prev = A,next = B,就會丟失A的前序進程 C,而context_switch中最後一個函數finish_task_switch(prev)此時要求傳入的prev = C,以執行一些鎖的釋放和硬件體系的一些回調。

此時就增加了一個last參數,是一個輸出參數。

1)A -> B的時候,switch_to(A, B, A),此時prev = last。

2)當C -> A的時候,switch_to(C, A, C),此時eax = C。當已經切換到A時,會將eax的值賦值給A內核棧中的last變量,此時prev變量的值也會變爲C,這樣保證A的前序進程C不丟失。

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