深入理解 Linux 內核之內核搶佔

  1. 開場白 ======

環境:

處理器架構:arm64

內核源碼:linux-5.11

ubuntu 版本:20.04.1

代碼閱讀工具:vim+ctags+cscope

我們或許經常聽說過內核搶佔,可是我們是否真正理解它呢?內核搶佔和搶佔式內核究竟有什麼關係呢?搶佔計數器究竟幹什麼用?... 本文我們就來好好討論下,關於內核搶佔的一些技術細節,力求讓大家理解內核搶佔。

注:本文主要關注 CFS 調度類。

  1. 內核搶佔和搶佔式內核 =============

我們經常使用 uname -a 命令能看到 “PREEMPT” 的字樣,沒錯,我們使用的是搶佔式內核

# uname -a
Linux (none) 5.11.0-g08a3831f3ae1 #1 SMP PREEMPT Fri Apr 30 17:41:53 CST 2021 aarch64 GNU/Linux

那什麼是搶佔式內核呢? 實際上,支持內核搶佔的內核叫做搶佔式內核,不支持內核搶佔的內核叫做不可搶佔式內核。那麼問題又來了,什麼是內核搶佔呢?我們都知道,拿週期性的 tick 來說:對於用戶任務,當每個時鐘中斷到來後都會檢查它的實際運行時間是否超過理想運行時間,或者運行隊列中有沒有優先級更高的進程,一般如果滿足其中一個條件就會設置重新調度標誌,然後在中斷返回用戶態的前夕發生調度,這是所謂的用戶任務搶佔。但是如果處於一個內核態的任務正在運行,這個時候發生中斷喚醒了一個高優先級的任務,那麼這個被喚醒的任務能否被調度執行呢?這個時候就會分兩種情況分析,如果是搶佔式內核那麼高優先級任務就有可能搶佔當前任務而調度執行(之所有是有可能是因爲兩者虛擬運行時間差值要大於搶佔粒度才允許搶佔),如果是不可搶佔式內核那麼不允許搶佔,除非當前進程執行完或者主動發生調度高優先級進程該有機會被調度。也就是說,支持內核搶佔的內核不僅允許在用戶態的任務可以被搶佔,處在內核態的任務也允許被搶佔(請注意這裏說的是內核態,因爲用戶空間任務可以通過系統調用等進入內核態),這樣對於交互性或者低延遲的應用場景很友好,如手持設備和桌面應用,響應會很快。而對於服務器來說,它就對吞吐量要求較高,希望獲得更多的 cpu 時間,而交互性或者低延遲都是次要的,所以被設計成不可搶佔式內核。

下圖給出非搶佔式內核調度情況:

下圖給出搶佔式內核調度情況:

對比兩個圖可以發現:採用搶佔式內核調度的情況下,在中斷中喚醒一個高優先級任務能夠得到很好的響應。

關於搶佔式內核還是不可搶佔式內核的選擇在源碼的 kernel/Kconfig.preempt 有所描述:

config PREEMPT_NONE
        bool "No Forced Preemption (Server)"
        help
        ¦ This is the traditional Linux preemption model, geared towards
        ¦ throughput. It will still provide good latencies most of the
        ¦ time, but there are no guarantees and occasional longer delays
        ¦ are possible.

        ¦ Select this option if you are building a kernel for a server or
        ¦ scientific/computation system, or if you want to maximize the
        ¦ raw processing power of the kernel, irrespective of scheduling
        ¦ latencies.

config PREEMPT
        bool "Preemptible Kernel (Low-Latency Desktop)"
        depends on !ARCH_NO_PREEMPT
        select PREEMPTION
        select UNINLINE_SPIN_UNLOCK if !ARCH_INLINE_SPIN_UNLOCK
        select PREEMPT_DYNAMIC if HAVE_PREEMPT_DYNAMIC
        help
        ¦ This option reduces the latency of the kernel by making
        ¦ all kernel code (that is not executing in a critical section)
        ¦ preemptible.  This allows reaction to interactive events by
        ¦ permitting a low priority process to be preempted involuntarily
        ¦ even if it is in kernel mode executing a system call and would
        ¦ otherwise not be about to reach a natural preemption point.
        ¦ This allows applications to run more 'smoothly' even when the
        ¦ system is under load, at the cost of slightly lower throughput
        ¦ and a slight runtime overhead to kernel code.

        ¦ Select this if you are building a kernel for a desktop or
        ¦ embedded system with latency requirements in the milliseconds
        ¦ range.

上面列舉了兩個編譯選項一個是支持內核搶佔一個是不支持內核搶佔,其實還有 PREEMPT_VOLUNTARY 和 PREEMPT_RT,前者會顯式增加一些搶佔點,後者用於支持實時性 。

  1. 重新調度標誌和搶佔計數器 ===============

內核有些路徑是不允許調度的,如原子上下文,那麼這個時候如果喚醒一個高優先級的任務或者 tick 的時候檢查可重新調度條件滿足,那麼高優先級的任務將不能馬上得到執行,但是我又要標識一下需要重新調度,那麼就需要設置重新調度標誌,當返回到可調度上下文的時候(如開搶佔),這個時候就會檢查是否設置了這個標誌來決定是否調用調度器來選擇下一個任務來運行。

標識重新調度是設置:

//當前任務的task_struct的thread_info的flag
stsk->thread_info->flags設置TIF_NEED_RESCHED標誌
#define TIF_NEED_RESCHED        1       /* rescheduling necessary */

內核的某些路徑上設置了這個標誌之後,將在最近的調度點發生調度(可能是最近開啓搶佔的時候,也可能是最近中斷異常返回的時候)。

當前任務被設置了重新調度標誌,只是表明不久的將來會發生調度,並不是馬上發生調度,對於用戶任務來說就是中斷異常返回用戶態的前夕發生調度,而對於處於內核態的任務來說,想要在內核態搶佔當前進程,僅僅置位重新調度標誌還不行,還需要判斷當前進程的搶佔計數器是否爲 0。

所有對於處於內核態的任務來說,搶佔計數器對於重新調度至關重要,只要搶佔計數器不爲 0,無論被喚醒的任務在緊急都不能獲得調度器,我們來看看這個搶佔計數器:

tsk->thread_info->preempt.count

我們來看下對於 arm64 架構,搶佔計數器的定義:

 24 struct thread_info {
 25         unsigned long           flags;          /* low level flags */

 29         union {
 30                 u64             preempt_count;  /* 0 => preemptible, <0 => bug */
 31                 struct {
 32 #ifdef CONFIG_CPU_BIG_ENDIAN
 33                         u32     need_resched;
 34                         u32     count;
 35 #else
 36                         u32     count;
 37                         u32     need_resched;
 38 #endif
 39                 } preempt;
 40         };
 45 };

可以發現它是一個共用體,內核某些路徑使用 preempt_count,有的是 preempt,爲何會使用這麼奇怪的定義呢?因爲一個成員可以表示兩種狀態:重新調度標誌和搶佔計數器的數值

當需要重新調度的時候會置位 flags 的 TIF_NEED_RESCHED 標誌,與此同時會將 preempt.need_resched 清零。當檢查 thread_info 的 preempt_count==0 成立時,說明搶佔計數器的數值爲 0 且 flags 的 TIF_NEED_RESCHED 標誌被置位,這個時候可以進程重新調度(如中斷返回內核態前夕的檢查)。

下面看下如何設置重新調度標誌:

resched_curr   //kernel/sched/core.c
 613         if (cpu == smp_processor_id()) {   
 614                 set_tsk_need_resched(curr);
 615                 set_preempt_need_resched();
 616                 return;                    
 617         }    


29 static inline void set_preempt_need_resched(void)   //arch/arm64/include/asm/preempt.h
30 {
31         current_thread_info()->preempt.need_resched = 0;
32 }

當內核的某個路徑設置重新調度標誌(如時鐘中斷 tick 時),會調用到 resched_curr  來設置重新調度標誌:可以看到除了設置任務的 flags 的 TIF_NEED_RESCHED 標誌外,還設置了 preempt.need_resched 爲 0。

如何清除重新調度標誌:

kernel/sched/core.c
__schedule    //主動調度或搶佔式調度 都會調用到這
 5046         clear_tsk_need_resched(prev); 
 5047         clear_preempt_need_resched(); 

//arch/arm64/include/asm/preempt.h
34 static inline void clear_preempt_need_resched(void)      
35 {                
36         current_thread_info()->preempt.need_resched = 1;
37 }

可以看到在主調度器中,除了調用 clear_tsk_need_resched 來清除任務的 flags 的 TIF_NEED_RESCHED 標誌外,會調用 clear_preempt_need_resched 來設置 preempt.need_resched 爲 1, 來清除重新調度。

下面爲搶佔計數器的各個域的表示:

0-7 表示搶佔計數 ,8-15 表示軟中斷計數, 16-19 表示硬中斷計數,20-23 表示不可屏蔽中斷計數。當進入不同的上下文時會設置響應的位域,表示在某個上下文中,當某個位域被設置,搶佔計數器不爲 0,任務在內核態就不容許被搶佔。

所以,搶佔計數器有兩個作用:一個是標識內核路徑在某個原子上下文,一個是用來判斷是否允許任務在內核態被搶佔。

include/linux/preempt.h

85 /*                                                                                    
86  * Macros to retrieve the current execution context:                                  
87  *                                                                                    
88  * in_nmi()             - We're in NMI context                                        
89  * in_hardirq()         - We're in hard IRQ context                                   
90  * in_serving_softirq() - We're in softirq context                                    
91  * in_task()            - We're in task context                                       
92  */                                                                                   
93 #define in_nmi()                (nmi_count())     //判斷是否在 不可屏蔽中斷上下文                                  
94 #define in_hardirq()            (hardirq_count())     //判斷是否在硬中斷上下文                                    
95 #define in_serving_softirq()    (softirq_count() & SOFTIRQ_OFFSET)        //判斷是否在軟中斷上下文                
96 #define in_task()               (!(in_nmi() | in_hardirq() | in_serving_softirq()))  //判斷是否在進程上下文 
97                                                                                       


 98 /*                                                                              
 99  * The following macros are deprecated and should not be used in new code:      
100  * in_irq()       - Obsolete version of in_hardirq()                            
101  * in_softirq()   - We have BH disabled, or are processing softirqs             
102  * in_interrupt() - We're in NMI,IRQ,SoftIRQ context or have BH disabled        
103  */                                                                             
104 #define in_irq()                (hardirq_count())    //判斷是否在硬中斷上下文                           
105 #define in_softirq()            (softirq_count())    //判斷是否在軟中斷上下文(關閉軟中斷或者在執行軟中斷)                              
106 #define in_interrupt()          (irq_count())      //判斷是否在中斷上下文(包括硬中斷 軟中斷和不可屏蔽中斷)                                


//判斷是否在原子上下文(搶佔計數器不爲0)
144 #define in_atomic()     (preempt_count() != 0)
  1. 內核搶佔的調度時機 ============

這裏調度時機我將它細分爲兩種情況,一種是不進行調度的 check 點,一種是真正的搶佔點(即是調用主調度器進行調度):

check 點 ->

 tick 的時候  :  滿足條件 (任務使用完理想運行時間,運行時間大於最小搶佔粒度且運行隊列有優先級更高的任務) 時,設置 TIF_NEED_RESCHED 標誌, 最近的搶佔點發生調度 。

喚醒搶佔    : 滿足條件 (喚醒的任務與當前任務的虛擬運行時間差值大於最小喚醒搶佔粒度 ,喚醒的任務虛擬運行時間更小) 時, 設置 TIF_NEED_RESCHED 標誌,最近的搶佔點發生調度。

搶佔點 ->

中斷返回內核態  :    滿足條件(重新調度標誌置位且搶佔計數器爲 0) 時, 搶佔式調度 。

打開搶佔的時候  : (如開搶佔,開中斷下半部,釋放自旋鎖) 滿足條件(重新調度標誌置位且搶佔計數器爲 0)時,  搶佔式調度。

開啓軟中斷的時候  : 滿足條件(重新調度標誌置位且搶佔計數器爲 0)時,  搶佔式調度。

中斷返回內核態是常規的搶佔點,一般情況下即使沒有其他中斷產生,週期性的 tick 中斷也會發生,  滿足條件(重新調度標誌置位且搶佔計數器爲 0)時,當前任務就會被搶佔。而在一些會發生多任務竟態的臨界區中,我們需要關閉內核搶佔,有的直接調用 preempt_disable, 有的是間接調用 preempt_disable(如申請自旋鎖的臨界區), 有的則是關閉軟中斷等,這些都會導致搶佔計數器不爲 0,但是在這些臨界區中如果中斷喚醒了高優先級的任務,中斷返回內核態的前夕是不能進行調度的,所以在這些臨界區結束的時候會檢查調度條件是否滿足,如果滿足進行搶佔式調度,從而使得被喚醒的任務被及時的響應。一般,一些 check 點設置了當前任務的重新調度標誌之後,如果搶佔計數器爲 0,會在最近的搶佔點發生調度(就是上面所說的三種情況)。還有需要注意的是:關搶佔的臨界區中,只是禁止了當前任務所在 cpu 的內核搶佔,其他 cpu 依然可以進行內核搶佔,如果這段臨界區有可能被其他 cpu 訪問到,可以直接使用自旋鎖來保護。

4.1 check 點

1) 時鐘中斷 tick 時:

kernel/sched/core.c

scheduler_tick
->curr->sched_class->task_tick(rq, curr, 0)
 ->task_tick_fair
  ->entity_tick
   ->check_preempt_tick
    ->4374         if (delta_exec > ideal_runtime) {  //1.當前任務的實際運行時間大於理想運行時間
    4375                 resched_curr(rq_of(cfs_rq));   //設置重新調度標誌
    4389         if (delta_exec < sysctl_sched_min_granularity) //當前任務的實際運行時間 小於 最小調度粒度嗎?
    4390                 return;

    4398         if (delta > ideal_runtime)  //2.紅黑樹最左邊的任務的虛擬運行時間和當前任務的虛擬運行時間的差值小於 理想運行時間
    4399                 resched_curr(rq_of(cfs_rq)); //設置重新調度標誌

每個時鐘 tick 到來時,會調用 scheduler_tick 來檢查是否需要重新調度,以下兩個條件有一個發生都會設置重新調度標誌:

  1. 當前任務的實際運行時間大於理想運行時間(保證任務在一個調度週期內運行時間不會超過理想運行時間,防止 “流氓” 任務一直霸佔 cpu,通過週期性的時鐘中斷奪回處理器的使用權)。 

  2. 當前任務的實際運行時間大於最小調度粒度,且紅黑樹最左邊的任務的虛擬運行時間和當前任務的虛擬運行時間的差值小於理想運行時間(紅黑樹中的高優先級的任務可以搶佔當前任務)。

2)喚醒搶佔:

在 fork 和正常的喚醒路徑上:

fork 路徑:

kernel/fork.c

kernel_clone
->wake_up_new_task(p)
 ->check_preempt_curr(rq, p, WF_FORK)
  ->rq->curr->sched_class->check_preempt_curr(rq, p, flags)
   ->check_preempt_wakeup    //kernel/sched/fair.c
    -> 6994         if (wakeup_preempt_entity(se, pse) == 1) {   //喚醒的任務的虛擬運行時間和當前任務的虛擬運行時間差值小於最新喚醒搶佔粒度轉換的虛擬運行時間       
     6995                 /*                                                  
     6996                 ¦* Bias pick_next to pick the sched entity that is  
     6997                 ¦* triggering this preemption.                      
     6998                 ¦*/                                                 
     6999                 if (!next_buddy_marked)                             
     7000                         set_next_buddy(pse);                        
     7001                 goto preempt;                                       
     7002         }                                                           
     7003                                                                     
     7004         return;                                                     
     7005                                                                     
     7006 preempt:                                                            
     7007         resched_curr(rq);      //設置重新調度標誌

正常喚醒路徑:

kernel/sched/core.c
wake_up_process
->try_to_wake_up
 ->ttwu_queue
  ->ttwu_do_activate
   ->ttwu_do_wakeup
    ->check_preempt_curr(rq, p, wake_flags)

無論是創建新任務或者是喚醒任務的時候,都有可能新喚醒的任務搶佔當前任務,判斷條件如下:****喚醒的任務的虛擬運行時間和當前任務的虛擬運行時間差值小於最小喚醒搶佔粒度轉換的虛擬運行時間(喚醒的任務的虛擬運行時間更小)。

4.2 搶佔點

上面介紹的都是 check 點,只是設置重新調度標誌,並沒有讓搶佔的任務運行,真正的搶佔點是調用主調度器的時候

1)中斷返回內核態

當開啓內核搶佔的時候,在中斷返回內核態的前夕,會檢查當前任務是否設置了重新調度標誌且搶佔計數器爲 0,如果都滿足,進行搶佔式調度。

arch/arm64/kernel/entry.S

el1_irq
->  671 #ifdef CONFIG_PREEMPTION                                                                   
  672         ldr     x24, [tsk, #TSK_TI_PREEMPT]     // get preempt count                       
  673 alternative_if ARM64_HAS_IRQ_PRIO_MASKING                                                  
  674         /*                                                                                 
  675         ¦* DA_F were cleared at start of handling. If anything is set in DAIF,             
  676         ¦* we come back from an NMI, so skip preemption                                    
  677         ¦*/                                                                                
  678         mrs     x0, daif                                                                   
  679         orr     x24, x24, x0                                                               
  680 alternative_else_nop_endif                                                                 
  681         cbnz    x24, 1f                         // preempt count != 0 || NMI return path   
  682         bl      arm64_preempt_schedule_irq      // irq en/disable is done inside           
  683 1:                                                                                         
  684 #endif

當發生中斷時,會執行 el1_irq 來處理中斷,

672 行  來讀取當前任務的 thread_info.preempt_count 681 行 判斷 thread_info.preempt_count 是否爲 0,如果爲 0 則調用 682  行的 arm64_preempt_schedule_irq  進行搶佔式調度(上一節已經分析過)。

下面看下搶佔式調度:

arm64_preempt_schedule_irq
->preempt_schedule_irq
 ->__schedule(true)  //調用主調度器進行搶佔式調度

2)打開搶佔的時候

開啓搶佔:

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

釋放自旋鎖:

spin_unlock
->raw_spin_unlock
 ->__raw_spin_unlock
  ->preempt_enable  //如上

3) 開啓軟中斷

local_bh_enable
->__local_bh_enable_ip
 ->preempt_check_resched
  ->if (should_resched(0))     
         __preempt_schedule();
         ->preempt_schedule
    -> __schedule(true)  //調用主調度器進行搶佔式調度

其實,無論是主動進行調度還是搶佔式調度都會調用__schedule,而__schedule 是屬於關搶佔上下文,在調度期間不允許被搶佔。

  1. 不可搶佔內核的低延遲處理 ===============

下面我們來看下在沒有開啓內核搶佔的內核中如何處理低延遲:

我們會看到在一些比較耗時的處理中如文件系統和內存回收的一些路徑會調用 cond_resched,它是幹什麼用呢:

下面是使用這個宏的例子:在內存回收路徑中,會從不活躍的 lru 鏈表尾部取出一些頁面回收隔離到 page_list 中,最終會調用到 shrink_page_list:

mm/vmscan.c
shrink_page_list
->
 1084         while (!list_empty(page_list)) {
 
 ...
 
 1091                 cond_resched();
 
 ... //回收處理
}

可以看到對於 page_list 中的每一個被隔離的候選回收頁,在處理之前都會調用到 cond_resched 來主動判斷是否需要重新調度。

下面我們來看下 cond_resched 這個宏實現:

include/linux/sched.h

1868 /*
1869  * cond_resched() and cond_resched_lock(): latency reduction via
1870  * explicit rescheduling in places that are safe. The return
1871  * value indicates whether a reschedule was done in fact.
1872  * cond_resched_lock() will drop the spinlock before scheduling,
1873  */
1874 #ifndef CONFIG_PREEMPTION
1875 extern int _cond_resched(void);
1876 #else
1877 static inline int _cond_resched(void) { return 0; }
1878 #endif
1879 
1880 #define cond_resched() ({                       \
1881         ___might_sleep(__FILE__, __LINE__, 0);  \
1882          _cond_resched();                    \
1883 })

我們可以很清楚的看到,搶佔式內核中(CONFIG_PREEMPTION=y)cond_resched 宏的_cond_resched 爲空,並沒有主動判斷重新調度的功能,只有非搶佔式內核纔會調用_cond_resched 來執行主動檢查可搶佔性。

下面我們來看下_cond_resched:

6671 #ifndef CONFIG_PREEMPTION
6672 int __sched _cond_resched(void)
6673 {
6674         if (should_resched(0)) {   //判斷搶佔計數器是否爲0
6675                 preempt_schedule_common();  //進行搶佔式調度
6676                 return 1;
6677         }
6678         rcu_all_qs();
6679         return 0;
6680 }
6681 EXPORT_SYMBOL(_cond_resched);
6682 #endif

會主動檢查搶佔計數器是否爲 0(實際上搶佔計數器是否爲 0 且當前任務被設置了重新調度標誌),則進行搶佔式調度。

實際上,對於非搶佔式內核來說,在內核的很多地方,特別是文件系統操作和內存管理相關的一些耗時路徑中,都已經被內核開發者識別出來,並使用 cond_resched 來減小延遲(感興趣的小夥伴可以通過 grep 和 wc -l 命令來查看一下)。

  1. 自願內核搶佔 =========

內核搶佔模型有一種叫做自願內核搶佔模型(CONFIG_PREEMPT_VOLUNTARY=y),可以使得內核開發者在進行耗時操作的時候,主動檢查是否需要發生搶佔式調度,這個和上一節差不多。

config PREEMPT_VOLUNTARY
        bool "Voluntary Kernel Preemption (Desktop)"
        depends on !ARCH_NO_PREEMPT
        help
        ¦ This option reduces the latency of the kernel by adding more
        ¦ "explicit preemption points" to the kernel code. These new
        ¦ preemption points have been selected to reduce the maximum
        ¦ latency of rescheduling, providing faster application reactions,
        ¦ at the cost of slightly lower throughput.

        ¦ This allows reaction to interactive events by allowing a
        ¦ low priority process to voluntarily preempt itself even if it
        ¦ is in kernel mode executing a system call. This allows
        ¦ applications to run more 'smoothly' even when the system is
        ¦ under load.

        ¦ Select this if you are building a kernel for a desktop system.

使用 might_resched

83 #ifdef CONFIG_PREEMPT_VOLUNTARY
84 extern int _cond_resched(void);
85 # define might_resched() _cond_resched()
86 #else
87 # define might_resched() do { } while (0)
88 #endif

發現只有 CONFIG_PREEMPT_VOLUNTARY=y 時,might_resched 纔有效,否則爲空。

可以驚奇的發現,當搜索 might_resched 在內核中使用的使用的時候,並沒有看見有任何地方在使用,猜想是因爲大多數耗時的內核路徑,都已經使用 cond_resched 來進行檢查是否具備調度時機。

  1. 總結 =====

本文講解了內核搶佔的方方面面,非搶佔式內核主要用於服務器等對吞吐量要求較高的場景,而搶佔式內核主要用於嵌入式設備和桌面等對響應要求較高的場景。內核搶佔的調度時機主要從 check 點和搶佔點兩個角度去分析:check 點是在合適的時機(如時鐘中斷 tick 時或者任務喚醒的時候)判斷是否需要重新調度任務,如果需要設置重新調度標誌(need_resched),並沒有馬上進行調度,然後在最近的搶佔點發生調度;而搶佔點是真正調用主調度器發生調度的時機,一般會在中斷返回內核態或者重新開啓內核搶佔等情況下發生。最後,我們又分析了非搶佔式內核如何進行低延遲處理已經自願搶佔式內核如何實現自願式搶佔。

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