深入理解Linux內(nèi)核之主調(diào)度器(下)
本文轉(zhuǎn)載自微信公眾號「Linux內(nèi)核遠(yuǎn)航者」,作者Linux內(nèi)核遠(yuǎn)航者。轉(zhuǎn)載本文請聯(lián)系Linux內(nèi)核遠(yuǎn)航者公眾號。
4.進(jìn)程上下文切換
前面選擇了一個合適進(jìn)程作為下一個進(jìn)程,接下來做重要的上下文切換動作,來保存上一個進(jìn)程的“上下文”恢復(fù)下一個進(jìn)程的“上下文”,主要包括進(jìn)程地址空間切換和處理器狀態(tài)切換。
注:這里的上下文實際上是指進(jìn)程運(yùn)行時最小寄存器的集合。
如果切換的next進(jìn)程不是同一個進(jìn)程,才進(jìn)行切換:
- __schedule
- i f (likely(prev != next)) {
- ...
- context_switch //進(jìn)程上下文切換
- }
4.1 進(jìn)程地址空間切換
進(jìn)程地址空間切換就是切換虛擬地址空間,使得切換之后,當(dāng)前進(jìn)程訪問的是屬于自己的虛擬地址空間(包括用戶地址空間和內(nèi)核地址空間),本質(zhì)上是切換頁表基地址寄存器。
進(jìn)程地址空間切換讓進(jìn)程產(chǎn)生獨占系統(tǒng)內(nèi)存的錯覺,因為切換完地址空間后,當(dāng)前進(jìn)程可以訪問屬于它的海量的虛擬地址空間(內(nèi)核地址空間各個進(jìn)程共享,用戶地址空間各個進(jìn)程私有),而實際上物理地址空間只有一份。
下面給出源代碼分析:
- context_switch
- ->
- /*
- ¦* kernel -> kernel lazy + transfer active
- ¦* user -> kernel lazy + mmgrab() active
- ¦*
- ¦* kernel -> user switch + mmdrop() active
- ¦* user -> user switch
- ¦*/
- if (!next->mm) { // to kernel
- enter_lazy_tlb(prev->active_mm, next);
- next->active_mm = prev->active_mm;
- if (prev->mm) // from user
- mmgrab(prev->active_mm);
- else
- prev->active_mm = NULL;
- } else { // to user
- ...
- switch_mm_irqs_off(prev->active_mm, next->mm, next);
- if (!prev->mm) { // from kernel
- /* will mmdrop() in finish_task_switch(). */
- rq->prev_mm = prev->active_mm;
- prev->active_mm = NULL;
- }
- }
以上代碼是判斷是否next進(jìn)程是內(nèi)核線程,如果是則不需要進(jìn)行地址空間切換(實際上指的是用戶地址空間),因為內(nèi)核線程總是運(yùn)行在內(nèi)核態(tài)訪問的是內(nèi)核地址空間,而內(nèi)核地址空間是所有的進(jìn)程共享的。在arm64架構(gòu)中,內(nèi)核地址空間是通過ttbr1_el1來訪問,而它的主內(nèi)核頁表在內(nèi)核初始化的時候已經(jīng)填充好了,也就是我們常說的swapper_pg_dir頁表,后面所有對內(nèi)核地址空間的訪問,無論是內(nèi)核線程也好還是用戶任務(wù),統(tǒng)統(tǒng)通過swapper_pg_dir頁表來訪問,而在內(nèi)核初始化期間swapper_pg_dir頁表地址已經(jīng)加載到ttbr1_el1中。
需要說明一點的是:這里會做“借用” prev->active_mm的處理,借用的目的是為了避免切換屬于同一個進(jìn)程的地址空間。舉例說明:Ua -> Ka -> Ua ,Ua表示用戶進(jìn)程, Ka表示內(nèi)核線程,當(dāng)進(jìn)行這樣的切換的時候,Ka 借用Ua地址空間,Ua -> Ka不需要做地址空間切換,而Ka -> Ua按理來說需要做地址空間切換,但是由于切換的還是Ua 地址空間,所以也不需要真正的切換(判斷了Ka->active_mm == Ua->active_mm ),當(dāng)然還包括切換的是同一個進(jìn)程的多個線程的情況,這留給大家思考。
下面來看下真正的地址空間切換:
- switch_mm_irqs_off(prev->active_mm, next->mm, next);
- ->switch_mm //arch/arm64/include/asm/mmu_context.h
- -> if (prev != next)
- __switch_mm(next);
- ->check_and_switch_context(next)
- -> ... //asid處理
- -> cpu_switch_mm(mm->pgd, mm)
- ->cpu_do_switch_mm(virt_to_phys(pgd),mm)
- -> unsigned long ttbr1 = read_sysreg(ttbr1_el1);
- unsigned long asid = ASID(mm);
- unsigned long ttbr0 = phys_to_ttbr(pgd_phys);
- ...
- write_sysreg(ttbr1, ttbr1_el1); //設(shè)置asid到ttbr1_el1
- isb();
- write_sysreg(ttbr0, ttbr0_el1); //設(shè)置mm->pgd 到ttbr0_el1
上面代碼是做真正的地址空間切換,實際的切換很簡單,并沒有那么復(fù)雜和玄乎,僅僅設(shè)置頁表基地址寄存器即可,當(dāng)然這里還涉及到了為了防止頻繁無效tlb的ASID的設(shè)置。
主要做的工作就是設(shè)置next進(jìn)程的ASID到ttbr1_el1, 設(shè)置mm->pgd 到ttbr0_el1,僅此而已!
需要注意的是:1.寫到ttbr0_el1的值是進(jìn)程pgd頁表的物理地址。2.雖然做了這樣的切換,但是這個時候并不能訪問到next的用戶地址空間,因為還處在主調(diào)度器上下文中,屬于內(nèi)核態(tài),訪問的是內(nèi)核空間。
而一旦返回了用戶態(tài),next進(jìn)程就能正常訪問自己地址空間內(nèi)容:
- 訪問一個用戶空間的虛擬地址va,首先通過va和記錄在ttbr1_el1的asid查詢tlb,如果找到相應(yīng)表項則獲得pa進(jìn)行訪問。
- 如果tlb中沒有找到,通過ttbr0_el1來遍歷自己的多級頁表,找到相應(yīng)表項則獲得pa進(jìn)行訪問。
- 如果發(fā)生中斷異常等訪問內(nèi)核地址空間,直接通過ttbr1_el1即可完成訪問。
- 訪問沒有建立頁表映射的合法va,發(fā)生缺頁異常來建立映射關(guān)系,填寫屬于進(jìn)程自己的各級頁表,然后訪問。
- 訪問無法地址,發(fā)生缺頁殺死進(jìn)程等等。
4.2 處理器狀態(tài)切換
來切換下一個進(jìn)程的執(zhí)行流,上一個進(jìn)程執(zhí)行狀態(tài)保存,讓下一個進(jìn)程恢復(fù)執(zhí)行狀態(tài)。
處理器狀態(tài)切換而后者讓進(jìn)程產(chǎn)生獨占系統(tǒng)cpu的錯覺,使得系統(tǒng)中各個任務(wù)能夠并發(fā)(多個任務(wù)在多個cpu上運(yùn)行)或分時復(fù)用(多個任務(wù)在一個cpu上運(yùn)行)cpu資源。
下面給出代碼:
- context_switch
- ->(last) = __switch_to((prev), (next))
- -> fpsimd_thread_switch(next) //浮點寄存器切換
- ...
- last = cpu_switch_to(prev, next);
處理器狀態(tài)切換會做浮點寄存器等切換,最終調(diào)用cpu_switch_to做真正切換。
- cpu_switch_to //arch/arm64/kernel/entry.S
- SYM_FUNC_START(cpu_switch_to)
- mov x10, #THREAD_CPU_CONTEXT
- add x8, x0, x10
- mov x9, sp
- stp x19, x20, [x8], #16 // store callee-saved registers
- stp x21, x22, [x8], #16
- stp x23, x24, [x8], #16
- stp x25, x26, [x8], #16
- stp x27, x28, [x8], #16
- stp x29, x9, [x8], #16
- str lr, [x8]
- add x8, x1, x10
- ldp x19, x20, [x8], #16 // restore callee-saved registers
- ldp x21, x22, [x8], #16
- ldp x23, x24, [x8], #16
- ldp x25, x26, [x8], #16
- ldp x27, x28, [x8], #16
- ldp x29, x9, [x8], #16
- ldr lr, [x8]
- mov sp, x9
- msr sp_el0, x1
- ptrauth_keys_install_kernel x1, x8, x9, x10
- scs_save x0, x8
- scs_load x1, x8
- ret
- SYM_FUNC_END(cpu_switch_to)
這里傳遞過來的是x0為prev進(jìn)程的進(jìn)程描述符(struct task_struct)地址, x1為next的進(jìn)程描述符地址。會就將prev進(jìn)程的 x19-x28,fp,sp,lr保存到prev進(jìn)程的tsk.thread.cpu_context中,next進(jìn)程的這些寄存器值從next進(jìn)程的tsk.thread.cpu_context中恢復(fù)到相應(yīng)寄存器。這里還做了sp_el0設(shè)置為next進(jìn)程描述符的操作,為了通過current宏找到當(dāng)前的任務(wù)。
需要注意的是:
- mov sp, x9 做了切換進(jìn)程內(nèi)核棧的操作。
- ldr lr, [x8] 設(shè)置了鏈接寄存器,然后ret的時候會將lr恢復(fù)到pc從而真正完成了執(zhí)行流的切換。
4.3 精美圖示
這里給出了進(jìn)程切換的圖示(以arm64處理器為例),這里從prev進(jìn)程切換到next進(jìn)程。
5.進(jìn)程再次被調(diào)度
當(dāng)進(jìn)程重新被調(diào)度的時候,從原來的調(diào)度現(xiàn)場恢復(fù)執(zhí)行。
5.1 關(guān)于lr地址的設(shè)置
1)如果切換的next進(jìn)程是剛fork的進(jìn)程,它并沒有真正的這些調(diào)度上下文的存在,那么lr是什么呢?這是在fork的時候設(shè)置的:
- do_fork
- ...
- copy_thread //arch/arm64/kernel/process.c
- ->memset(&p->thread.cpu_context, 0, sizeof(struct cpu_context));
- p->thread.cpu_context.pc = (unsigned long)ret_from_fork;
- p->thread.cpu_context.sp = (unsigned long)childregs;
設(shè)置為了ret_from_fork的地址,當(dāng)然這里也設(shè)置了sp等調(diào)度上下文(這里將進(jìn)程切換保存的寄存器稱之為調(diào)度上下文)。
- SYM_CODE_START(ret_from_fork)
- bl schedule_tail
- cbz x19, 1f // not a kernel thread
- mov x0, x20
- blr x19
- 1: get_current_task tsk
- b ret_to_user
- SYM_CODE_END(ret_from_fork)
剛fork的進(jìn)程,從cpu_switch_to的ret指令執(zhí)行后返回,lr加載到pc。
于是執(zhí)行到ret_from_fork:這里首先調(diào)用schedule_tail對前一個進(jìn)程做清理工作,然后判斷是否為內(nèi)核線程如果是執(zhí)行內(nèi)核線程的執(zhí)行函數(shù),如果是用戶任務(wù)通過ret_to_user返回到用戶態(tài)。
2)如果是之前已經(jīng)被切換過的進(jìn)程,lr為cpu_switch_to調(diào)用的下一條指令地址(這里實際上是__schedule函數(shù)中調(diào)用barrier()的指令地址)。
5.2 關(guān)于__switch_to的參數(shù)和返回值
- switch_to(prev, next, prev)
- > ((last) = __switch_to((prev), (next)))
這里做處理器狀態(tài)切換時,傳遞了兩個參數(shù),返回了一個參數(shù):
prev和next很好理解就是 就是前一個進(jìn)程(當(dāng)前進(jìn)程)和下一個進(jìn)程的 task_struct結(jié)構(gòu)指針,那么last是什么呢?
一句話:返回的last是當(dāng)前重新被調(diào)度的進(jìn)程的上一個進(jìn)程的 task_struct結(jié)構(gòu)指針。
如:A ->B ->千山萬水->D -> A 上面的切換過程:A切換到B 然后經(jīng)歷千山萬水再從D -> A,這個時候A重新被調(diào)度時,last即為D的 task_struct結(jié)構(gòu)指針。
獲得當(dāng)前重新被調(diào)度進(jìn)程的前一個進(jìn)程是為了回收前一個進(jìn)程資源,見后面分析。
5.3 關(guān)于finish_task_switch
進(jìn)程被重新調(diào)度時無論是否為剛fork出的進(jìn)程都會走到finish_task_switch這個函數(shù),下面我們來看它做了什么事情:
主要工作為:檢查回收前一個進(jìn)程資源,為當(dāng)前進(jìn)程恢復(fù)執(zhí)行做一些準(zhǔn)備工作。
- finish_task_switch
- ->finish_lock_switch
- ->raw_spin_unlock_irq //使能本地中斷
- ->if (mm)
- mmdrop(mm) //有借有還 借用的mm現(xiàn)在歸還
- ->if (unlikely(prev_state == TASK_DEAD)) { //前一個進(jìn)程是死亡狀態(tài)
- put_task_stack(prev); //如果內(nèi)核棧在task_struct中 釋放內(nèi)核棧
- put_task_struct_rcu_user(prev); //釋放前一個進(jìn)程的task_struct占用內(nèi)存
- }
可以看到進(jìn)程被重新調(diào)度時首先需要做的主要是:
- 重新使能本地中斷 ,進(jìn)程被重新調(diào)度時,本地cpu中斷是被重新打開的!!!
- 如果有借用mm的情況,現(xiàn)在歸還 如果前一個是內(nèi)核線程,在進(jìn)程地址空間切換時“借用了”某個進(jìn)程的mm_struct,現(xiàn)在切換到了下一個進(jìn)程,理應(yīng)歸還,歸還做的是遞減借用的mm_struct的引用計數(shù),引用計數(shù)為0就會釋放mm_struct占用的內(nèi)存。
- 對于上一個死亡的進(jìn)程現(xiàn)在回收最后的資源, 注意這里是遞減引用計數(shù),當(dāng)引用計數(shù)為0時才會真正釋放。
6. 總結(jié)
主調(diào)度器可以說Linux內(nèi)核進(jìn)程管理中的核心組件,進(jìn)程管理的其他部分如搶占、喚醒、睡眠等都是圍繞它來運(yùn)作。在原子上下文不能發(fā)生調(diào)度,說的就是調(diào)用主調(diào)度器,但是可以設(shè)置搶占標(biāo)志以至于在最近的搶占點發(fā)生調(diào)度,如中斷中喚醒高優(yōu)先級進(jìn)程的場景。主調(diào)度器所做的工作就是讓出cpu,內(nèi)核很多場景可以直接或間接調(diào)用它,而大體上可以分為兩種情況:即為主動調(diào)度和搶占式調(diào)度。主調(diào)度器做了兩件事情:選擇下一個進(jìn)程和進(jìn)程進(jìn)程上下文切換。選擇下一個進(jìn)程解決選擇合適高優(yōu)先級進(jìn)程的問題。進(jìn)程進(jìn)程上下文切換又分為地址空間切換和處理器狀態(tài)切換,前者讓進(jìn)程產(chǎn)生獨自占用系統(tǒng)內(nèi)存的錯覺,而后者讓進(jìn)程產(chǎn)生獨自占用系統(tǒng)cpu的錯覺,讓系統(tǒng)各個進(jìn)程有條不紊的共享內(nèi)存和cpu等資源。