深入理解 Linux 內核之進程睡眠

1 開場白

環境:

無論是任務處於用戶態還是內核態,經常會因爲等待某些事件而睡眠(可能是等待 IO 讀寫完成,也可能等待其他內核路徑釋放一把鎖等)。本文來探討一下,任務處於睡眠中有哪些狀態?睡眠對於任務來說究竟意味着什麼?內核是如何管理睡眠的任務的?我們會結合內核源代碼來分析任務的睡眠,力求全方位角度來剖析。

注:由於篇幅問題,文章分爲上下兩篇,且這裏不區分進程和任務,統一使用任務來表示進程。

主要講解以下內容:

  1. 睡眠的三種狀態 ==========

任務睡眠有三種狀態:

**淺度睡眠 **

**中度睡眠 **

深度睡眠

2.1 淺度睡眠

進程描述符的 state 使用 TASK_INTERRUPTIBLE 表示這種狀態。

爲可中斷的睡眠狀態,這裏可中斷是可以被信號所打斷(喚醒)。

這裏給出被信號打斷 / 喚醒的代碼路徑:

kernel/signal.c
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
->kill_something_info
    ->__kill_pgrp_info
        ->group_send_sig_info
            ->do_send_sig_info
                ->send_signal
                    ->__send_signal  
                        ->complete_signal
                            ->signal_wake_up
                                 -> signal_wake_up_state(t, resume ? TASK_WAKEKILL : 0) 
                                    ->wake_up_state(t, state | TASK_INTERRUPTIBLE)
                                        ->try_to_wake_up

可以看到在信號傳遞的時候,會通過 signal_wake_up 喚醒從處於可中斷睡眠狀態的任務。

2.2 中度睡眠

進程描述符的 state 使用 TASK_KILLABLE 表示這種狀態。

可以被致命信號所打斷。

這裏給出被致命信號打斷 / 喚醒的代碼路徑:

include/linux/sched.h
#define TASK_KILLABLE                   (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)

kernel/signal.c
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
->kill_something_info
    ->__kill_pgrp_info
        ->group_send_sig_info
            ->do_send_sig_info
                ->send_signal
                    ->__send_signal  
                        ->complete_signal
                         ->
                                if (sig_fatal(p, sig) &&
                            ¦   !(signal->flags & SIGNAL_GROUP_EXIT) &&
                            ¦   !sigismember(&t->real_blocked, sig) &&
                            ¦   (sig == SIGKILL || !p->ptrace)) {  //致命信號
                            
                                    ...
                                    signal_wake_up(t, 1);
                                       -> signal_wake_up_state(t, resume ? TASK_WAKEKILL : 0)  // resume == 1
                                           -> wake_up_state(t, state | TASK_INTERRUPTIBLE)
                                                ->try_to_wake_up
                                    ...
                            }

2.3 深度睡眠

進程描述符的 state 使用 TASK_UNINTERRUPTIBLE 表示這種狀態。

爲不可中斷的睡眠狀態,不能被任何信號所喚醒 (特定條件沒有滿足發生信號喚醒可能導致數據不一致等問題,這種場景使用這種睡眠狀態,如等待 IO 讀寫完成)。

  1. 睡眠的內核原理 ==========

睡眠都是主動發生調度,即主動調用主調度器。

睡眠的主要步驟如下:

**1)設置任務狀態爲睡眠狀態 **

**2)記錄睡眠的任務 **

3)發起主動調度

下面我們來詳細解讀下這幾個步驟:

3.1 設置任務狀態爲睡眠狀態

這一步很有必要,一來標識進入了睡眠狀態,二來是主調度器會根據睡眠標誌將任務從運行隊列刪除。

注:睡眠狀態描述見上一小節!

3.2 記錄睡眠的任務

這一步也非常有必要,內核會將即將睡眠的任務記錄下來,要麼加入到鏈表中管理,要麼使用數據結構記錄。

如延遲睡眠場景,內核將即將睡眠的任務記錄在定時器相關的數據結構中;可睡眠的信號量場景中,內核將即將睡眠的任務加入到信號量的相關鏈表中。

記錄的目的在於:當喚醒條件滿足時,喚醒函數能夠找到想要喚醒的任務。

3.3 發起主動調度

這一步是真正進行睡眠的操作,主要是調用主調度器來發起主動調度讓出處理器。

下面我們來看下主調度器爲任務睡眠所作的處理:

kernel/sched/core.c

__schedule
->
    prev_state = prev->state;     //獲得前一個任務狀態
    if (!preempt && prev_state) {  //如果是主動調度   且任務狀態不爲0                         
            if (signal_pending_state(prev_state, prev)) {   //有掛起的信號
                    prev->state = TASK_RUNNING;       //設置狀態爲可運行      
            } else {                                        
                  deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK);  //cpu運行隊列中刪除任務
            }
    }
    
   next = pick_next_task(rq, prev, &rf);  //選擇下一個任務

   context_switch  //進行上下文切換

來看下 deactivate_task 對於睡眠任務做的主要工作:

deactivate_task
->deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK)
    ->p->on_rq = (flags & DEQUEUE_SLEEP) ? 0 : TASK_ON_RQ_MIGRATING;  //設置任務的on_rq 爲0  標識是睡眠
    dequeue_task(rq, p, flags);
    ->p->sched_class->dequeue_task(rq, p, flags)
        ->dequeue_task_fair
            ->dequeue_entity
            
                ...
                if (se != cfs_rq->curr)        //不是cpu當前 任務
                      __dequeue_entity(cfs_rq, se); //cfs運行隊列刪除

                ->se->on_rq = 0;  //標識調度實體不在運行隊列!!!
                
                ->if (!(flags & DEQUEUE_SLEEP))
                       se->vruntime -= cfs_rq->min_vruntime; //調度實體的虛擬運行時間 減去 cfs運行隊列的最小虛擬運行時間

deactivate_task 會設置任務的 on_rq 爲 0 來 標識是睡眠 ,然後 調用到調度類的 dequeue_task 方法,在 cfs 中設置 se->on_rq = 0 標識調度實體不在 cfs 隊列。

可以看到,發起主動調度的時候,在主調度器中會做判斷:如果是主動調度且任務狀態不爲 0 (即爲不是可運行的 TASK_RUNNING)時,如果沒有掛起的信號,就會將任務從 cpu 的運行隊列中 “刪除”,然後選擇下一個任務,進行上下文切換。

將即將睡眠的任務從 cpu 的運行隊列中 “刪除” 意義重大:主調度器再次選擇下一個任務的時候不會在選擇睡眠的任務(因爲主調度器總是在運行隊列中選擇任務運行,除非任務被喚醒,重新加入運行隊列)。

注意:1. 這裏的刪除指的是設置對應標誌如 p->on_rq=0,se->on_rq = 0,當選擇下一個任務的時候不會在加入運行隊列中。2. 即將睡眠的任務是 cpu 上的當前任務(curr 指向)。3. 調用主調度器後,即將睡眠的任務不會再次加入 cpu 運行隊列,除非被喚醒。

再來看下選擇下一個任務的時候會做哪些事情和睡眠有關 (暫不考慮組調度情況):

pick_next_task
->class->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
            ->  if (prev->on_rq) { //前一個任務的調度實體on_rq不爲0?
                update_stats_wait_start(cfs_rq, prev);
                /* Put 'current' back into the tree. */
                __enqueue_entity(cfs_rq, prev);   //重新加入cfs運行隊列
                /* in !on_rq case, update occurred at dequeue */
                update_load_avg(cfs_rq, prev, 0);
              }
           cfs_rq->curr = NULL; //設置cfs運行隊列的curr爲NULL

put_prev_task 所做的主要工作就是將前一個任務從 cfs 運行隊列中刪除,在這裏就是通過調用__enqueue_entity 將對應的調度實體重新加入 cfs 隊列的紅黑樹,但是對於即將睡眠的任務之前在主調度器中通過 deactivate_task 將 prev->on_rq 設置爲 0 了,所以對於即將睡眠的任務來說,它對應的調度實體不會在重新加入 cfs 運行隊列的紅黑樹

下面來看下睡眠圖示:

  1. 用戶態睡眠

以 sleep 爲例來說明任務在用戶態是如何睡眠的。

首先我們通過 strace 工具來看下其調用的系統調用:

$ strace sleep 1

...
close(3)                                = 0
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=1, tv_nsec=0}, NULL) = 0
close(1)                                = 0
...

可以發現 sleep 主要調用 clock_nanosleep 系統調用來進行睡眠(也就是說用戶態任務睡眠需要調用系統調用陷入內核)。

下面我們來研究下 clock_nanosleep 的實現(這裏集中到睡眠的實現,先忽略掉定時器等諸多的技術細節):

kernel/time/posix-timers.c

SYSCALL_DEFINE4(clock_nanosleep
->const struct k_clock *kc = clockid_to_kclock(which_clock);  //根據時鐘類型得到內核時鐘結構
    return kc->nsleep(which_clock, flags, &t); //調用內核時鐘結構的nsleep回調

我們傳遞過來的時鐘類型爲 CLOCK_REALTIME,則調用鏈爲:

kc->nsleep(CLOCK_REALTIME, flags, &t)
->clock_realtime.nsleep
    ->common_nsleep
        ->hrtimer_nanosleep  //kernel/time/hrtimer.c
            ->hrtimer_init_sleeper_on_stack
                    ->__hrtimer_init_sleeper
                        ->__hrtimer_init(&sl->timer, clock_id, mode); //初始化高精度定時器
                            sl->timer.function = hrtimer_wakeup;  //設置超時回調函數
                            sl->task = current;.//設置超時時要喚醒的任務
                     ->do_nanosleep  //睡眠操作

可以看到,睡眠函數最終調用到 hrtimer_nanosleep,它調用了兩個主要函數:__hrtimer_init_sleeper 和 do_nanosleep,前者主要設置高精度定時器,後者就是真正的睡眠,主要來看下 do_nanosleep:

 kernel/time/hrtimer.c
 do_nanosleep
 ->
         do {
                 set_current_state(TASK_INTERRUPTIBLE);  //設置可中斷的睡眠狀態
                 hrtimer_sleeper_start_expires(t, mode); //開啓高精度定時器

                 if (likely(t->task))
                         freezable_schedule(); //主動調度
                   

                 hrtimer_cancel(&t->timer);
                 mode = HRTIMER_MODE_ABS;

         } while (t->task && !signal_pending(current));  //是否記錄的有任務且沒有掛起的信號

         __set_current_state(TASK_RUNNING);  //設置爲可運行狀態

do_nanosleep 函數是睡眠的核心實現:首先設置任務的狀態爲可中斷的睡眠狀態,然後開啓了之前設置的高精度定時器,隨即調用 freezable_schedule 進行真正的睡眠。

來看下 freezable_schedule:

//include/linux/freezer.h
freezable_schedule
->schedule()
    ->__schedule(false);

可以看到最終調用主調度器__schedule 進行主動調度。

當任務睡眠完成,定時器超時,會調用之前在__hrtimer_init_sleeper 設置的超時回調函數 hrtimer_wakeup 將睡眠的任務喚醒(關於進程喚醒在這裏就不在贅述,在後面的進程喚醒專題文章在進行詳細解讀),然後就可以再次獲得處理器的使用權了。

總結:處於用戶態的任務,如果想要睡眠一段時間必須向內核請求服務(如調用 clock_nanosleep 系統調用),內核中會設置一個高精度定時器,來記錄要睡眠的任務,然後設置任務狀態爲可中斷的睡眠狀態,緊接着發生主動調度,這樣任務就發生睡眠了。

  1. 內核態睡眠 ========

當任務處於內核態時,有時候也需要睡眠一段時間,不像任務處於用戶態需要發生系統調用來請求內核進行睡眠,在內核態可以直接調用睡眠函數。當然,內核態中,睡眠有兩種場景:一種是睡眠特定的時間的延遲操作(喚醒條件爲超時),一種是等待特定條件滿足(如 IO 讀寫完成,可睡眠的鎖被釋放等)。

下面分別以 msleep 和 mutex 鎖爲例講解內核態睡眠:

5.1 msleep

msleep 做 ms 級別的睡眠延遲。

//kernel/time/timer.c
void msleep(unsigned int msecs)
{
        unsigned long timeout = msecs_to_jiffies(msecs) + 1;  //ms時間轉換爲jiffies

        while (timeout)
                timeout = schedule_timeout_uninterruptible(timeout);  //不可中斷睡眠
}

下面看下 schedule_timeout_uninterruptible:

這裏涉及到一個重要數據結構 process_timer

struct process_timer {
        struct timer_list timer;  //定時器結構
        struct task_struct *task; //定時器到期要喚醒的任務
};
schedule_timeout_uninterruptible
->  __set_current_state(TASK_UNINTERRUPTIBLE);  //設置任務狀態爲不可中斷睡眠
  return schedule_timeout(timeout); 
    ->expire = timeout + jiffies;   //計算到期時的jiffies值
        timer.task = current; //記錄定時器到期要喚醒的任務 爲當前任務
        timer_setup_on_stack(&timer.timer, process_timeout, 0);  //初始化定時器   超時回調爲process_timeout
        __mod_timer(&timer.timer, expire, MOD_TIMER_NOTPENDING); //添加定時器
        schedule();  //主動調度

再看下超時回調爲 process_timeout:

process_timeout
 ->struct process_timer *timeout = from_timer(timeout, t, timer); //通過定時器結構獲得process_timer
    wake_up_process(timeout->task); //喚醒其管理的任務

可以看到,msleep 實現睡眠也是通過定時器,首先設置當前任務狀態爲不可中斷睡眠,然後設置定時器超時時間爲傳遞的 ms 級延遲轉換的 jiffies, 超時回調爲 process_timeout,然後將定時器添加到系統中,最後調用 schedule 發起主動調度,當定時器超時的時候調用 process_timeout 來喚醒睡眠的任務。

5.2 mutex 鎖

mutex 鎖是可睡眠鎖的一種,當申請 mutex 鎖時發現其他內核路徑已經持有這把鎖,當前任務就會睡眠等待在這把鎖上。

下面我們來看他的實現,主要看睡眠的部分:

kernel/locking/mutex.c

mutex_lock
->__mutex_lock_slowpath
    ->__mutex_lock(lock, TASK_UNINTERRUPTIBLE, 0, NULL, _RET_IP_)  //睡眠的狀態爲不可中斷睡眠
        ->__mutex_lock_common
            ->
            ...
            waiter.task = current;  //記錄需要喚醒的任務爲當前任務
            set_current_state(state);  //設置睡眠狀態
            for (;;) {
                
                     if (__mutex_trylock(lock))  //嘗試獲得鎖
                         goto acquired;

                    schedule_preempt_disabled(); 
                        ->schedule();  //主動調度

            }
       acquired:
            __set_current_state(TASK_RUNNING);//設置狀態爲可運行狀態

可以看到 mutex 鎖實現睡眠套路和之前是一樣的:申請 mutex 鎖的時候,如果其他內核路徑已經持有這把鎖,首先通過 mutex 鎖的相關結構來記錄下當前任務,然後設置任務狀態爲不可中斷睡眠,接着在一個 for 循環中調用 schedule_preempt_disabled 發生主動調度,於是當前任務就睡眠在這把鎖上。當其他內核路徑釋放了這把鎖,就會喚醒等待在這把鎖上的任務,當前任務就獲得了這把鎖,然後進入鎖的臨界區,喚醒操作就完成了(關於喚醒的技術細節,後面的喚醒專題會詳細講解)。

  1. 總結 =====

進程睡眠按照應用場景可以分爲:延遲睡眠和等待某些特定條件而睡眠,實際上都可以歸於等待某些特定條件而睡眠,因爲延遲特定時間也可以作爲特定條件。進程睡眠按照進程所處的特權級別可以分爲:用戶態進程睡眠和內核態進程睡眠,用戶態進程睡眠需要進程通過系統調用陷入內核來發起睡眠請求。對於進程睡眠,內核主要需要做三大步操作:1. 設置任務狀態爲睡眠狀態 2. 記錄睡眠的任務 3. 發起主動調度。這三大步操作都是非常有必要,第一步設置睡眠狀態爲後面調用主調度器做必要的標識準備;第二步記錄下睡眠的任務是爲了以後喚醒任務來準備的;第三步是睡眠的主體部分,這裏會將睡眠的任務從運行隊列中踢出,選擇下一個任務運行。

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