深入理解 Linux 內核之主調度器

  1. 開場白 ======

本文分析的內核源代碼主要集中在:

  1. 調用時機 =======

關於調度時機,網上的文章也五花八門,之前在內核搶佔文章已經做了詳細講解,而在本文我們從源碼註釋中給出依據(再次強調一下:本文的調度時機關注的是何時調用主調度器,不是設置重新調度標誌的時機,之前講解中我們知道他們都可以稱爲調度時機)。

先來說一下什麼是主調度器,其實和主調度器並列的還有一個叫做週期性調度器的東西(後面有機會會講解,主要用於時鐘中斷 tick 調來使奪取處理器的控制權),他們都是內核中的一個函數,在合適的時機被調用。

主調度器函數如下:

kernel/sched/core.c

__schedule()

內核的很多路徑會包裝這個函數,主要分爲主動調度和搶佔式調度場景。

內核源碼中主調度器函數也給出了調度時機的註釋,下面我們就以此爲依據來看下:

kernel/sched/core.c
/*
 * __schedule() is the main scheduler function.                                
 *                                                                             
 * The main means of driving the scheduler and thus entering this function are:
 *                                                                             
 *   1. Explicit blocking: mutex, semaphore, waitqueue, etc.                   
 *                                                                             
 *   2. TIF_NEED_RESCHED flag is checked on interrupt and userspace return     
 *      paths. For example, see arch/x86/entry_64.S.                           
 *                                                                             
 *      To drive preemption between tasks, the scheduler sets the flag in timer
 *      interrupt handler scheduler_tick().                                    
 *                                                                             
 *   3. Wakeups don't really cause entry into schedule(). They add a           
 *      task to the run-queue and that's it.                                   
 *                                                                             
 *      Now, if the new task added to the run-queue preempts the current       
 *      task, then the wakeup sets TIF_NEED_RESCHED and schedule() gets        
 *      called on the nearest possible occasion:                               
 *                                                                             
 *       - If the kernel is preemptible (CONFIG_PREEMPTION=y):                 
 *                                                                             
 *         - in syscall or exception context, at the next outmost              
 *           preempt_enable()(this might be as soon as the wake_up()'s       
 *           spin_unlock()!)                                                   
 *                                                                             
 *         - in IRQ context, return from interrupt-handler to                  
 *           preemptible context                                               
 *                                                                             
 *       - If the kernel is not preemptible (CONFIG_PREEMPTION is not set)     
 *         then at the next:                                                   
*          - cond_resched() call                               
*          - explicit schedule() call                          
*          - return from syscall or exception to user-space    
*          - return from interrupt-handler to user-space       
*                                                              
* WARNING: must be called with preemption disabled!            
*/                                                             
static void __sched notrace __schedule(bool preempt)

我們對註釋做出解釋,讓大家深刻理解調度時機(基本上是原樣翻譯,用顏色標註)。

  1. 顯式阻塞場景:包括互斥體、信號量、等待隊列等。

這個場景主要是爲了等待某些資源而主動放棄處理器,來調用主調度器,如發現互斥體被其他內核路徑所持有,則睡眠等待互斥體被釋放的時候來喚醒我。

  1. 在中斷和用戶空間返回路徑上檢查 TIF_NEED_RESCHED 標誌。例如,arch/x86/entry_64.S。爲了在任務之間驅動搶佔,調度程序在計時器中斷處理程序 scheduler_tick() 中設置標誌。

解釋如下:這實際上是說重新調度標誌(TIF_NEED_RESCHED)的設置和檢查的情形。

1)重新調度標誌設置情形:如 scheduler_tick 週期性調度器按照特定條件設置、喚醒的路徑上按照特定條件設置等。當前這樣的場景並不會直接調用主調度器,而會在最近的調度點到來時調用主調度器。

2)重新調度標誌檢查情形:是真正的調用主調度器,下面的場景都會涉及到,在此不在贅述。

  1. 喚醒並不會真正導致 schedule() 的進入。他們添加一個任務到運行隊列,僅此而已。

現在,如果添加到運行隊列中的新任務搶佔了當前任務,那麼喚醒設置 TIF_NEED_RESCHED, schedule() 在最近的可能情況下被調用:

1)如果內核是可搶佔的 (CONFIG_PREEMPTION=y)

註釋中很簡潔的幾句話,但其中的含義需要深刻去體會。

首先需要知道一點是:內核搶佔說的是處於內核態的任務被其他任務所搶佔的情況(無論是不是可搶佔式內核,處於用戶態的任務都可以被搶佔,處於內核態的任務是否能被搶佔由是否開啓內核搶佔來決定),當然內核態的任務可以是內核線程也可以是通過系統調用請求內核服務的用戶任務。

情況 1:這是重新開啓內核搶佔的情況,即是搶佔計數器爲 0 時,檢查重新調度標誌(TIF_NEED_RESCHED),如果設置則調用主調度器,放棄處理器(這是搶佔式調度)。

情況 2:中斷返回內核態的時候,檢查重新調度標誌(TIF_NEED_RESCHED),如果設置且搶佔計數器爲 0 時則調用主調度器,放棄處理器(這是搶佔式調度)。

注:關於內核搶佔可以參考之前發佈的文章。

2)如果內核是不可搶佔的 (CONFIG_PREEMPTION=y)

解釋如下:

cond_resched() 是爲了在不可搶佔內核的一些耗時的內核處理路徑中增加主動搶佔點(搶佔計數器是否爲 0 且當前任務被設置了重新調度標誌),則調用主調度器進行搶佔式調度,所進行低延時處理。

顯式的 schedule() 調用,這是主動放棄處理器的場景,如一些睡眠場景,像用戶任務調用 sleep。

系統調用或異常返回到用戶空間使會判斷當前進程是否設置重新調度標誌(TIF_NEED_RESCHED),如果設置則調用主調度器,放棄處理器。

中斷處理器返回到用戶空間會判斷當前進程是否設置重新調度標誌(TIF_NEED_RESCHED),如果設置則調用主調度器,放棄處理器。

其實還有一種場景也會調用到主調度器讓出處理器,那就是進程退出時,這裏不在贅述。

下面給出總結:

  1. 主動調度:
  1. 搶佔調度:

不可搶佔式內核

可搶佔式內核(增加一些搶佔點)

  1. 主調度器調用時機源碼窺探 ===============

下面給出主要的一些主調度器調用時機源碼分析,作爲學習參考。

3.1 常規場景

中斷返回用戶態場景:

arch/arm64/kernel/entry.S

el0_irq
-> ret_to_user
-> work_pending
-> do_notify_resume
-> if (thread_flags & _TIF_NEED_RESCHED) {         // arch/arm64/kernel/signal.c
         schedule();
            -> __schedule(false);       //  kernel/sched/core.c   false表示主動調度

異常返回用戶態場景:

arch/arm64/kernel/entry.S

el0_sync
-> ret_to_user
    ...

任務退出場景:

kernel/exit.c

do_exit
 ->do_task_dead
     ->__schedule(false);    //  kernel/sched/core.c   false表示主動調度

顯式阻塞場景(舉例互斥體):

kernel/locking/mutex.c

mutex_lock
 ->__mutex_lock_slowpath
     ->__mutex_lock
         ->__mutex_lock_common
             ->schedule_preempt_disabled
                 ->schedule();
                 -> __schedule(false);       //  kernel/sched/core.c   false表示主動調度

3.2 支持內核搶佔場景

中斷返回內核態場景

arch/arm64/kernel/entry.S

el1_irq
#ifdef CONFIG_PREEMPTION
->arm64_preempt_schedule_irq
    ->preempt_schedule_irq();
        ->__schedule(true);   //kernel/sched/core.c  true表示搶佔式調度
#endif

內核搶佔開啓場景

preempt_enable
->if (unlikely(preempt_count_dec_and_test()))   //搶佔計數器減一  爲0        
    __preempt_schedule();                  
        ->preempt_schedule  //kernel/sched/core.c   
            -> __schedule(true)  //調用主調度器進行搶佔式調度

注:一般說異常 / 中斷返回,返回是處理器異常狀態,可能是用戶態也可能是內核態,但是會看到很多資料寫的都是用戶空間 / 內核空間並不準確,但是我們認爲表達一個意思,做的心中有數即可。

  1. 選擇下一個進程 ==========

本節主要講解主調度器是如何選擇下一個進程的,這和調度策略強相關。

下面我們來看具體實現:

kernel/sched/core.c

__schedule
-> next = pick_next_task(rq, prev, &rf);
    ->if (likely(prev->sched_class <= &fair_sched_class &&              
        ¦  rq->nr_running == rq->cfs.h_nr_running)) {             
                                                                  
        p = pick_next_task_fair(rq, prev, rf);                    
        if (unlikely(p == RETRY_TASK))                            
                goto restart;                                     
                                                                  
        /* Assumes fair_sched_class->next == idle_sched_class */  
        if (!p) {                                                 
                put_prev_task(rq, prev);                          
                p = pick_next_task_idle(rq);                      
        }                                                         
                                                                  
        return p;                                                 
}      


 for_each_class(class) {                     
         p = class->pick_next_task(rq);      
         if (p)                              
                 return p;                   
 }

這裏做了優化,噹噹前進程的調度類爲公平調度類或者空閒調度類時,且 cpu 運行隊列的進程個數等於 cfs 運行隊列進程個數,說明運行隊列進程都是普通進程,則直接調用公平調度類的 pick_next_task_fair 選擇下一個進程(選擇紅黑樹最左邊的那個進程),如果沒有找到說明當前進程調度類爲空閒調度類,直接調用 pick_next_task_idle 選擇 idle 進程。

否則,遍歷調度類,從高優先級調度類開始調用其 pick_next_task 方法選擇下一個進程。

下面以公平調度類爲例來看如何選擇下一個進程的:調用過程如下 (這裏暫不考慮組調度情況):

pick_next_task
->pick_next_task_fair   //kernel/sched/fair.c
    -> if (prev)                        
         put_prev_task(rq, prev); 
   
   
   se = pick_next_entity(cfs_rq, NULL);  
   set_next_entity(cfs_rq, se);

先看 put_prev_task:

put_prev_task
->prev->sched_class->put_prev_task(rq, prev);
    ->put_prev_task_fair
        ->put_prev_entity(cfs_rq, se);
            ->/* Put 'current' back into the tree. */ 
                __enqueue_entity(cfs_rq, prev);         
              cfs_rq->curr = NULL;

這裏會調用__enqueue_entity 將前一個進程重新加入到 cfs 隊列的紅黑樹。然後將 cfs_rq->curr 設置爲空。

再看 pick_next_entity:

pick_next_entity
->left = __pick_first_entity(cfs_rq);
    ->left = rb_first_cached(&cfs_rq->tasks_timeline);

將選擇 cfs 隊列紅黑樹最左邊進程。

最後看 set_next_entity:

set_next_entity
 ->__dequeue_entity(cfs_rq, se);
    ->cfs_rq->curr = se;

這裏調用__dequeue_entity 將下一個選擇的進程從 cfs 隊列的紅黑樹中刪除,然後將 cfs 隊列的 curr 指向進程的調度實體

選擇下一個進程總結如下:

通用的調度類選擇順序爲:

stop_sched_class -> dl_sched_class ->rt_sched_class  -> fair_sched_class  ->idle_sched_class

比如:當前運行隊列都是 cfs 的普通進程,某一時刻發生中斷喚醒了一個 rt 進程,那麼在最近的調度點到來時就會調用主調度器選擇 rt 進程作爲 next 進程。

做了以上的工作之後,紅黑樹中選擇下一個進程的時候就不會再選擇到當前 cpu 上運行的進程了,而當前進程調度實體又被 cfs 隊列的 curr 來記錄着(運行隊列的 curr 也會記錄當前進程)。

下面給出公平調度類選擇下一個進程圖解(其中 A 爲前一個進程,即是當前進程,即爲前一個進程,B 爲下一個進程):

  1. 進程上下文切換 ==========

前面選擇了一個合適進程作爲下一個進程,接下來做重要的上下文切換動作,來保存上一個進程的 “上下文” 恢復下一個進程的“上下文”,主要包括進程地址空間切換和處理器狀態切換

注:這裏的上下文實際上是指進程運行時最小寄存器的集合。

如果切換的 next 進程不是同一個進程,才進行切換:

__schedule
 i  f (likely(prev != next)) {      
        ...
        context_switch  //進程上下文切換
    }

4.1 進程地址空間切換

進程地址空間切換就是切換虛擬地址空間,使得切換之後,當前進程訪問的是屬於自己的虛擬地址空間(包括用戶地址空間和內核地址空間),本質上是切換頁表基地址寄存器

進程地址空間切換讓進程產生獨佔系統內存的錯覺,因爲切換完地址空間後,當前進程可以訪問屬於它的海量的虛擬地址空間(內核地址空間各個進程共享,用戶地址空間各個進程私有),而實際上物理地址空間只有一份。

下面給出源代碼分析:

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 進程是內核線程,如果是則不需要進行地址空間切換(實際上指的是用戶地址空間),因爲內核線程總是運行在內核態訪問的是內核地址空間,而內核地址空間是所有的進程共享的。在 arm64 架構中,內核地址空間是通過 ttbr1_el1 來訪問,而它的主內核頁表在內核初始化的時候已經填充好了,也就是我們常說的 swapper_pg_dir 頁表,後面所有對內核地址空間的訪問,無論是內核線程也好還是用戶任務,統統通過 swapper_pg_dir 頁表來訪問,而在內核初始化期間 swapper_pg_dir 頁表地址已經加載到 ttbr1_el1 中。

需要說明一點的是:這裏會做 “借用” prev->active_mm 的處理,借用的目的是爲了避免切換屬於同一個進程的地址空間。舉例說明:Ua  ->  Ka  ->  Ua   ,Ua 表示用戶進程,  Ka 表示內核線程,當進行這樣的切換的時候,Ka 借用 Ua 地址空間,Ua  ->  Ka 不需要做地址空間切換,而 Ka  ->  Ua 按理來說需要做地址空間切換,但是由於切換的還是 Ua 地址空間,所以也不需要真正的切換(判斷了 Ka->active_mm == Ua->active_mm ),當然還包括切換的是同一個進程的多個線程的情況,這留給大家思考。

下面來看下真正的地址空間切換:

 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);   //設置asid到ttbr1_el1
                             isb();                            
                             write_sysreg(ttbr0, ttbr0_el1);   //設置mm->pgd 到ttbr0_el1

上面代碼是做真正的地址空間切換,實際的切換很簡單,並沒有那麼複雜和玄乎,僅僅設置頁表基地址寄存器即可,當然這裏還涉及到了爲了防止頻繁無效 tlb 的 ASID 的設置。

主要做的工作就是設置 next 進程的 ASID 到 ttbr1_el1, 設置 mm->pgd 到 ttbr0_el1,僅此而已****!

需要注意的是:1. 寫到 ttbr0_el1 的值是進程 pgd 頁表的物理地址。2. 雖然做了這樣的切換,但是這個時候並不能訪問到 next 的用戶地址空間,因爲還處在主調度器上下文中,屬於內核態,訪問的是內核空間。

而一旦返回了用戶態,next 進程就能正常訪問自己地址空間內容:

4.2 處理器狀態切換

來切換下一個進程的執行流,上一個進程執行狀態保存,讓下一個進程恢復執行狀態。

處理器狀態切換而後者讓進程產生獨佔系統 cpu 的錯覺,使得系統中各個任務能夠併發(多個任務在多個 cpu 上運行)或分時複用(多個任務在一個 cpu 上運行)cpu 資源。

下面給出代碼:

context_switch
->(last) = __switch_to((prev)(next))
    -> fpsimd_thread_switch(next) //浮點寄存器切換
        ...
        last = cpu_switch_to(prev, next);

處理器狀態切換會做浮點寄存器等切換,最終調用 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 進程的進程描述符(struct task_struct)地址, x1 爲 next 的進程描述符地址。會就將 prev 進程的 x19-x28,fp,sp,lr 保存到 prev 進程的 tsk.thread.cpu_context 中,next 進程的這些寄存器值從 next 進程的 tsk.thread.cpu_context 中恢復到相應寄存器。這裏還做了 sp_el0 設置爲 next 進程描述符的操作,爲了通過 current 宏找到當前的任務。

需要注意的是:

  1. mov     sp, x9  做了切換進程內核棧的操作。

  2. ldr     lr, [x8] 設置了鏈接寄存器,然後 ret 的時候會將 lr 恢復到 pc 從而真正完成了執行流的切換。

4.3 精美圖示

這裏給出了進程切換的圖示(以 arm64 處理器爲例),這裏從 prev 進程切換到 next 進程。

  1. 進程再次被調度 ==========

當進程重新被調度的時候,從原來的調度現場恢復執行。

5.1 關於 lr 地址的設置

1)如果切換的 next 進程是剛 fork 的進程,它並沒有真正的這些調度上下文的存在,那麼 lr 是什麼呢?這是在 fork 的時候設置的:

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;

設置爲了 ret_from_fork 的地址,當然這裏也設置了 sp 等調度上下文(這裏將進程切換保存的寄存器稱之爲調度上下文)。

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 的進程,從 cpu_switch_to 的 ret 指令執行後返回,lr 加載到 pc。

於是執行到 ret_from_fork:這裏首先調用 schedule_tail 對前一個進程做清理工作,然後判斷是否爲內核線程如果是執行內核線程的執行函數,如果是用戶任務通過 ret_to_user 返回到用戶態。

2)如果是之前已經被切換過的進程,lr 爲 cpu_switch_to 調用的下一條指令地址 (這裏實際上是__schedule 函數中調用 barrier()的指令地址)。

5.2 關於__switch_to 的參數和返回值

 
 switch_to(prev, next, prev)
->  ((last) = __switch_to((prev)(next)))

這裏做處理器狀態切換時,傳遞了兩個參數,返回了一個參數:

prev 和 next 很好理解就是 就是前一個進程(當前進程)和下一個進程的 task_struct 結構指針,那麼 last 是什麼呢?

一句話:返回的 last 是當前重新被調度的進程的上一個進程的 task_struct 結構指針

如:A ->B -> 千山萬水 ->D -> A 上面的切換過程:A 切換到 B 然後經歷千山萬水再從 D -> A,這個時候 A 重新被調度時,last 即爲 D 的 task_struct 結構指針。

獲得當前重新被調度進程的前一個進程是爲了回收前一個進程資源,見後面分析。

5.3 關於 finish_task_switch

進程被重新調度時無論是否爲剛 fork 出的進程都會走到 finish_task_switch 這個函數,下面我們來看它做了什麼事情:

主要工作爲:檢查回收前一個進程資源,爲當前進程恢復執行做一些準備工作

finish_task_switch
->finish_lock_switch
    ->raw_spin_unlock_irq   //使能本地中斷
->if (mm) 
    mmdrop(mm)  //有借有還  借用的mm現在歸還
 ->if (unlikely(prev_state == TASK_DEAD)) {        //前一個進程是死亡狀態
            put_task_stack(prev);    //如果內核棧在task_struct中   釋放內核棧                                      
           put_task_struct_rcu_user(prev);  //釋放前一個進程的task_struct佔用內存
   }

可以看到進程被重新調度時首先需要做的主要是:

  1. 總結 =====

主調度器可以說 Linux 內核進程管理中的核心組件,進程管理的其他部分如搶佔、喚醒、睡眠等都是圍繞它來運作。在原子上下文不能發生調度,說的就是調用主調度器,但是可以設置搶佔標誌以至於在最近的搶佔點發生調度,如中斷中喚醒高優先級進程的場景。主調度器所做的工作就是讓出 cpu,內核很多場景可以直接或間接調用它,而大體上可以分爲兩種情況:即爲主動調度和搶佔式調度。主調度器做了兩件事情:選擇下一個進程和進程進程上下文切換。選擇下一個進程解決選擇合適高優先級進程的問題。進程進程上下文切換又分爲地址空間切換和處理器狀態切換,前者讓進程產生獨自佔用系統內存的錯覺,而後者讓進程產生獨自佔用系統 cpu 的錯覺,讓系統各個進程有條不紊的共享內存和 cpu 等資源。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/8u3gpW_AQzAU-HuXyBH43w