進程控制:狀態、調度和優先級

目錄

進程的狀態

可運行狀態

可中斷睡眠狀態和不可中斷睡眠狀態

睡眠進程和等待隊列

TASK_KILLABLE 狀態

TASK_STOPPED 狀態和 TASK_TRACED 狀態

EXIT_ZOMBIE 狀態和 EXIT_DEAD 狀態

進程調度概述

普通進程的優先級

完全公平調度的實現

普通進程的組調度

實時進程

CPU 的親和力


圖片和文章沒有關係啦

進程的狀態

進程是無法始終佔有 CPU 資源的,原因是:

  1. 進程可能需要等待某種外部條件的滿足,在條件滿足之前,進程是無法繼續執行的。這種情況下,該進程繼續佔有 CPU 就是對 CPU 資源的浪費
  2. Linux 是多用戶多任務的操作系統,可能同時存在多個可以運行的進程,進程的個數可能遠遠多於 CPU 的個數。一個進程始終佔有 CPU 對其他進程來說是不公平的,進程調度器會在合適的時機,選擇合適的進程使用 CPU
  3. Linux 進程支持軟實時,實時進程的優先級高於普通進程,實時進程進程之間也有優先級的差別。軟實時進程進入可運行狀態的時候,可能會發生搶佔,搶佔當前運行的進程

Linux 下傳統的進程 7 狀態:前一項時 procfs 文件系統中的值,後一項是進程狀態

  1. R(running)   TASK_RUNNING
  2. S(sleeping)   TASK_INTERRUPTIBLE
  3. D(disk sleep)   TASK_UNINTERRUPTIBLE
  4. T(stopped)   TASK_STOPPED
  5. t(tracing stop)   TASK_TRACED
  6. Z(zombie)   TASK_ZOMBIE
  7. X(dead)   TASK_DEAD

可運行狀態

該狀態的名稱爲 TASK_RUNNING。有人說 Linux 進程有八種狀態,這種說法也是對的。因爲 TASK_RUNNING 狀態可以根據是否在 CPU 上運行,進一步細分成 RUNNING 和 READY 兩種狀態。處於 READY 狀態的進程,隨時可以投入運行,只是由於 CPU 資源有限,調度器暫時未被選中它運行。

處於可運行狀態的進程是進程調度的對象。如果進程並不處於可運行狀態,進程調度器不會選擇它投入運行。在 Linux 中每一個 CPU 都有自己的運行隊列,事實上還不止一個,根據進程調度類別的不同,可運行狀態的進程也會位於不同的隊列中:如果是實時進程(屬於事實調度類),則根據優先級的情況,落在相應的優先級隊列中;如果是普通進程(屬於完全公平調度類),則根據虛擬運行時間的大小,落在紅黑樹的相應結點上。這樣進程調度器可以根據一定的算法從運行隊列上挑選合適的進程來使用 CPU 資源。

處於 RUNNING 狀態的進程,可能正在執行用戶態代碼,也可能正在執行內核態代碼,內核提供了進一步的區分和統計。Linux 提供的 time 命令可以統計進程在用戶態和內核態消耗的 CPU 時間:

 time 命令統計了三個時間:

  1. 實際時間 :日常生活中的時間,即進程從開始到結束一共執行了多久
  2. 用戶 CPU 時間 :進程執行用戶態代碼消耗的 CPU 時間
  3. 系統 CPU 時間 :進程在內核態運行所消耗的 CPU 時間

如何區分用戶態 CPU 時間和內核態 CPU 時間呢?如果進程在執行加減乘除或排序等操作時,儘管這些操作正在消耗 CPU 資源,但是和內核並沒有太多的關係,CPU 大部分時間都在執行用戶態指令。這種場景下我們稱 CPU 時間消耗在用戶態。如果進程頻繁的執行進程創建、進程銷燬、分配內存、操作文件等,那麼進程不得不頻繁的陷入內核執行系統調用,這些時間都累加在進程的內核態 CPU 時間中。

在單核系統上 real time 總是不小於 user time 和 sys time 的總和。但是在多核系統上,user time 和 sys time 的總和可以大於 real time。利用這三個時間我們可以算出程序的 CPU 使用率:

cpu_usage = (user time + sys time) / real time

在多核處理器的情況下,如果 cpu_usage 大於 1,則表示該進程是計算密集型的進程,且 cpu_usage 的值越大,表示越充分的利用了多處理器的並行運行優勢;如果  cpu_usage 的值小於 1,則表示進成爲 I/O 密集型的進程,多核並行的優勢並不明顯。

time 命令的問題在於要等進程運行完畢後,才能獲取到進程的統計信息。有些時候我們需要了解正在運行的進程,他運行了多久、用戶態 CPU 時間和內核態 CPU 時間分別是多少?procfc文件系統在 /proc/PID/stat 中提供了相關信息:

每個字段都有自己獨特的含義。如果從 0 開始計數,那麼字段 13 對應的是進程消耗的用戶態 CPU 時間,字段 14 對應的是進程消耗的內核態 CPU 時間。兩者的單位是始終滴答。 

系統提供了 pidstat 命令,通過該命令可以獲取到各個進程的 CPU 使用情況:

pidstat 命令可以通過 -p 參數指定觀察的進程,從而獲取到該進程的 CPU 使用時間,包括用戶態 CPU 時間和內核態 CPU 時間。

可中斷睡眠狀態和不可中斷睡眠狀態

進程並不總是處於可運行的狀態。有些進程需要和慢速設備打交道。比如進程和磁盤進行交互,相關的系統調用消耗的時間是非常長的(可能在毫秒數量級甚至更久),進程需要等待這些操作完成纔可以執行接下來的指令。有些進程需要等待某種特定條件(比如父進程等待子進程退出、等待 socket 連接、嘗試獲取鎖、等待信號量等)得到滿足後方可執行,而等待的時間往往是不可預估的。在這種情況下,進程依然佔有 CPU 資源就不合適了,對 CPU 資源而言,這是一種極大的浪費。內核會將該進程的狀態改變成其他狀態,將其從 CPU 的運行隊列中移除,同時調度器選擇其它的進程來使用 CPU。

Linux 下存在兩種睡眠狀態:可中斷睡眠狀態和不可中斷睡眠狀態,這兩種睡眠狀態是很類似的。兩者的區別就在於能否響應收到的信號。

處於可中斷睡眠狀態的進程,返回到可運行的狀態有以下兩種可能性:

  1. 等待的事件發生了,繼續運行的條件滿足了
  2. 收到未被屏蔽的信號

當處於可運行狀態的進程收到信號時,會返回 EINTR 給用戶進程。程序員需要檢測返回值,並做出正確的處理。

但是對於不可中斷睡眠狀態的進程,只有一種可能性使其返回到可運行的狀態,即等待的事件發生了,繼續運行的條件滿足了。

睡眠進程和等待隊列

進程無論是處於可中斷睡眠狀態還是不可中斷睡眠狀態,有一個數據結構是繞不開的:等待隊列(wait queue)。進程但凡需要休眠,必然是等待某種資源或等待某個事件,內核必須想辦法將進程和它等待的資源(或事件)關聯起來,當等待的資源可用或等待的事件發生時,可以及時地喚醒進程。內核採用的方法是等待隊列。

等待隊列作爲 Linux 內核中基礎數據結構和進程調度密切地結合在一起。當進程需要等待特定事件時,就將其放置在合適的等待隊列上,因此等待隊列對應的是一組進入休眠狀態的進程,當等待的事件發生(或者說等待的條件滿足)時,這組進程會被喚醒,這類事件通常包括:中斷(I/O 完成)、進程同步、休眠事件到等。

內核使用雙鏈表來實現等待隊列,每個等待隊列都可以用等待隊列頭來標識,等待隊列頭的定義如下:

struct __wait_queue_head{
    spinlock_t lock;
    struct list_head task_lisk;
};
trpedef struct __wait_queue_head wait_queue_head_t;

進程需要休眠的時候,需要定義一個等待隊列元素,將該元素插入合適的等待隊列,等待隊列元素的定義如下:

struct __wait_queue{
    unsigned int flags;
#define WQ_FLAG_EXCLUSIVE 0x01
    void *private;
    wait_queue_func_t func;
    struct list_head task_list;
};
typedef struct __wait_queue wait_queue_t;

等待隊列上的每個元素,都對應一個處於睡眠狀態的進程:

內核如何使用等待隊列完成睡眠,以及條件滿足之後如何喚醒對應的進程內?

首先要定義和初始化等待隊列頭部。 內核提供了 init_waitqueue_head 和 DECLARE_WAITQUEUE_HEAD 兩個宏,用來初始化等待隊列頭部。

其次,當進程需要睡眠時,需要定義等待隊列元素。內核提供了 init_waitqueue_entry 函數和 init_waitqueue_func_entry 函數來完成等待隊列元素的初始化:

static inline void init_waitqueue_entry(wait_queue_t *q,struct task_struct *p){
    q->flags = 0;
    q->private = p;
    q->func = default_wake_function;  //通用的喚醒回調函數
}

static inline void init_waitqueue_func_entry(wait_queue_t *q,wait_queue_func_t func){
    q->flags = 0;
    q->private = NULL;
    q->func = func;
}

從等待隊列元素的初始化函數可以看出,等待隊列的 private 成員變量指向了進程的描述符 task_struct,因此就有了等待隊列元素,就可以將進程掛入對應的等待隊列了。

第三步是將睡眠進程(即等待隊列元素)放入合適的等待隊列中。內核同時提供了 add_wait_queue 和 add_wait_queue_exclusive 兩個函數來把等待隊列元素添加到等待隊列頭部指向的雙向鏈表中,代碼如下:

void add_wait_queue(wait_queue_head_t *q,wait_queue_t *wait){
    unsigned long flags;

    wait->flag &= ~WQ_FLAG_EXCLUSIVE;
    spin_lock_irqsave(&q->lock,flags);
    __add_wait_queue(q,wait);
    spin_unlock_irqrestore(&q->lock,flags);
}

void add_wait_queue_exclusive(wait_queue_head_t *q,wait_queue_t *wait){
    unsigned long flags;

    wait->flag |= WQ_FLAG_EXCLUSIVE;
    spin_lock_irqsave(&q->lock,flags);
    __add_wait_queue(q,wait);
    spin_unlock_irqrestore(&q->lock,flags);
}

這兩個函數的區別在於:

  1. 一個等待隊列元素設置了 WQ_FLAG_EXCLUSIVE 標誌位,而另一個則沒有
  2. 一個等待隊列元素放到了等待隊列的隊尾,而另一個則放到了等待隊列的隊頭

同樣是添加到等待隊列,爲何同時提供兩個函數,WQ_FLAG_EXCLUSIVE 標誌位到底有什麼作用?

有這樣的場景:如果存在多個進程(既在等待隊列上有多個等待隊列元素)在等待同一個條件滿足或同一件事情發生,那麼當條件滿足時,應該喚醒一個或某幾個進程還是所有進程一併喚醒。

有時候需要喚醒等待隊列上的所有進程,但有時候喚醒操作需要具有排他性(EXCLUSIVE)。比如多個進程等待臨界區資源,當鎖的持有者釋放鎖時,如果內核將所有等待在該鎖上的進程一併喚醒,那麼最終只能是某一個進程競爭到鎖,其他進程不過是從休眠中醒來,然後繼續休眠,這會浪費 CPU 資源,如果等待隊列中的進程數目很大,還會嚴重影響性能。這就是所謂的驚羣效應。因此內核提供了 WQ_FLAG_EXCLUSIVE 標誌位來實現互斥等待,add_wait_queue_exclusive 函數會將帶有該標誌位的等待隊列元素添加到等待隊列的尾部。當內核喚醒等待隊列上的進程時,等待隊列元素上的 WQ_FLAG_EXCLUSIVE 標誌位會影響喚醒行爲,比如 wake_up 宏,它喚醒第一個帶有 WQ_FLAG_EXCLUSIVE 標誌位的進城後就會停止。

事實上,當進程需要等待某個條件滿足而不得不休眠時,內核封裝了一些宏來完成前面提到的流程,這些宏包括:

wait_event(wq,condition)  //進程狀態是不可中斷的睡眠狀態
wait_event_timeout(wq,condition,timeout)  //超時時間

wait_event_interruptible(wq,condition)  //進程狀態是可中斷睡眠狀態
wait_event_interruptible_timeout(wq,condition,timeout)  //超時時間

第一個參數指向的是等待隊列的頭部,表示進程會睡眠在該等待隊列上。進程醒來時,condition 需要得到滿足,否則繼續阻塞。

有睡眠就要有喚醒,喚醒系列宏包括:

wake_up(x)
wake_up_nr(x,nr)
wake_up_all(x)
wake_up_interruptible(x)
wake_up_interruptible_nr(x,nr)
wake_up_interruptible_all(x)

其中該系列宏中,名字中帶 _interruptible 的宏只能喚醒處於可中斷休眠狀態的進程,名字中不帶  _interruptible 的宏,既可以喚醒可中斷狀態的進程,也可以喚醒不可中斷休眠狀態的進程。

wake_up 系列函數中爲什麼有些函數後面有 _nr 或 _all 這樣的後綴?不帶後綴的表示只能喚醒一個帶有 WQ_FLAG_EXCLUSIVE 標誌位的進程,帶 _nr 的表示可以喚醒 nr 個帶有 WQ_FLAG_EXCLUSIVE 標誌位的進程,帶 _all 後綴的表示可以喚醒等待隊列上所有的進程。

這些宏和前面 wait_event 系列宏的配對使用情況如下:

 這些 wake_up 系列的宏,其實現部分最終是通過 __wake_up 函數的簡單封裝來實現的。

注意,遍歷等待隊列上的所有等待隊列元素時,對於每一個需要喚醒的進程,執行的是等待隊列元素中定義的 func。

在初始化等待隊列元素的時候,需要註冊回調函數 func。當內核喚醒該進程時,就會執行等待隊列元素中的回調函數。這裏最常用的回調函數是 default_wake_function,這是默認的回調函數。而 default_wake_function 函數僅僅是 try_to_wake_up  函數的簡單封裝。

try_to_wake_up  是進程調度裏非常重要的一個函數,它負責將睡眠的進程喚醒,並將醒來的進程放置到 CPU 的運行隊列中,並設置進程的狀態爲 TASK_RUNNING。

TASK_KILLABLE 狀態

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
	if(!vfork()){
		sleep(100);
		printf("666\n");
	}
	return 0;
}

很多文章認爲,調用 vfork 函數創建子進程時,子進程在調用 exec 系列函數或退出之前,父進程始終處於 TASK_UNINTERRUPTIBLE 狀態。

 其實這種說法是錯誤的。因爲父進程可以輕易的被信號殺死,這證明父進程並不處於 不可中斷睡眠狀態。

父進程的狀態顯示的是 D+,按 ps 命令的說法應該是處於不可中斷睡眠狀態,可爲什麼仍然會被信號殺死呢?

事實上,ps 命令輸出的 D 狀態不能簡單的理解成  TASK_UNINTERRUPTIBLE 狀態。內核自 2.6.25 版本起引入了一種新的狀態即 TASK_KILLABLE 狀態。可中斷睡眠狀態的信號太容易被信號打斷,不可中斷睡眠狀態完全不可以被信號打斷,容易失控,兩者都失之極端。TASK_KILLABLE 狀態則介於兩者之間,是一種調和狀態。該狀態行爲上類似於 TASK_UNINTERRUPTIBLE 狀態,但是處於該狀態的進程收到致命信號時,進程會被喚醒。

上面例子中 vfork 創建子進程後,ps 顯示父進程處於 D 狀態,卻依然被殺死的原因是進程並不是處於不可中斷睡眠狀態,而是處於 TASK_KILLABLE 狀態,是可以響應致命信號的。

有了該狀態,wait_event 系列宏也增加了 killable 的變體,即 wait_event_killable。該宏會將進程設置爲 TASK_KILLABLE 狀態,同時睡眠在等待隊列上。致命信號 SIGKILL 可以將其喚醒。

TASK_STOPPED 狀態和 TASK_TRACED 狀態

TASK_STOPPED 狀態是一種比較特殊的狀態。SIGSTOP、SIGTSYP、SIGTTIN、SIGTTOU 等信號會將進程暫時停止,停止後的進程就會進入到該狀態。其中 SIGSTOP 具有和 SIGKILL 類似的屬性,即不能忽略、不能安裝新的信號處理函數、不能屏蔽等。當處於 TASK_STOPPED 狀態的進程收到 SIGCONT 信號後,可以恢復到 TASK_RUNNING 狀態。

TASK_TRACED 狀態是被跟蹤的狀態,進程會停下來等待跟蹤它的進程對它進行進一步的操作。如何才能製造出處於 TASK_TRACED 狀態的信號呢?最簡單的例子是用 gdb 調試程序,當進程在端點處停下來時,此時進程處於該狀態。

處於這兩種狀態的進程類似之處是都處於暫停狀態,不同之處是 TASK_TRACED 狀態的進程不會被 SIGCONT 信號喚醒。只有調試進程通過 ptrace 系統調用下達 PTRACE_CONT、PTRACE_DETACH 等指令,或者調試進程退出,被調試的進程才能恢復 TASK_RUNNING 狀態。

EXIT_ZOMBIE 狀態和 EXIT_DEAD 狀態

是兩種退出狀態,嚴格來說,他們並不是運行狀態。當進程處於這兩種狀態中的任何一種時,它其實已經死去了。內核會將這兩種狀態記錄在進程描述符的 exit_state 中。

兩種狀態的區別在於,如果父進程沒有將 SIGCHLD 信號的處理函數設置爲 SIG_IGN,或者沒有爲 SIGCHLD 信號設置 SA_NOCLDWAIT 標誌位,那麼子進程退出後,會進入殭屍狀態等待父進程或 init 進程來收屍,否則直接進入 EXIT_DEAD 狀態。如果不停留在殭屍狀態,進程的退出是非常快的,因此很難觀察到一個進程是否處於 EXIT_DEAD 狀態。

進程調度概述

進程調度是任何一個現代操作系統都要解決的問題,它是操作系統相當重要的一個組成部分。首先需要了解的一點是,進程調度是對處於可運行狀態的進程進行調度,如果進程並非處於 TASK_RUNNING 狀態,那麼該進程和進程調度是沒有關係的。

Linux 是多任務操作系統,所謂多任務是指系統能夠同時併發的執行多個進程,哪怕是單處理器系統。在單處理器上處理多任務,會給用戶多個進程同時跑的幻覺,事實上多個進程僅僅是輪流使用 CPU 資源。只有在多處理器上,多個進程才能做到同時、並行執行。

多任務系統可以根據是否支持搶佔分爲兩類:非搶佔式多任務和搶佔式多任務。在非搶佔式多任務系統中,下一個任務被調度的前提是當前進程主動讓出 CPU 的使用權,因此,非搶佔式多任務又稱爲合作型多任務。而搶佔式多任務系統中由操作系統來決定進程調度。毫無疑問,Linux 屬於搶佔式多任務系統。事實上,大多數的現代操作系統都是搶佔式多任務系統。

CPU 是一種關鍵的系統資源。在普通 PC 上 CPU 的核數(cpu 內核數量)是 4 核、8 核等,在服務器上可能有 16 核、32 核甚至更多。在系統負載始終比較輕的情況下,進程調度算法的重要性並不大。但是如果系統的負載很高,有幾百上千的進程處於可運行狀態,那麼一套合理高效的調度算法就非常重要了。

此外,不同的進程之間,其行爲模式可能存在着巨大的差異。進程的行爲模式可以粗略的分爲兩類:CPU 消耗型和 I/O 消耗型。所謂 CPU 消耗型指進程沒有太多的 I/O 請求,始終處於可運行的狀態,始終在執行指令。而 I/O 消耗型是指進程會有大量的 I/O 請求,它處於可執行狀態的時間不多,而是將更多的時間耗費在等待上。當然這種劃分方式並非絕對的,有可能有些進程某段時間表現出 CPU 消耗型的特徵,另一段時間又表現出 I/O 消耗型的特徵。

還有另外一種進程的分類方法:

  • 交互式進程:這種類型的進程有很多的人機交互,進程會不斷地陷入休眠狀態,等待鍵盤和鼠標的輸入。這種進程對系統的響應時間要求非常高,用戶輸入之後,進程必須被及時喚醒,否則用戶就會覺得系統反應遲鈍。比較典型的例子是文本編輯程序和圖形處理程序
  • 批處理型進程:這類進程和交互式進程相反,它不需要和用戶交互,通常在後臺執行。這樣的進程不需要及時地響應。比較典型的例子是編譯、大規模的科學計算等
  • 實時進程:這類進程優先級比較高,不應該被普通進程和優先級比它低的進程阻塞,一般需要比較短的響應時間

系統之中,有很多性格各異的進程,這就增加了設計調度器算法的難度。設計一個優秀的進程調度器算法絕不是一件容易的事,需要很多要素被考慮:

  • 公平:每一個進程都可以獲得調度的機會,不會出現“餓死”現象
  • 良好的調度延遲:經量確保進程在一定的時間範圍內,總能夠獲得調度的機會
  • 差異化:允許重要的進程獲得更多的執行時間
  • 支持軟實時進程:軟實時進程比普通進程有更高的優先級
  • 負載均衡:多個 CPU 之間的負載要均衡,不能出現一些 CPU 很忙,一些 CPU 很閒的情況
  • 高吞吐量:單位時間內完成調度的進程個數儘可能多
  • 簡單高效:調度算法要高效,不能在調度上花費太長的時間
  • 低耗電量:在系統並不繁忙的情況下,降低系統的耗電量

在對稱多處理器(SMP)的系統上,存在着多個處理器,那麼所有處於可運行狀態的進程應該位於一個隊列上,還是每個處理器應該有自己的隊列?這大概是進程調度首先要解決的問題。

目前 Linux 採用的是每個 CPU 都要有自己的運行隊列。每個 CPU 去自己的運行隊列裏選擇進程,這樣就降低了競爭。這種方案還有一個好處:緩存重用。某個進程位於這個 CPU 的調度隊列裏,經過多次調度之後,內核趨於相同的進程選擇該 CPU 執行該進程。這種情況下,上下次運行的變量很可能仍然在 CPU 的緩存中,這樣就提升了效率。所有的 CPU 共用一個運行隊列這種方案的弊端是顯而易見的,尤其是在 CPU 數量很多的情況下。

Linux 選擇了每一個 CPU 都有自己的運行隊列的方案。也帶來了一些問題:CPU 之間負載不均衡,可能會出現一些 CPU 閒着而另外一些 CPU 忙不過來的情況。爲了解決這個問題,load_balence 就閃亮登場啦。它的任務是在一定時機下,通過將任務從一個 CPU 的運行隊列遷移到另一個 CPU 的運行隊列中,來保持 CPU 之間的負載均衡。

進程調度具體要做哪些事情呢?概括地說,進程調度的職責是挑選下一個執行地進程,如果下一個被調到的進程和調度前運行的進程不是同一個,則執行上下文切換,將新選擇的進程投入運行。

普通進程的優先級

完全公平調度的實現

普通進程的組調度

實時進程

CPU 的親和力

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章