1. 程式人生 > >Linux核心搶佔機制

Linux核心搶佔機制

本文主要圍繞 Linux 核心排程器 Preemption 的相關實現進行討論。其中涉及的一般作業系統和 x86 處理器和硬體概念,可能也適用於其它作業系統。

1. Scheduler Overview

Linux 排程器的實現實際上主要做了兩部分事情,

  1. 任務上下文切換

    Preemption Overview 裡,我們對任務上下文切換做了簡單介紹。可以看到,任務上下文切換有兩個層次的實現:公共層處理器架構相關層。任務執行狀態的切換的實現最終與處理器架構密切關聯。因此 Linux 做了很好的抽象。在不同的處理器架構上,處理器架構相關的程式碼和公共層的程式碼相互配合共同實現了任務上下文切換的功能。這也使得任務上下文切換程式碼可以很容易的移植到不同的處理器架構上。

  2. 任務排程策略

    同樣的,為了滿足不同型別應用場景的排程需求,Linux 排程器也做了模組化處理。排程策略的程式碼也可被定義兩層 Scheduler Core (排程核心)Scheduling Class (排程類)。排程核心的程式碼實現了排程器任務排程的基本操作,所有具體的排程策略都被封裝在具體排程類的實現中。這樣,Linux 核心的排程策略就支援了模組化的擴充套件能力。Linux v3.19 支援以下排程類和排程策略,

    • Real Time (實時)排程類 - 支援 SCHED_FIFO 和 SCHED_RR 排程策略。
    • CFS (完全公平)排程類 - 支援 SCHED_OTHER(SCHED_NORMAL),SCHED_BATCH 和 SCHED_IDLE 排程策略。(注:SCHED_IDLE 是一種排程策略,與 CPU IDLE 程序無關)。
    • Deadline (最後期限)排程類 - 支援 SCHED_DEADLINE 排程策略。

    Linux 排程策略設定的系統呼叫 SCHED_SETATTR(2) 的手冊有對核心支援的各種排程策略的詳細說明。核心的排程類和 sched_setattr 支援的排程策略命名上不一致但是存在對應關係,而且排程策略的命名更一般化。這樣做的一個好處是,同一種排程策略未來可能有不同的核心排程演算法來實現。新的排程演算法必然引入新的排程類。核心引入新排程類的時候,使用這個系統呼叫的應用不需要去為之修改。排程策略本身也是 POSIX 結構規範的一部分。上述排程策略中,SCHED_DEADLINE 是 Linux 獨有的,POSIX 規範中並無此排程策略。

    SCHED(7) 對 Linux 排程 API 和歷史發展提供了概覽,值得參考。

1.1 Scheduler Core

排程器核心程式碼位於 kernel/sched/core.c 檔案。主要包含了以下實現,

  • 排程器的初始化,排程域初始化。
  • 核心排程函式 __schedule 及上下文切換的通用層程式碼。
  • 時鐘週期處理的通用層程式碼,包含 Tick Preemption 的程式碼。
  • 喚醒函式,Per-CPU Run Queue 操作的程式碼,包含 Wakeup Preemption 通用層的程式碼。
  • 基於高精度定時器中斷實現的高精度排程,處理器間排程中斷。
  • 處理器 IDLE 執行緒,排程負載均衡,遷移任務的程式碼。
  • 與排程器有關的系統呼叫的實現程式碼。

排程器核心程式碼的主要作用就是排程器的模組化實現,降低了跨處理器平臺移植和實現新排程演算法模組的重複程式碼和模組間的耦合度,提高了核心可移植性和可擴充套件性。

1.2 Scheduling Class

在 Linux 核心引入一種新排程演算法,基本上就是實現一個新的 Scheduling Class (排程類)。排程類需要實現的所有藉口定義在 struct sched_class 裡。下面對其中最重要的一些排程類介面做簡單的介紹,

  • enqueue_task

    將待執行的任務插入到 Per-CPU Run Queue。典型的場景就是核心裡的喚醒函式,將被喚醒的任務插入 Run Queue 然後設定任務執行態為 TASK_RUNNING

    對 CFS 排程器來說,則是將任務插入紅黑樹,給 nr_running 增加計數。

  • dequeue_task

    將非執行態任務移除出 Per-CPU Run Queue。典型的場景就是任務排程引起阻塞的核心函式,把任務執行態設定成 TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE,然後呼叫 schedule 函式,最終觸發本操作。

    對 CFS 排程器來說,則是將不在處於執行態的任務從紅黑樹中移除,給 nr_running 減少計數。

  • yield_task

    處於執行態的任務申請主動讓出 CPU。典型的場景就是處於執行態的應用呼叫 sched_yield(2) 系統呼叫,直接讓出 CPU。此時系統呼叫 sched_yield 系統呼叫先呼叫 yield_task 申請讓出 CPU,然後呼叫 schedule 去做上下文切換。

    對 CFS 排程器來說,如果 nr_running 是 1,則直接返回,最終 schedule 函式也不產生上下文切換。否則,任務被標記為 skip 狀態。排程器在紅黑樹上選擇待執行任務時肯定會跳過該任務。之後,因為 schedule 函式被呼叫,pick_next_task 最終會被呼叫。其程式碼會從紅黑樹中最左側選擇一個任務,然後把要放棄執行的任務放回紅黑樹,然後呼叫上下文切換函式做任務上下文切換。

  • check_preempt_curr

    用於在待執行任務插入 Run Queue 後,檢查是否應該 Preempt 正在 CPU 執行的當前任務。Wakeup Preemption 的實現邏輯主要在這裡。

    對 CFS 排程器而言,主要是在是否能滿足排程時延和是否能保證足夠任務執行時間之間來取捨。CFS 排程器也提供了預定義的 Threshold 允許做 Wakeup Preemption 的調優。本文有專門章節對 Wakeup Preemption 做詳細分析。

  • pick_next_task

    選擇下一個最適合排程的任務,將其從 Run Queue 移除。並且如果前一個任務還保持在執行態,即沒有從 Run Queue 移除,則將當前的任務重新放回到 Run Queue。核心 schedule 函式利用它來完成排程時任務的選擇。

    對 CFS 排程器而言,大多數情況下,下一個排程任務是從紅黑樹的最左側節點選擇並移除。如果前一個任務是其它排程類,則呼叫該排程類的 put_prev_task 方法將前一個任務做正確的安置處理。但如果前一個任務如果也屬於 CFS 排程類的話,為了效率,跳過排程類標準方法 put_prev_task,但核心邏輯仍舊是 put_prev_task_fair 的主要部分。關於 put_prev_task 的具體功能,請參考隨後的說明。

  • put_prev_task

    將前一個正在 CPU 上執行的任務做拿下 CPU 的處理。如果任務還在執行態則將任務放回 Run Queue,否則,根據排程類要求做簡單處理。此函式通常是 pick_next_task 的密切關聯操作,是 schedule 實現的關鍵部分。

    如果前一個任務屬於 CFS 排程類,則使用 CFS 排程類的具體實現 put_prev_task_fair。此時,如果任務還是 TASK_RUNNING 狀態,則被重新插入到紅黑樹的最右側。如果這個任務不是 TASK_RUNNING 狀態,則已經從紅黑樹移除過了,只需要修改 CFS 當前任務指標 cfs_rq->curr 即可。

  • select_task_rq

    為給定的任務選擇一個 Run Queue,返回 Run Queue 所屬的 CPU 號。典型的使用場景是喚醒,fork/exec 程序時,給程序選擇一個 Run Queue,這也給排程器一個 CPU 負載均衡的機會。

    對 CFS 排程器而言,主要是根據傳入的引數要求找到符合親和性要求的最空閒的 CPU 所屬的 Run Queue。

  • set_curr_task

    當任務改變自己的排程類或者任務組時,該函式被呼叫。使用者程序可以使用 sched_setscheduler系統呼叫,通過設定自己新的排程策略來修改自己的排程類。

    對 CFS 排程器而言,當任務把自己排程類從其它型別修改成 CFS 排程類,此時需要把該任務設定成正當前 CPU 正在執行的任務。例如把任務從紅黑樹上移除,設定 CFS 當前任務指標 cfs_rq->curr 和排程統計資料等。

  • task_tick

    這個函式通常在系統週期性 (Per-tick) 的時鐘中斷上下文呼叫,排程類可以把 Per-tick 處理的事務交給該方法執行。例如,排程器的統計資料更新,Tick Preemption 的實現邏輯主要在這裡。Tick Preemption 主要判斷是否當前執行任務需要 Preemption 來被強制剝奪執行。

    對 CFS 排程器而言,Tick Preemption 主要是在是否能滿足排程時延和是否能保證足夠任務執行時間之間來取捨。CFS 排程器也提供了預定義的 Threshold 允許做 Tick Preemption 的調優。需要進一步瞭解 Tick Preemption,請參考 2.1 章節。

Linux 核心的 CFS 排程演算法就是通過實現該排程類結構來實現其主要邏輯的,CFS 的程式碼主要集中在 kernel/sched/fair.c 原始檔。下面的 sched_class 結構初始化程式碼包含了本節介紹的所有方法在 CFS 排程器實現中的入口函式名稱,

    const struct sched_class fair_sched_class = {

                    [...snipped...]

            .enqueue_task           = enqueue_task_fair,
            .dequeue_task           = dequeue_task_fair,
            .yield_task             = yield_task_fair,

                    [...snipped...]

            .check_preempt_curr     = check_preempt_wakeup,

                    [...snipped...]

            .pick_next_task         = pick_next_task_fair,
            .put_prev_task      = put_prev_task_fair,

                    [...snipped...]

            .select_task_rq     = select_task_rq_fair,

                    [...snipped...]

            .set_curr_task          = set_curr_task_fair,

                    [...snipped...]

            .task_tick              = task_tick_fair,

                    [...snipped...]
    };

1.3 preempt_count

Linux 核心為支援 Kernel Preemption 而引入了 preempt_count 計數器。如果 preempt_count 為 0,就允許 Kernel Preemption,否則就不允許。核心函式 preempt_disablepreempt_enable 用來核心程式碼的臨界區動態關閉和開啟 Kernel Preemption。其主要原理就是要通過對這個計數器的加和減來實現關閉和開啟。

一般而言,開啟 Kernel Preemption 特性的核心,在儘可能的情況下,允許在核心態通過 Tick Preemption 和 Wakeup Preemption 去觸發和執行 Kernel Preemption。但在以下情形,Kernel Preemption 會有關閉和開啟操作,

  • 核心顯式呼叫 preempt_disable 關閉搶佔期間,
  • 進入中斷上下文時,preempt_count 計數器被加操作置為非零。退出中斷時開啟 Kernel Preemption。
  • 獲取各種核心鎖以後,preempt_disable 被間接呼叫。退出核心鎖會有 preempt_enable 操作。

早期 Linux 核心,preempt_count 是每個任務所屬的 struct thread_info 裡的一個成員,是 Per-thread 的。

而在 Linux 新核心,為了優化 Kernel Preemption 帶來的頻繁檢查 preempt_count 的開銷,Linus 和排程器的維護者決定對其做更多的優化。因此,Per-CPU preempt_count 的優化被整合到 3.13 版核心。所以,新核心的的 preempt_count 定義如下,

    DECLARE_PER_CPU(int, __preempt_count);

原始碼裡有對這個計數器不同位意義的詳細說明,

    /*
     * We put the hardirq and softirq counter into the preemption
     * counter. The bitmask has the following meaning:
     *
     * - bits 0-7 are the preemption count (max preemption depth: 256)
     * - bits 8-15 are the softirq count (max # of softirqs: 256)
     *
     * The hardirq count could in theory be the same as the number of
     * interrupts in the system, but we run all interrupt handlers with
     * interrupts disabled, so we cannot have nesting interrupts. 
     * Though there are a few palaeontologic drivers which reenable
     * interrupts in the handler, so we need more than one bit here.
     *
     * PREEMPT_MASK:        0x000000ff
     * SOFTIRQ_MASK:        0x0000ff00
     * HARDIRQ_MASK:        0x000f0000
     *     NMI_MASK:        0x00100000
     * PREEMPT_ACTIVE:      0x00200000
     */

這裡要特別注意的是,如上面的註釋中所說,preempt_count 的設計時允許巢狀的。例如,

  • 核心的鎖原語都有 preempt_disablepreempt_enable,而鎖是可以巢狀的。
  • 核心在拿鎖的同時,被中斷打斷,鎖和中斷進入的程式碼路徑裡也會有 preempt_count 操作。

在有巢狀呼叫的情況下,呼叫 preempt_enablepreempt_count 也不會立刻減成零。
sched: likely profiling 這個布丁的最後一個 unlikelylikely 的優化很好的說明了一點:

    在核心裡,`preempt_enable` 在 `preempt_disable` 和中斷關閉情形下的呼叫比例更高。

另外要特別注意 PREEMPT_ACTIVE 位的用法,

  • 在核心開啟 Kernel Preemption 的時候,PREEMPT_ACTIVE 用來指示 __schedule 函式正確處理 Kernel Preemption 語義,防止被打斷的即將睡眠的任務被從 Run Queue 誤刪。
  • 在核心關閉 Kernel Preemption 時,雖然只有 User Preemption,cond_resched 還是利用這個標誌來判斷核心排程器是否初始化完成。

以上兩點在後續的 User Preemption 和 Kernel Preemption 的相關章節會展開介紹。

2. 觸發搶佔

2.1 Tick Preemption

Preemption Overview 裡對時鐘中斷和 Tick Preemption 都有簡單的介紹。本節主要關注 Tick Preemption 在 Linux v3.19 裡的實現。

Tick Preemption 的主要邏輯都實現在排程類的 task_tick 方法裡,排程核心程式碼裡並不做處理。

如前所述,Tick Preemption 主要在時鐘中斷上下文處理。從時鐘中斷處理函式到 CFS 排程類的 task_tick 方法,中間要經歷四個層次的處理。

2.1.1 處理器相關的時鐘中斷處理

以 x86 為例,時鐘中斷處理是 Per-CPU 的 Local APIC 定時器中斷,中斷處理函式 apic_timer_interrupt 被初始化到中斷門 IDT 的 LOCAL_TIMER_VECTOR 上。當 CPU LAPIC 時鐘中斷髮生時,apic_timer_interrupt 中斷處理函式會一路呼叫到處理器無關的通用時鐘中斷處理函式 tick_handle_periodic

    apic_timer_interrupt->smp_apic_timer_interrupt->local_apic_timer_interrupt->tick_handle_periodic

進一步的細節請參考 arch/x86/kernel/entry_64.S 裡的 apic_timer_interrupt 彙編程式碼
特別注意 apicinterrupt 巨集展開後 apic_timer_interrupt 是如何呼叫 smp_apic_timer_interrupt 的彙編技巧。

2.1.2 處理器無關的時鐘中斷處理

函式 tick_handle_periodic 在時鐘中斷的處理器平臺無關層,經過一路呼叫,最終會進入到排程核心程式碼層的 scheduler_tick 函式,

    tick_handle_periodic->tick_periodic->update_process_times->scheduler_tick

核心時鐘中斷處理函式要做很多其它複雜的工作,例如,jiffies 和程序時間的維護,Per-CPU 的定時器的呼叫,RCU 的處理。進一步的細節請參考 kernel/time/tick-common.c 裡的 tick_handle_periodic 的實現

2.1.3 排程器核心層

函式 scheduler_tick 屬於排程核心層程式碼,通過呼叫當前任務排程類的 task_tick 方法,進入到具體排程類的入口函式。對 CFS 排程類而言,就是 task_tick_fair

    void scheduler_tick(void)
    {
            [...snipped...]

            curr->sched_class->task_tick(rq, curr, 0); /* CFS 排程類時,指向 task_tick_fair */

            [...snipped...]
    }

除了 Tick Preemption 處理,scheduler_tick 函式還做了排程時鐘維護和處理器的負載均衡等工作。進一步的細節請參考 kernel/sched/core.c 裡的 scheduler_tick 的實現

2.1.4 排程類層

如前所述,Linux 支援多種排程類,而且可以通過系統呼叫設定程序的排程類。不同調度類對 Tick Preemption 的支援可以是不同的,只需要實現 task_tick 的方法即可。本小節只關注 CFS 排程類的實現。

CFS 排程器的 task_tick_fair 會最終呼叫到 check_preempt_tick 來檢查是否需要 Tick Preemption,進而呼叫 resched_curr 申請 Preemption,

    task_tick_fair->entity_tick->check_preempt_tick->resched_curr

進入到 check_preempt_tick 之前,entity_tick 需要檢查本 CPU 所屬處於執行狀態的任務數是否大於 1,只有一個執行的任務則根本沒有必要觸發 Preemption。在 check_preempt_tick 內部,主要做以下幾件事情,

  1. 呼叫 sched_slice 根據 Run Queue 任務數和排程延遲,任務權重計算任務理想執行時間: ideal_runtime
  2. 計算當前 CPU 上執行任務的執行時間 delta_exec
    • 如果 delta_exec 已經超過了 ideal_runtime,則呼叫 resched_curr 觸發 Tick Preemption。
    • 如果 delta_exec 小於 CFS 排程器預設的最小排程粒度,則意味著當前任務執行時間太短,直接返回而不觸發 Tick Preemption。
  3. 計算當前任務和紅黑樹最左節點任務的虛擬執行時間 vruntime 的差值 delta
    • 如果 delta 小於 0,則意味著沒有比當前任務急需排程的任務。
    • 如果 delta 大於 ideal_runtime,則意味著紅黑樹裡有更需要排程的任務。例如,喚醒後沒能做 Wakeup Preemption 的任務可以通過 Tick Preemption 被今早排程。

2.2 Wakeup Preemption

Preemption Overview 所述,Wakeup Preemption 與 Linux 核心喚醒機制密切相關。從喚醒發生到 Wakeup Preemption,涉及到個重要的層次。

2.2.1 同步原語層

Linux 核心裡,很多同步原語都會觸發程序喚醒,典型的場景如下,

  • 鎖退出時,喚醒其它等待鎖的任務。

    例如,semaphore,mutex,futex 等機制退出時會呼叫 wake_up_processwake_up_state 等待該鎖的任務列表裡的第一個等待任務。

  • 等待佇列 (wait queue) 或者 completion 機制裡,喚醒其它等待在指定等待佇列 (wait queue) 或者 completion 上的一個或者多個其它任務。

    Linux 定義了 wake_up 即其各種變體 主動喚醒等待佇列上的任務。

而以上各種機制觸發的喚醒任務操作最終都會進入一個共同的入口點 try_to_wake_up

2.2.2 排程器核心層

作為排程核心層程式碼,try_to_wake_up 定義在 kernel/sched/core.c 原檔案裡
首先 try_to_wake_up 會使用 select_task_rq 方法為要被喚醒的任務選擇一個 Run Queue,返回目標 Run Queue 所屬的 CPU 號。然後,ttwu_queue 的程式碼會判斷這個選擇的 CPU 與執行 try_to_wake_up 任務的當前執行的 CPU 是否 共享快取 (即 LLC,最後一級 cache,x86 就是 L3 Cache)。在 Preemption Overview 的相關章節裡,針對共享快取的兩個不同情況都做了詳細的介紹。因此,這裡只給出相關的程式碼呼叫路徑,作為參考,

  • 共享快取

    這類喚醒是同步的,在呼叫喚醒任務當前的上下文完成。從 try_to_wake_up 到呼叫到觸發 Wakeup Preemption 檢查的程式碼路徑如下,

      try_to_wake_up->ttwu_queue->ttwu_do_activate->ttwu_activate->activate_task->enqueue_task->...
                                             |
                                             +->ttwu_do_wakeup->check_preempt_curr->...
    

    喚醒任務被呼叫具體排程類的 enqueue_task 方法插入到目標 CPU Run Queue 之後,再呼叫 check_preempt_curr 來檢查是否觸發 Wakeup Preemption。

  • 不共享快取

    這類喚醒是非同步的,呼叫喚醒任務當前的上文只是將待喚醒的任務加入到目標 CPU Run Queue 的專用喚醒佇列裡 (wake_list),然後給目標 CPU 觸發排程處理器間中斷 (IPI) 後,立即返回,

      try_to_wake_up->ttwu_queue->ttwu_queue_remote->smp_send_reschedule->...
    

    其中 smp_send_reschedule 函式是處理器相關的排程 IPI 觸發函式,在不同處理器架構實現時不同的,下個小節會簡單介紹。

    排程 IPI 觸發後,目標 CPU 會接收到該中斷,然後通過處理器相關的中斷處理函式調入到排程核心層的中斷處理函式 scheduler_ipi。在這個 scheduler_ipi 處理上下文中,任務通過 sched_ttwu_pending 呼叫 ttwu_activate 被插入目標 CPU Run Queue,然後最終的 Wakeup Preemption 檢查和觸發程式碼會被呼叫,

      scheduler_ipi->sched_ttwu_pending->ttwu_do_activate->ttwu_do_wakeup->check_preempt_curr->...
    

    總之,不共享快取的情況下,Linux 核心通過實現非同步的喚醒操作,將任務實際喚醒操作的下半部分移到被喚醒任務所在 Run Queue 的 CPU 上的 IPI 中斷處理上下文中執行。這樣做的好處主要是減少同步喚醒操作的 Run Queue 鎖競爭和快取方面的開銷。詳情請參考 sched: Move the second half of ttwu() to the remote cpu

此外,在 try_to_wake_up 函式一進入時,還有一個特殊情況的檢查:當被喚醒任務還在 Run Queue 上沒有被刪除時 (如睡眠途中),則程式碼走如下快速處理路徑,

    try_to_wake_up->ttwu_remote->ttwu_do_wakeup->check_preempt_curr->...

注意此時不需要有 Run queue 的選擇和插入操作,因此不需要呼叫 ttwu_do_activate,而是直接呼叫 ttwu_do_wakeup

函式 check_preempt_curr 是核心排程器的程式碼,主要的處理邏輯有三點,

  1. 檢查目標 Run Queue 所屬 CPU 上正在執行的任務和喚醒的任務是否同屬一個排程類

    • 如果是相同排程類,則呼叫具體排程類的 check_preempt_curr 方法來處理真正的 Wakeup Preemption。
    • 如果不是相同的排程類,如果被喚醒任務的排程類優先順序比當前 CPU 執行任務高,則直接呼叫 resched_curr 觸發 Wakeup Preemption 申請。否則,則直接退出,沒有搶佔資格
  2. 函式退出前檢查是否進入 check_preempt_curr 之前是否發生過佇列插入操作,並且是否 Wakeup Preemption 申請成功。

    如果兩個條件都滿足,就把 skip_clock_update 置 1,這樣接下來的 __schedule 呼叫裡,update_rq_clock 會被呼叫,但在這個函式裡會跳過整個函式的處理,這算是個小小的優化。因為在插入佇列操作時,同樣的 update_rq_clock 已經被呼叫過了。

下面是 check_preempt_curr 的程式碼,關鍵行有程式碼註釋,

    void check_preempt_curr(struct rq *rq, struct task_struct *p, int flags)
    {
            const struct sched_class *class;

            if (p->sched_class == rq->curr->sched_class) {
                    rq->curr->sched_class->check_preempt_curr(rq, p, flags); /* 具體排程類方法,CFS 的是 check_preempt_wakeup */
            } else {
                    for_each_class(class) { /* 排程類的優先順序就是連結串列的順序: DL > RT > CFS > IDLE */
                            if (class == rq->curr->sched_class)     /* 當前 CPU 執行任務先匹配,意味著新喚醒的排程類優先順序低 */
                                    break;
                            if (class == p->sched_class) { /* 新喚醒的任務先匹配到,說明當前 CPU 上的優先順序低 */
                                    resched_curr(rq); /* 觸發 Wakeup Preemption */
                                    break;
                            }
                    }
            }

            /*
             * A queue event has occurred, and we're going to schedule.  In
             * this case, we can save a useless back to back clock update.
             */
            if (task_on_rq_queued(rq->curr) && test_tsk_need_resched(rq->curr))     /* 滿足條件即可以跳過一次 update_rq_clock */
                    rq->skip_clock_update = 1;
    }

2.2.3 處理器相關的排程中斷處理

上一小節裡,介紹了被喚醒任務的目標執行 CPU 和執行 try_to_wake_up 的任務所在 CPU 不共享快取時,需要利用排程 IPI 執行非同步喚醒流程。整個過程中,主要分為以下兩個階段,

  • 前半部:觸發非同步喚醒的排程 IPI

    try_to_wake_up 一直呼叫到處理器相關實現 smp_send_reschedule

      try_to_wake_up->ttwu_queue->ttwu_queue_remote->smp_send_reschedule->...
    

    native_smp_send_reschedule 函式裡,呼叫 apic 的 send_IPI_mask 方法給指定 CPU 的中斷向量 RESCHEDULE_VECTOR 觸發中斷,

      apic->send_IPI_mask(cpumask_of(cpu), RESCHEDULE_VECTOR);
    
      physflat_send_IPI_mask->default_send_IPI_mask_sequence_phys->__default_send_IPI_dest_field->__default_send_IPI_dest_field
    
  • 後半部:處理排程 IPI 中斷

      reschedule_interrupt->smp_reschedule_interrupt->__smp_reschedule_interrupt->scheduler_ipi->...
    

    上小節中,已經介紹了 scheduler_ipi 的程式碼是通過呼叫 sched_ttwu_pending 來實現非同步喚醒的下半部操作,並觸發 Wakeup Preemption 的。

至此,通過本節和上節的描述,我們可以清楚的知道處理器相關的排程 IPI 處理程式碼是如何與排程器核心程式碼緊密合作,實現非同步喚醒並觸發 Wakeup Preemption 的。此外,排程 IPI 更重要的一個功能就是觸發真正的 User Preemption 和 Kernel Preemption,這部分在 Preemption Overview 已有相關說明,此處不再贅述。

2.2.4 排程類層

本節以 CFS 排程類為例,介紹 check_preempt_curr 在 CFS 排程類裡的實現。

如前所述,在排程核心層的 check_preempt_curr 函式如果發現被喚醒的任務和正在被喚醒任務目標 CPU 上執行的任務共同屬於一個排程類,則立即呼叫具體排程類的 check_preempt_curr 方法。具體觸發 Wakeup Preemption 的程式碼路徑如下,

    check_preempt_curr->check_preempt_wakeup->resched_curr

如上所示,CFS 排程類裡,該方法的具體實現為 check_preempt_wakeup。這個函式主要做以下工作,

  1. 如果被喚醒的任務已經被目標 CPU 排程執行,立即返回。
  2. 如果喚醒的任務處於被 throttled 節流狀態 (CFS 頻寬控制),就不做搶佔。因為 throttled 的任務已經睡眠。
  3. 如果 NEXT_BUDDY 特性被開啟,則呼叫 set_next_buddy 標記任務。該任務會在下次排程呼叫 pick_next_entity 被優先選擇
  4. 如果 TIF_NEED_RESCHED 已經被置位,則已經申請 Preemption 成功,退出。
  5. 如果當前正在執行的 CFS 排程類任務的排程策略是 SCHED_IDLE,而當前被喚醒任務不是這個排程策略,則肯定當前任務有更高優先順序,可以觸發 Preemption。
  6. 如果被喚醒的 CFS 排程類任務的排程策略是 SCHED_BATCH 或 SCHED_IDLE,或者 Wakeup Preemption 特性沒有開啟,則退出。
  7. 呼叫 wakeup_preempt_entity 函式,判斷當前執行任務和被喚醒任務的 vruntime 的差值是否足夠大。
    • 如果被喚醒任務 vruntime 足夠落後,差值大於 sysctl_sched_wakeup_granularity 則 Wakeup Preemption 條件成立。
    • 如果被喚醒任務和當前執行任務 vruntime 的差值太小,則不滿足 Wakeup Preemption 條件。
    • NEXT_BUDDY 特性預設是關閉的,只能手動設定開啟。開啟後,當新喚醒任務做 Wakeup Preemption 失敗時,被設定為排程器偏愛。但是,Wakeup Preemption 成功的任務會覆蓋這個標記。
  8. 滿足 Wakeup Preemption 條件的情況下,呼叫 resched_curr 觸發搶佔。
    • 如果 LAST_BUDDY 特性被開啟,則呼叫 set_last_buddy 標記該任務,該任務會在下次排程呼叫 pick_next_entity 被優先選擇
    • 預設條件下,LAST_BUDDY 特性是開啟的,表示排程器偏愛排程上次 Wakeup Preemption 成功的任務。
  9. 值得注意的是,在 pick_next_entity 的優先選擇邏輯裡,還要利用 wakeup_preempt_entity 保證被標記為偏愛排程的任務和 CFS 紅黑樹最左側的任務之間 vruntime 的差值是足夠小的,否則不公平。

2.3 Reschedule Request

不論是 Tick Preemption 還是 Wakeup Preemption,一旦滿足搶佔條件,都會呼叫 resched_curr 來請求搶佔。這個函式的主要功能如下,

  1. 一進入函式,檢查是否當前要搶佔的 CPU 當前執行的任務已經有人標記了 TIF_NEED_RESCHED 標誌,如果有,就無需重複請求搶佔。
  2. 檢查目標搶佔的 CPU 是否是當前執行 resched_curr 的 CPU,如果是,則請求搶佔當前執行任務,然後直接返回。
    • 早期 Linux 核心,只需要呼叫 set_tsk_need_resched 給當前任務設定 struct thread_infoTIF_NEED_RESCHED 標誌。
    • 新 Linux 核心的實現,除了這一步,還需要呼叫 set_preempt_need_resched 給 Per-CPU preempt_count 設定 PREEMPT_NEED_RESCHED
  3. 如果目標搶佔的 CPU 和當前執行 resched_curr 的 CPU 不是同一個 CPU,則有以下兩種情況,
    • 如果目標 CPU 上正在執行的的任務不是正在輪詢 TIF_NEED_RESCHED 的 IDLE 執行緒,則觸發一個 cross-CPU call (INtel 叫 IPI) 給目標 CPU
    • 如果想反,目標 CPU 上 真該執行的任務是 IDLE 執行緒,則不需要 IPI,只需要實現特定的核心 Trace Point。
  4. 成功請求 Preemption 後,隨後的 scheduler IPI,Timer Interrupt,外設 Interrupt 都可以觸發真正的 User Preemption 或者 Kernel Preemption。
    • 早期 Linux 核心,scheduler_ipi 被實現為空函式,User or Kernel Preemption 的觸發程式碼應該在 scheduler IPI 退出中斷到使用者/核心空間來發展。
    • 新 Linux 核心,scheduler_ipi 的程式碼加入了喚醒任務的功能,被用於不共享快取的的請況下,任務喚醒的下半部,這樣可以減少 Run Queue 鎖競爭。

下面是相關的程式碼,

    /*
     * resched_curr - mark rq's current task 'to be rescheduled now'.
     *
     * On UP this means the setting of the need_resched flag, on SMP it
     * might also involve a cross-CPU call to trigger the scheduler on
     * the target CPU.
     */
    void resched_curr(struct rq *rq)
    {
            struct task_struct *curr = rq->curr;
            int cpu;

            lockdep_assert_held(&rq->lock);

            if (test_tsk_need_resched(curr)) /* 是否已經有人申請搶佔 */
                    return;

            cpu = cpu_of(rq);

            if (cpu == smp_processor_id()) { /* 目標 CPU 與當前執行 CPU 相同 */
                    set_tsk_need_resched(curr); /* 標記 `TIF_NEED_RESCHED` */
                    set_preempt_need_resched(); /* 標記 `PREEMPT_NEED_RESCHED` */
                    return;
            }

            if (set_nr_and_not_polling(curr)) /* 是否是 IDLE 執行緒正在做輪詢 */
                    smp_send_reschedule(cpu); /* 在給定 CPU 上觸發 IPI,引起 scheduler_ipi 被執行, 間接觸發 Preemption. */
            else
                    trace_sched_wake_idle_without_ipi(cpu);
    }

3. 執行 Preemption

3.1 User Preemption

如前所述,User Preemption 主要發生在以下兩類場景,

  • 系統呼叫,中斷,異常時返回使用者空間時。

    此處的程式碼都是和處理器架構相關的,本文都以 x86 64 位 CPU 為例。

    1. 系統呼叫返回使用者空間的程式碼裡檢查 TIF_NEED_RESCHED 標誌,決定是否呼叫 schedule
    2. 不論是外設中斷還是 CPU 的 APIC 中斷,都會在
      entry_64.S 裡的中斷公共程式碼裡的返回使用者空間路徑上檢查 TIF_NEED_RESCHED 標誌,決定是否呼叫 schedule
    3. 異常返回使用者空間的程式碼實際上與中斷返回的程式碼共享相同的程式碼,retint_careful
  • 任務為 TASK_RUNNING 狀態時,直接或間接地呼叫 schedule

    Linux 核心的 Kernel Preemption 沒有開啟的話,除了系統呼叫,中斷,異常返回使用者空間時發生 Preemption,使用 cond_resched 是推薦的方式來防止核心濫用 CPU。由於這些程式碼可以在只有 User Preemption 開啟的時候工作,因此本文將此類程式碼歸類為 User Preemption。

    3.13 之前的核心版本,cond_resched 在核心程式碼主動呼叫它時,先檢查 TIF_NEED_RESCHED 標誌和 preempt_countPREEMPT_ACTIVE 標誌,然後再決定是否呼叫 schedule。這裡檢查 PREEMPT_ACTIVE 標誌,只是為了阻止核心使用 cond_resched 的程式碼在排程器初始化完成前執行排程

    而 3.13 引入的 per_CPU 的 preempt_count patch,則將 TIF_NEED_RESCHED 標誌設定到 preempt_count 裡儲存,以便一條指令就可以完成原來的兩個條件判斷。因此,TIF_NEED_RESCHED 標誌檢查的程式碼變成了只檢查 preempt_count。需要注意的是,雖然 preempt_count 已經包含 TIF_NEED_RESCHED 標誌,但原有的 task_struct::state 的TIF_NEED_RESCHED 標誌仍舊在 User Preemption 程式碼裡發揮作用。

    這裡不再分析 yield 的實現。但需要注意的是,核心中的迴圈程式碼應該儘量使用 cond_resched 來讓出 CPU,而不是使用 yield詳見 yield 的註釋
    POSIX 規範裡規定了 sched_yield(2) 呼叫,一些實時排程類的應用可以使用 sched_yield 讓出 CPU。核心 API yield 使用了 sched_yield 的實現。與 cond_resched 最大的不同是,yield 會使用具體排程類的 yield_task 方法。不同調度類對 yield_task 可以有很大不同。例如,SCHED_DEADLINE 排程策略裡,yield_task 方法會讓任務睡眠,這時的 sched_yield 已經不再屬於 Preemption 的範疇。

3.1.1 schedule 對 User Preemption 的處理

User Preemption 的程式碼同樣是顯示地呼叫 schedule 函式,但與主動上下文切換中很大的不同是,呼叫 schedule 函式時,當前上下文任務的狀態還是 TASK_RUNNING。只要呼叫 schedule 時當前任務是 TASK_RUNNING,這時 schedule 的程式碼就把這次上下文切換算作強制上下文切換,並且這次上下文切換不會涉及到把被 Preempt 任務從 Run Queue 移除操作。

下面是 schedule 程式碼在 Linux 3.19 的實現,

    static void __sched __schedule(void)
    {
            struct task_struct *prev, *next;
            unsigned long *switch_count;
            struct rq *rq;
            int cpu;

            [...snipped...]

            raw_spin_lock_irq(&rq->lock);

            switch_count = &prev->nivcsw; /* 預設 switch_count 是強制上下文切換的 */
            if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) { /* User Preemption 是 TASK_RUNNING 且無 PREEMPT_ACTIVE 置位,所以下面程式碼不會執行 */
                    if (unlikely(signal_pending_state(prev->state, prev))) {
                            prev->state = TASK_RUNNING;     /* 可中斷睡眠有 Pending 訊號,只做上下文切換,無需從執行佇列移除 */
                    } else {
                            deactivate_task(rq, prev, DEQUEUE_SLEEP); /* 不是 TASK_RUNNING 且無 PREEMPT_ACTIVE 置位,需要從執行佇列移除 */
                            prev->on_rq = 0;


                    [...snipped...]

                    switch_count = &prev->nvcsw; /* 不是 TASK_RUNNING 且無 PREEMPT_ACTIVE 置位,
                                                 swtich_count 則指向主動上下文切換計數器 */
            }

            [...snipped...]

            next = pick_next_task(rq, prev);

            [...snipped...]

            if (likely(prev != next)) { /* Run Queue 上真有待排程的任務才做上下文切換 */
                    rq->nr_switches++;
                    rq->curr = next;
                    ++*switch_count; /* 此時確實發生了排程,要給 nivcsw 或者 nvcsw 計數器累加 */

                    rq = context_switch(rq, prev, next); /* unlocks the rq 真正上下文切換髮生 */
                    cpu = cpu_of(rq);
            } else
                    raw_spin_unlock_irq(&rq->lock);

從程式碼可以看出,User Preemption 觸發的上下文切換,都被算作了強制上下文切換

3.2 Kernel Preemption

核心搶佔需要開啟特定的 Kconfig (CONFIG_PREEMPT=y)。本文只介紹引起 Kernel Preemption 的關鍵程式碼。如前所述,Kernel Preemption 主要發生在以下兩類場景,

  • 中斷和異常時返回核心空間時。

    如前面章節介紹,系統呼叫返回不會發生 Kernel Preemption,但中斷和異常則會。
    中斷和異常返回核心空間的程式碼是共享同一段實現,呼叫 preempt_schedule_irq 來檢查 TIF_NEED_RESCHED 標誌,決定是否呼叫 schedule

  • 禁止搶佔上下文結束時。

    核心程式碼呼叫 preempt_enablepreempt_check_reschedpreempt_schedule 退出禁止搶佔的臨界區。下面主要針對這部分實現做詳細介紹。

Preemption Overview 所述,User Preemption 總是限定在任務處於 TASK_RUNNING 的幾個有限的固定時機發生。而 Kernel Preemption 發生時,任務的執行態是不可預料的,任務執行態可能處於任何執行狀態,如 TASK_UNINTERRUPTIBLE 狀態。

一個典型的例子就是,任務睡眠時要先將任務設定成睡眠態,然後再呼叫 schedule 來做真正的睡眠。

    set_current_state(TASK_UNINTERRUPTIBLE);
    /* 中斷在 schedule 之前發生,觸發 Kernel Preemption */
    schedule();

設定睡眠態和 schedule 呼叫之間並不是原子的操作,大多時候也沒有禁止搶佔和關中斷。這時 Kernel Preemption 如果正好發生在兩者之間,那麼就會造成我們所說的情況。上面的例子裡,中斷恰好在任務被設定成 TASK_UNINTERRUPTIBLE 之後發生。中斷退出後,preempt_schedule_irq 就會觸發 Kernel Preemption。

下面的例子裡,Kernel Preemption 可以發生在最後一個 spin_unlock 退出時,這時當前任務狀態是 TASK_UNINTERRUPTIBLE

    prepare_to_wait(wq, &wait.wait, TASK_UNINTERRUPTIBLE);
    spin_unlock(&inode->i_lock);
    spin_unlock(&inode_hash_lock); /* preempt_enable 在 spin_unlock 內部被呼叫 */
    schedule();

不論是中斷退出程式碼呼叫 preempt_schedule_irq, 還是 preempt_enable 呼叫 preempt_schedule,都會最在滿足條件時觸發 Kernel Preemption。下面以 preempt_enable 呼叫 preempt_schedule 為例,剖析核心程式碼實現。

3.2.1 preempt_disable 和 preempt_enable

在核心中需要禁止搶佔的臨界區程式碼,直接使用 preempt_disable 和 preempt_enable 即可達到目的。關於為何以及如何禁止搶佔,請參考 Proper Locking Under a Preemptible Kernel 這篇文件。

Preemption Overview 所述,preempt_disable 和 preempt_enable 函式也被嵌入到很多核心函式的實現裡,例如各種鎖的進入和退出函式。

preempt_enable 的程式碼為例,如果 preempt_count 為 0,則呼叫 __preempt_schedule
而該函式會最終呼叫 preempt_schedule 來嘗試核心搶佔。

    #define preempt_enable() \
    do { \
            barrier(); \
            if (unlikely(preempt_count_dec_and_test())) \
                    __preempt_schedule(); \  /* 最終會呼叫 preempt_schedule */
    } while (0)

3.2.2 preempt_schedule

preempt_schedule 函式內部,在呼叫 schedule 之前,做如下檢查,

  1. 檢查 preempt_count 是否非零和 IRQ 是否處於 disabled 狀態,如果是則不允許搶佔。

    做這個檢查是為防止搶佔的巢狀呼叫。例如,preempt_enable 可以在關中斷時被呼叫。總之,核心並不保證呼叫 preempt_enable 之前,總是可以被搶佔的。這是因為,preempt_enable 嵌入在很多核心函式裡,可以被巢狀間接呼叫。此外,搶佔正在進行時也能讓這種巢狀的搶佔呼叫不會再次觸發搶佔。

  2. 設定 preempt_countPREEMPT_ACTIVE,避免搶佔發生途中,再有核心搶佔。

  3. 被搶佔的程序再次返回排程點時,檢查 TIF_NEED_RESCHED 標誌,如果有新的核心 Preemption 申請,則再次觸發 Kernel Preemption。

    這一步驟是迴圈條件,直到當前 CPU 的 Run Queue 裡再也沒有申請 Preemption 的任務。

Linux v3.19 preempt_schedule 的程式碼如下,

    /*
     * this is the entry point to schedule() from in-kernel preemption
     * off of preempt_enable. Kernel preemptions off return from interrupt
     * occur there and call schedule directly.
     */
    asmlinkage __visible void __sched notrace preempt_schedule(void)
    {
            /*
             * If there is a non-zero preempt_count or interrupts are disabled,
             * we do not want to preempt the current task. Just return..
             */
            if (likely(!preemptible())) /* preempt_enable 可能在被關搶佔和關中斷後被巢狀呼叫 */
                    return;

            do {
                    __preempt_count_add(PREEMPT_ACTIVE); /* 呼叫 schedule 前,PREEMPT_ACTIVE 被設定 */
                    __schedule();
                    __preempt_count_sub(PREEMPT_ACTIVE); /* 結束一次搶佔,PREEMPT_ACTIVE 被清除 */

                    /*
                     * Check again in case we missed a preemption opportunity
                     * between schedule and now.
                     */
                    barrier();
            } while (need_resched());       /* 恢復執行時,檢查 TIF_NEED_RESCHED 標誌是否設定 */
    }

需要注意,schedule 呼叫前,PREEMPT_ACTIVE 標誌已經被設定好了。

3.2.3 schedule 對 Kernel Preemption 的處理

如前所述,進入函式呼叫前,PREEMPT_ACTIVE 標誌已經被設定。根據當前的任務的執行狀態,我們分別做出如下分析,

  1. 當前任務是 TASK_RUNNING

    任務不會被從其所屬 CPU 的 Run Queue 上移除。這時只發生上下文切換,當前任務被下一個任務取代後在 CPU 上執行。

  2. 當前任務是其它非執行態。

    繼續本節開始的例子,當前任務設定好 TASK_UNINTERRUPTIBLE 狀態,即將呼叫 schedule 之前被 spin_unlock 裡的 preempt_enable 呼叫 preempt_schedule

    由於是 Kernel Preemption 上下文,PREEMPT_ACTIVE 被設定,任務不會被從 CPU 所屬 Run Queue 移除而睡眠,這時只發生上下文切換,當前任務被下一個任務取代在 CPU 上執行。當 Run Queue 中已經處於 TASK_UNINTERRUPTIBLE 狀態的任務被排程到 CPU 上時,PREEMPT_ACTIVE 標誌早被清除,因此,該任務會被 deactivate_task 從 Run Queue 上刪除,進入到睡眠狀態。

    這樣的處理保證了 Kernel Preemption 的正確性,以及後續被 Preempt 任務再度被排程時的正確性,

    • Preemption 的本質是一種打斷引起的上下文切換,不應該處理任務的睡眠操作。

      當前被 Preempt 的任務從 Run Queue 移除去睡眠的工作,本來就應該由任務自己程式碼呼叫的 schedule 來完成。假如沒有 PREEMPT_ACTIVE 標誌的檢查,那麼當前被 Preempt 任務就在 preempt_schedule 呼叫 schedule 時提前被從 Run Queue 移除而睡眠。這樣一來,該任務原來程式碼的語義發生了變化,從任務角度看,Preemption 只是一種任務打斷,被 Preempt 任務的睡眠不應該由 preempt_schedule 的程式碼來做。

    • Run Queue 佇列移除操作給 Kernel Preemption 的程式碼路徑被增加了不必要的時延。

      不但如此,這個被 Preempt 任務再次被喚醒後,該任務還未執行的 schedule 呼叫還會被執行一次。

下面是 schedule 的程式碼,針對 Kernel Preemption 做了詳細註釋,

    static void __sched __schedule(void)
    {
            struct task_struct *prev, *next;
            unsigned long *switch_count;
            struct rq *rq;
            int cpu;

            [...snipped...]

            raw_spin_lock_irq(&rq->lock);

            switch_count = &prev->nivcsw; /* Kernel Preemption 使用強制上下文切換計數器 */
            if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) { /* 非 TASK_RUNNING 和非 Kernel Preemption 任務才從執行佇列移除 */
                    if (unlikely(signal_pending_state(prev->state, prev))) {
                            prev->state = TASK_RUNNING;     /* 可中斷睡眠有 Pending 訊號,只做上下文切換,無需從執行佇列移除 */
                    } else {
                            deactivate_task(rq, prev, DEQUEUE_SLEEP); /* 非 TASK_RUNNING,非 Kernel Preemption,需要從執行佇列移除 */
                            prev->on_rq = 0;


                    [...snipped...]

                            switch_count = &prev->nvcsw; /* 非 TASK_RUNNING 和非 Kernel Preemption 任務使用這個計數器 */
                    }
            }

4. 關聯閱讀