深度詳解Linux內核網絡結構及分佈
linux內核,進程調度器的實現,完全公平調度器 CFS
1、調度類型和時機
調度觸發有兩種類型,進程主動觸發的主動調度和被動調度,被動調度又叫搶佔式調度。
主動調度:進程主動觸發以下情況,然後陷入內核態,最終調用schedule
函數,進行調度。
1、當進程發生需要等待IO的系統調用,如read
、write
。
2、進程主動調用sleep
時。
3、進程等待佔用信用量或mutex
時,注意spin
鎖不會觸發調度,可能在空轉。
被動調度:當發生以下情況時會發生被動調度:
1、tick_clock
,cpu
的時鐘中斷,一般是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
。
__schedule
:smp_processor_id()
獲取當前運行的cpu id
,rq = 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_struct
、tss
。
1、內核棧:進程進入內核態後使用內核棧,和用戶棧完全隔離,task_struct -> stack
指向該進程的內核棧,大小一般爲8k。
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
};
整個內核棧用union
表示,thread_info
和stack
共用一段存儲空間,thread_info
佔用低地址。在pt_regs
和STACK_END_MAGIC
之間,就是內核代碼的運行棧。當內核棧增長超過STACK_END_MAGIC
就會報內核棧溢出。
thread_info
:存儲內核態運行的一些信息,如指向task_struct
的task
指針,使得陷入內核態之後仍然能夠找到當前進程的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:用戶態)。用戶態 -> 內核態後,由於使用的棧、內存地址空間、代碼段等都不同,所以用戶態的eip
、esp
、ebp
等需要保存現場,內核態 -> 用戶態時再將棧中的信息恢復到硬件。由於進程調度一定會在內核態的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 task
的ebp
、ebx
、edi
、esi
、eflags
寄存器值壓入prev task的內核棧。
2)TASK_threadsp
是從task_struct -> thread_struct -> sp
獲取esp
指針。在switch stack
階段,首先保存prev task
內核棧的esp
指針到thread_struct -> sp
。然後將next
的thread_struct -> sp
恢復到esp
寄存器,此後所有的操作都在next task
的內核棧上運行。
只要完成了esp
寄存器的切換,基本就完成了進程的切換最核心的一步。因爲通過esp
找到next task
的內核棧,然後就能在內核棧中找到其他寄存器的值(步驟1壓入的寄存器值)和通過thread_info
找到task_struct.thread_struct
。
3)將next task
的eflags
、esi
、edi
、ebx
、ebp 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 task
的esp0
加載到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_task
的switch_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不丟失。