程式排程案例分析與常見疑惑1:為何不能排程?

rlk8888發表於2022-03-18

微信公眾號: 奔跑吧linux社群

本文節選自《奔跑吧linux核心》第二版卷1第9.3.5章


現在JD有半價搶購活動,如果您覺得本文還可以,千萬不要錯過喲!

1. 問題引入

假設Linux核心只有3個核心執行緒(見圖9.15),0號執行緒建立了核心執行緒1和核心執行緒2,它們永遠不會退出。當系統時鐘中斷到來時,時鐘中斷處理函式會檢查是否有程式需要排程。當有程式需要排程時,排程器會選擇執行執行緒1或者執行緒2。

假設0號執行緒先執行,那麼在這個場景下會發生什麼情況?
這是一個有意思的問題,涉及排程器的實現機制、中斷處理、核心搶佔、新建程式如何被排程、程式切換等知識點。我們只有把這些知識點都弄明白了,才能真正搞明白這個問題。

2.場景分析

這個場景中的主要操作步驟如下。
(1)start_kernel()執行在0號執行緒裡。0號執行緒建立了核心執行緒1和核心執行緒2。函式呼叫關係是start_kernel()→kernel_thread()→_do_fork()。在_do_fork()函式會建立新執行緒,並且把新執行緒新增到排程器的就緒佇列中。0號執行緒建立核心執行緒1和核心執行緒2後,進入while死迴圈,0號執行緒不會退出,它正在等待被排程出去。
(2)產生時鐘中斷。處理器採用時鐘定時器來週期性地提供系統脈搏。時鐘中斷是普通外設中斷的一種。排程器利用時鐘中斷來定時檢測當前正在執行的執行緒是否需要排程。
(3)當時鍾中斷檢測到當前執行緒需要排程時,設定need_resched標誌位。
(4)當時鍾中斷返回時,根據Linux核心是否支援核心搶佔來確定是否需要排程,下面分兩種情況來討論。
 支援核心搶佔的核心:發生在核心態的中斷返回時,檢查當前執行緒的need_resched標誌位是否置位,如果置位,說明當前執行緒需要排程。
 不支援核心搶佔的核心:發生在核心態的中斷在中斷返回時不會檢查是否需要排程。

不支援核心搶佔的核心

在不支援核心搶佔功能的Linux核心(見圖9.16)裡,即使0號執行緒的need_resched標誌位置位了,Linux核心也不會排程核心執行緒1或者核心執行緒2來執行。只有發生在使用者態的中斷返回或者系統呼叫返回使用者空間時,才會檢查是否需要排程。處理流程如下所示。

(1)發生時鐘中斷。觸發時鐘中斷時當前程式(執行緒)有可能在使用者態執行,也可能在核心態執行。如果程式執行在使用者態時發生了中斷,那麼會進入異常向量表的el0_irq彙編函式;如果程式執行在核心態時發生了中斷,那麼會進入異常向量表的el1_irq彙編函式中。在本場景中,因為3個執行緒都是核心執行緒,所以時鐘中斷只能跳轉到el1_irq彙編函式裡。當進入中斷時,CPU會自動關閉中斷。
(2)在el1_irq彙編函式裡,首先會儲存中斷現場(也稱為中斷上下文)到當前程式的棧中,Linux核心使用pt_regs資料結構來實現一個棧框,用來儲存中斷現場(本節稱為pt_regs棧幀)。
(3)中斷處理過程包括切換到Linux核心的中斷棧、硬體中斷號的查詢、中斷服務程式的處理等,詳細分析可以參考本書卷2的2.4節以及2.5節。
(4)當確定當前中斷源是時鐘中斷後,scheduler_tick()函式會取檢查當前程式的是否需要排程。如果需要排程,則設定當前程式的need_resched標誌位(thread_info中的TIF_NEED_ RESCHED標誌位),詳細分析請參考8.1.7節。
(5)中斷返回。這裡需要給中斷控制器返回一箇中斷結束(End Of Interrupt, EOI)訊號。
(6)在el1_irq彙編函式直接恢復中斷現場,這裡會使用0號執行緒的pt_regs棧框來恢復中斷現場。在不支援核心搶佔的系統裡,el1_irq彙編函式不會檢查是否需要排程。在中斷返回時,CPU開啟中斷,然後從中斷的地方開始繼續執行0號程式。

支援核心搶佔的核心

在支援核心搶佔功能的Linux核心中,中斷返回時會檢查當前程式是否設定了need_resched標誌位置位。如果置位,那麼呼叫preempt_schedule_irq()函式以排程其他程式(執行緒)並執行。如圖9.17所示,在支援核心搶佔的Linux核心中,中斷與排程的流程和圖9.16略有不一樣。在el1_irq彙編函式即將返回中斷現場時,判斷當前程式是否需要排程。如果需要排程,排程器會選擇下一個程式,並且進行程式的切換。如果選擇了核心執行緒1,則從核心執行緒1的pt_regs棧框中恢復中斷現場並開啟中斷,然後繼續執行核心執行緒1的程式碼。

3.如何讓新程式執行

可能讀者對圖9.17會有如下疑問:

  1. 如果核心執行緒1是新建立的程式,它的棧應該是空的,那它第一次執行時如何恢復中斷現場呢?

  2. 如果不能從核心執行緒1的棧中恢復中斷現場,那是不是核心執行緒1一直在關閉中斷的狀態下執行?
    對於核心執行緒來說,在建立時會對如下兩部分內容進行設定與儲存。

  3. 程式的硬體上下文。它是儲存在程式中的cpu_context資料結構,程式硬體上下文包括X19~X28暫存器、FP暫存器、SP暫存器以及PC暫存器,詳見8.1.6節。對於ARM64處理器來說,設定Pc暫存器為ret_from_fork,即指向ret_from_fork彙編函式。設定SP暫存器指向棧的pt_regs棧框。

  4. pt_regs棧框。

上述記憶體的設定與儲存是在copy_thread()函式裡實現的。

<arch/arm64/kernel/process.c>


int  copy_thread ( )
{
      …
childregs->pstate = PSR_MODE_EL1h;
    p->thread.cpu_context.x19 = stack_start;
 p->thread.cpu_context.x20 = stk_sz;
 p->thread.cpu_context.pc = ( unsigned  long)ret_from_fork;
 p->thread.cpu_context.sp = ( unsigned  long)childregs;
      …
}

stack_start指向核心執行緒的回撥函式,而x20指向回撥函式的引數。
在程式切換時,switch_to()函式會完成程式硬體上下文的切換,即把下一個程式(next程式)的cpu_context資料結構儲存的內容恢復到處理器的暫存器中,從而完成程式的切換。此時,處理器開始執行next程式了。根據PC暫存器的值,處理器會從ret_from_fork彙編函式裡開始執行,新程式的執行過程如圖9.18所示。

ret_from_fork彙編函式實現在arch/arm64/kernel/entry.S檔案中。


1 ENTRY(ret_from_fork)

2     bl  schedule_tail
3     cbz x19,  1f        // 不是一個核心執行緒
4     mov x0, x20
5     blr x19
6  1:  get_thread_info tsk
7     b   ret_to_user

在第2行中,呼叫schedule_tail()函式來對prev程式做收尾工作。在finish_lock_switch()函式裡會呼叫raw_spin_unlock_irq()函式來開啟本地中斷。因此,next程式是執行在開啟中斷的環境下的。
在第3行中,判斷next執行緒是否為核心執行緒。如果next程式是核心執行緒,在建立時會設定X19暫存器指向stack_start。如果X19的值暫存器為0,說明這個next程式是使用者程式,直接跳轉到第6行,呼叫ret_to_user彙編函式,返回使用者空間。
在第4~5行中,如果next程式是核心執行緒,那麼直接跳轉到核心執行緒的回撥函式裡。
綜上所述,當處理器切換到核心執行緒1時,它從ret_from_fork彙編函式開始執行,schedule_tail()函式會開啟中斷,因此,不用擔心核心執行緒1在關閉中斷的狀態下執行。另外,此時的核心執行緒1不會從中斷現場返回,因為到目前為止,核心執行緒1還沒有觸發任何一箇中斷。那麼,對於0號執行緒觸發的中斷現場怎麼辦呢?中斷現場是儲存在中斷程式的棧裡,只有當排程器再一次排程該程式時,它才會從棧中恢復中斷現場,然後繼續執行該程式。

4.排程的本質

下面是一個常見的思考題。

raw_local_irq_disable() 
//關閉本地中斷


schedule()   //呼叫schedule()函式來切換程式

raw_local_irq_enable()   //開啟本地中斷

有讀者這麼認為,假設程式A在關閉本地中斷的情況下切換到程式B來執行,程式B會在關閉中斷的情況下執行,如果程式B一直佔用CPU,那麼系統會一直沒有辦法響應時鐘中斷,系統就處於癱瘓狀態。
顯然,上述分析是不正確的。因為程式B切換執行時會開啟本地中斷,以防止系統癱瘓。我們接下來詳細分析這個問題。
排程與中斷密不可分,而排程的本質是選擇下一個程式來執行。理解排程有如下幾個關鍵點。

  1. 排程的時機,即什麼情況下會觸發排程。

  2. 如何合理和高效選擇下一個程式?

  3. 如何切換到下一個程式來執行?

  4. 下一個程式如何返回上一次暫停的地方?

我們以一個場景為例,假設系統中只有一個使用者程式A和一個核心執行緒B,在不考慮自願排程和系統呼叫的情況下,請描述這兩個程式(執行緒)是如何相互切換並執行的。
如圖9.19所示,使用者程式A切換到核心執行緒B的過程如下。
(1)假設在T0時刻之前,使用者程式A正在使用者空間執行。
(2)在T0時刻,時鐘中斷髮生。
(3)CPU打斷正在執行的使用者程式A,處於異常模式。CPU會跳轉到異常向量表中的el0_irq裡。在el0_irq彙編函式裡,首先把中斷現場儲存到程式A的pt_regs棧框中。
(4)處理中斷。
(5)排程滴答處理函式。在排程滴答處理中,檢查當前程式是否需要排程。如果需要排程,則設定當前程式的need_resched標誌位(thread_info中的TIF_NEED_RESCHED標誌位)。
(6)中斷處理完成之後,返回el0_irq彙編函式裡。在即將返回中斷現場前,ret_to_user彙編函式會檢查當前程式是否需要排程。
(7)若當前程式序需要排程,則呼叫schedule()函式來選擇下一個程式並進行程式切換。
(8)在switch_to()函式裡進行程式切換。
(9)T1時刻,switch_to()函式返回時,CPU開始執行核心執行緒B了。
(10)CPU沿著核心執行緒B儲存的棧幀回溯,一直返回。返回路徑為finish_task_switch() →el1_preempt()→el1_irq。
(11)在el1_irq彙編函式裡把上一次發生中斷時儲存在棧裡的中斷現場進行恢復,最後從上一次中斷的地方開始執行核心執行緒B的程式碼。

從棧幀的角度來觀察,程式排程的棧幀變化情況如圖9.20所示。

首先,對於使用者程式A,從中斷觸發到程式切換這段時間內,核心棧的變化情況如圖9.20左邊檢視所示,棧的最高地址位於pt_regs棧框,用來儲存中斷現場。
然後,依次儲存el0_irq彙編函式、ret_to_user彙編函式、_schedule()函式、context_switch()函式以及switch_to()函式的棧幀,此時SP暫存器指向switch_to()函式棧幀,這個過程稱為壓棧。
接下來,切換程式。
switch_to()函式返回之後,即完成了程式切換。此時,CPU的SP暫存器指向了核心執行緒B的核心棧中的switch_to()函式棧幀。CPU沿著棧幀一直返回,並且恢復了上一次儲存在pt_regs棧框的中斷現場,最後跳轉到核心執行緒B中斷的地方並開始執行,這個過程稱為出棧。
綜上所述,上述過程有幾個比較難理解的地方。

  1. 剛切換到CPU執行的程式(next程式),它需要沿著上一次排程時保留在棧中的蹤跡一直返回,並且從棧中恢復上一次的中斷現場。我們假設只考慮中斷導致的排程,對於主動發生排程的情況以及系統呼叫返回時發生排程的情況,留給讀者思考。

  2. next程式需要為剛排程出去的程式(prev程式)做一些收尾工作,比如,呼叫raw_spin_unlock_irq()來釋放鎖並開啟本地中斷,見finish_task_switch()函式。

  3. switch_to()函式是程式切換的場所,對於系統中所有的程式,不管是執行在使用者態的使用者程式,還是執行在核心態的核心執行緒,都必須在switch_to()函式裡進行程式切換。對於使用者程式來說,它必須藉助中斷或者系統呼叫陷入核心,才能有機會從switch_to()函式裡把自己排程出去,這個過程必然會在棧中留下蹤跡。當使用者程式需要重新排程執行時,它也必須根據幀棧的回溯返回使用者態,才能繼續執行程式本身的程式碼。

  4. 以時鐘中斷驅動的程式切換涉及兩種上下文(一個是中斷上下文,一個是程式上下文)的儲存和恢復。中斷上下文儲存在中斷程式的棧(即pt_regs棧框)中。程式上下文儲存在程式的task_struct資料結構裡。


    最後留給讀者一個有意思的思考題:在中斷處理函式中能不能呼叫schedule()函式? 有興趣的讀者可以參考本書卷2的2.5.3節


新書預告

奔跑吧linux內 核》 第二版卷1已經上架了。現在JD上有半價搶購活動喲!千萬不要錯過了,全球首本最有深度和廣度的Linux 5.x核心分析書籍,融入笨叔十多年的工作經驗。點選“閱讀原文”進入JD購買。

《奔跑吧linux核心》第二版卷2上架!


金色年華,流金歲月,奔二入門篇上架!

圖片


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70005277/viewspace-2872514/,如需轉載,請註明出處,否則將追究法律責任。

相關文章