Linux進程管理(二)進程調度

Linux進程管理

Linux進程管理(一)進程數據結構

Linux進程管理(二)進程調度

Linux進程管理(三)進程調度之主動調度

Linux進程管理(四)進程調度之搶佔式調度

Linux進程管理(二)進程調度

一、進程調度解決什麼問題?

我們在使用電腦的時候,比如打開一個視頻剪輯器,一個文本編輯器,可以認爲它們都是一個進程。假如CPU是單核的,那麼在同一時間只能運行一個進程,但是給我們的感覺是視頻剪輯器和文本編輯器好像是同時運行的,也就是視頻剪輯器在剪輯視頻的時候,我們同時可以使用文本編輯器,這是怎麼實現的呢?

其實這只是我們從宏觀上感覺它們是並行運行的,而微觀上它們是串行運行的。也就是說,可以認爲這兩個進程在做頻繁的切換,比如視頻剪輯器運行10ms,然後文本編輯器運行10ms,如此交替,這樣子它們其實串行運行的,但由於我們的反應沒那麼快,所以覺得它們是並行運行的,如下圖所示

在這裏插入圖片描述

一般操作系統的進程的進程數會非常的多,而一個CPU同一時間只能運行一個進程,這些進程可能是視頻剪輯器,可能是文本編輯器等等。例如文本編輯器大多數時間在等待我們按下按鍵,並不需要佔用太多CPU運行時間,而每當我們按下鍵盤上的按鍵的時候,它需要快速響應我們的操作並且將字符顯示在屏幕。而視頻剪輯器在剪輯視頻的時候非常耗費CPU,但是它並不需要像文本編輯器那麼頻繁地與用戶交互。也就是文本編輯器它可以佔用更少地CPU運行時間,但是它需要快速響應用戶操作,而視頻編輯器它需要佔用更多地CPU運行時間,但是它不需要快速響應用戶操作,如下圖所示

在這裏插入圖片描述

爲了提高用戶體驗和系統性能,要解決的問題就是決定什麼時候應該運行哪一個進程,該進程應該運行多久。也就是我們上面舉的例子,每當我們操作文本編輯器的時候,要快速讓文本編輯器處於運行狀態,在我們沒有操作文本編輯器的時候,應該儘量讓視頻剪輯器運行

這就是進程調度解決的問題,這也是衡量一個操作系統的優秀與否的一個重要指標

本篇文章講解Linux如何管理進程,進程調度是怎麼轉起來的,爲了實現進程調度維護了哪些數據結構,實現了哪些算法

至於一個進程如何實現搶佔,進程調度的時機等細節將放到後面的文章講解

二、進程調度整體框架

在設計到內核具體的代碼之前,我先來給你講解一下進程調度的大體框架,讓你明白進程調度是怎麼轉起來的,在你明白每一個部分的含義之後,再深入講解內核的實現

操作系統管理非常多的進程,這些進程當前可能處於可運行狀態或者睡眠狀態。進程調度解決的是當前應該運行哪一個進程,它關心的對象是當前可運行狀態的進程,內核爲了管理這些可運行的進程,準備了一個運行隊列,如下圖所示

在這裏插入圖片描述

對於多CPU處理器,每一個CPU都有屬於它的運行隊列

我們將CPU當前正在運行的進程稱爲 current 進程,current 進程是不在運行隊列中的,如下圖所示

在這裏插入圖片描述

接下來要解決的是,current進程什麼時候應該被其它進程搶佔,以及如何搶佔?

進程切換一般分爲兩步

  • 第一步對current進程設置需要重新調度標誌

  • 第二步在系統調用返回或中斷返回時等時機檢查current進程是否設置了需要重新調度標誌,如果需要,則調用schedule發生進程切換(具體的時機將在後面的文章詳細討論,這裏暫且這麼認爲就行)

    什麼是系統調用返回和中斷返回?

    如果你對這兩個概念不瞭解也無大礙,我這裏簡單地講解。你可以理解成,當CPU在運行某一個進程的時候,發生系統調用或者中斷,會暫停進程的運行,然後去執行特定的處理程序,在執行完處理程序想要恢復進程運行的這個時候,就是系統調用返回或中斷返回的時機

    中斷是由硬件觸發的,系統調用是進程運行時觸發的,可能有很多硬件頻繁地產生中斷,許多進程頻繁地觸發系統調用,所以對於操作系統來說,系統調用返回和中斷返回這樣的時機是隨機又頻繁地產生地,所以我們有很多個時機可以去檢查current進程是否需要被切換

    另外,你可以這樣理解真正發生進程切換都是通過調用schedule函數完成的

首先我們來解決第一步,設置current進程需要重新調度的標誌

我們通過什麼機制來設置current進程需要重新調度的標誌呢?

硬件電路中有一個硬件定時器,它負責週期性的產生時鐘中斷(一般爲10ms),我們稱它爲滴答定時器,可以認爲,它就是操作系統的心臟。每當產生定時器中斷的時候,CPU就會執行中斷處理程序

在這裏插入圖片描述

在滴答定時器的中斷處理中,我們會判斷current進程是否需要被搶佔,怎麼判斷?

很明顯,這一部分需要具體的調度算法來實現,Linux將調度算法的實現抽象成調度類

在滴答定時器的中斷處理中,通過調度類去實現相應的計算,然後判斷current進程是否需要被搶佔,如果需要被搶佔,那麼就在current進程設置需要重新調度的標誌,如下圖所示

在這裏插入圖片描述

實時上,Linux內核的調度類不僅僅只有一個,因爲內核同時實現了多種調度算法,但是我們這裏強調總體框架,暫不討論這裏細節問題

到此,進程切換的第一步設置current進程需要重新調度標誌部分已經講解完

接下看第二步,進程真正的切換

實現進程真正的切換總是調用schedule函數,而schedule函數被調用的一般時機是系統調用返回或者是中斷返回時。在系統調用返回或者是中斷返回中,會檢查current進程是否設置了需要重新調度標誌,如果設置了,那麼就調用schedule函數

系統調用返回或者是中斷返回這樣的時機對於操作系統整體來說,總是隨機且頻繁地產生,如下圖所示

在這裏插入圖片描述

如果current進程設置了需要重新調度標誌,那麼就會調用schedule函數。schedule函數會通過調度類,從運行隊列中選取下一個要運行的進程,然後搶佔current進程,成爲新的current進程,如下圖所示

在這裏插入圖片描述

到這裏,你應該明白了整個進程調度機制是怎麼運行起來的,以及爲了實現進程調度,實現了哪些數據結構,下面適當地總結一下

  • 首先進程調度處理的對象是可運行的進程,所以準備了一個運行隊列來管理當前可運行的進程,如果是多CPU處理器,那麼每一個CPU都有它對應的一個運行隊列
  • CPU當前正在運行的進程成爲current進程,進程調度解決的問題就是合理地切換current進程
  • 進程發生切換需要兩步,第一步在current進程設置需要重新調度的標誌。第二步是在中斷返回或系統調用返回時,檢查是否current進程是否設置兩類需要重新調度標誌,如果設置了,那麼就調用schedule函數來發生進程搶佔(換言之,進程真正發生切換總是通過調用schedule函數發生的)
  • 在硬件電路有一個滴答定時器,每隔10ms產生一次中斷,CPU就處理一次中斷。在滴答定時器中斷處理中,通過調度類來檢查current進程是否需要被切換,如果需要就設置需要重新調度的標誌
  • 對於整個操作系統來說,中斷和系統調用總是隨機且頻繁地產生,在中斷返回或者系統調用返回地時候,會檢查current進程是設置了需要重新調度地標誌。如果設置了,就會調用schedule函數發生進程搶佔,切換current進程
  • schedule函數通過調度類,從運行隊列中獲取下一個運行的進程,然後用它來搶佔current進程,從而切換進程運行

下面我們繼續深入講解各個部分

三、優先級與調度策略

在內核中,肯定不能對所有的進程一視同仁,有的進程需要優先運行,有的進程需要運行更長的時間

爲了更好地實現進程調度,每個進程都有自己的優先級調度策略

所謂優先級,就是表示這個進程的重要性,優先級高的自然會被更好的對待

那調度策略又是什麼呢?想一想,進程調度其實是一個非常複雜的問題,想使用一種算法來實現良好的進程調度是不可能的,Linux內核實現了好幾種調度算法。所謂調度策略,你可以理解爲使用哪種算法來管理進程

每個進程都使用 task_struct 結構來表示,在這個結構體中,關於調度策略的定義如下

unsigned int policy;

policy 表示該進程採用哪種調度策略,內核提供了以下幾種調度策略

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

#define SCHED_NORMAL		0
#define SCHED_FIFO		1
#define SCHED_RR		2
#define SCHED_BATCH		3
#define SCHED_IDLE		5
#define SCHED_DEADLINE		6

Linux內核的進程大概可分爲兩類,一類是普通進程,一類是實時進程

其中屬於實時進程的調度策略是 SCHED_FIFO、SCHED_RR、SCHED_DEADLINE

屬於普通進程的調度策略是 SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE

下面我來跟你詳細講解每個調度策略代表什麼

  • SCHED_DEADLINE:這是實時進程的調度策略,它是按照任務的deadline來調度的,當產生一個調度點的時候,總會選取距離deadline最近的進程來運行

  • SCHED_FIFO:這是實時進程的一種調度策略,FIFO表示先進先出機制,在使用該調度策略的進程被選中運行後,它可以運行任意長時間,直到更高優先級的進程搶佔或者自己讓出CPU

  • SCHED_RR:這也是實時進程的調度策略,RR是時間片輪轉調度,每個使用該調度策略的進程都有自己的時間片,進程運行直到時間片耗盡,再將其添加到運行隊列尾部,如此循環

  • SCHED_NORMAL:表示普通進程的調度策略,內核大多數進程都屬於普通進程,普通進程使用完全公平調度算法實現調度

  • SCHED_BATCH:是用於非交互,CPU使用密集的批處理進程,它和普通進程都是使用完全公平調度算法來實現。內核中在某時刻可以去喚醒某個進程,如果這個進程的調度策略是SCHED_BATCH,那它就不會去搶佔當前正在運行的進程

  • SCHED_IDLE:是用於特別空閒的進程使用的調度策略

講完調度策略,我們來將優先級

task_struct 中關於優先級的定義如下

int prio, static_prio, normal_prio;
unsigned int rt_priority;

是的,內核使用了四個變量來表示優先級,這四個變量之間的關係相當複雜,不過沒關係,我會盡量地解釋清楚

  • prio:動態優先級,進程調度中判斷一個進程的優先級都是使用此變量
  • normal_prio:這個變量也表示動態優先級,它表示正常的優先級。最初的時候 prio 是等於 normal_prio 的,只不過有的時候進程的優先級需要臨時改變,所以會改變prio,但是 normal_prio 是不會變的。在創建子進程的時候,子進程繼承的優先級是normal_prio,而不是prio
  • static_prio:表示進程優先級,進程啓動的時候賦值的,內核不會去改變它,只能用戶通過nice和sched_setscheduler 系統調用來設置
  • rt_priority:只有實時進程纔會用到的優先級,其值範圍是0~99,最低優先級是0,最高優先級是99

這四個變量有什麼聯繫呢?

prio 和 normal_prio 最初的值是相等的,它們都是基於 static_prio 或者 rt_priority 計算的(至於基於 staticc_prio 還是 rt_priority,取決於調度策略)

下面來看一看內核的代碼

內核中將0139的優先級劃分爲兩個範圍,099表示實時進程優先級,100~139的優先級表示普通進程的優先級,數值越小表示優先級越高

在這裏插入圖片描述

首先我們講static_prio,進程啓動的時候會設置好靜態優先級。如果需要修改,可以通過nice系統調用來設置,nice的範圍是-2019,最終映射到優先級爲100139的部分,如下所示

在這裏插入圖片描述

內核中定義如下

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

/* nice的範圍 */
#define MAX_NICE	19
#define MIN_NICE	-20
#define NICE_WIDTH	(MAX_NICE - MIN_NICE + 1) //20

#define DEFAULT_PRIO		(MAX_RT_PRIO + NICE_WIDTH / 2) //120

#define NICE_TO_PRIO(nice)	((nice) + DEFAULT_PRIO) //100~139

void set_user_nice(struct task_struct *p, long nice)
{
    ...
    p->static_prio = NICE_TO_PRIO(nice);
	...
}

rt_priority 動態優先級又是怎麼指定的呢?

用戶層可以通過 sched_setscheduler,將普通進程更改爲實時進程,通過更改進程的調度策略,同時設置 rt_priority,也就是說它的值可以是應用程序指定的,範圍是0~99

在清楚 static_priort_priority 怎麼得來之後,我們來看看 normal_prioprio 這兩個變量是怎麼計算的

內核中通過下面的代碼來設置

p->prio = effective_prio(p);

看一下 effective_prio 的定義

static int effective_prio(struct task_struct *p)
{
	p->normal_prio = normal_prio(p);

	if (!rt_prio(p->prio))
		return p->normal_prio;

    /* 如果進程的優先級本來是實時優先級或者進程被提高到實時進程,那麼就保持不變 */
    return p->prio;
}

可以看到,通過這條指令 p->prio = effective_prio§,會同時設置 prio 和 normal_prio,下面來看看 normal_prio 函數的定義,這個函數也是解開這幾個變量之間關係的關鍵

#define MAX_DL_PRIO		0

static inline int normal_prio(struct task_struct *p)
{
	int prio;

	if (task_has_dl_policy(p)) //deadline調度策略
		prio = MAX_DL_PRIO-1; //-1
	else if (task_has_rt_policy(p)) //FIFO或者RR的調度策略
		prio = MAX_RT_PRIO-1 - p->rt_priority; //99-rt_priority
	else //普通進程調度策略(NORMAL、BATCH、IDLE)
		prio = __normal_prio(p);
	return prio;
}

normal_prio 根據進程不同的調度策略,使用不同的方法來設置進程的優先級

  • 如果進程採用 SCHED_DEADLINE 調度策略,那麼優先級就等於-1,這可不在正常的0~139範圍內,可見SCHED_DEADLINE 調度策略的優先級是極高的

  • 如果進程採用 SCHED_FIFO 或者 SCHED_RR 調度策略,那麼優先級就等於 99 - rt_priority,rt_priority 的範圍是0~99。當rt_priority 的越大,優先級數值越小,優先級就越高。這也就是爲什麼動態優先級 rt_priority 越大,優先級越大

  • 如果是採用 SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE 調度策略,那麼就採用 __normal_prio 來計算,其定義如下

    static inline int __normal_prio(struct task_struct *p)
    {
        /* 直接返回static_prio */
    	return p->static_prio;
    }
    

將上述的關係整理下表

進程類型 static_prio normal_prio prio
非實時進程(普通進程) static_prio static_prio static_prio
優先級提高的非實時進程 static_prio static_prio 不變
實時進程 static_prio MAX_RT_PRIO-1-rt_priority 不變

優先級和調度策略都存在於 task_struct 中,它們都是描述進程的信息,它們具體有什麼用,我們下面將會介紹

四、調度類

進程指定了調度策略,表明要使用哪種調度算法,那麼總要有一個地方來實現這個算法吧

你還記得我們講的進程調度基本框架嗎,看一下下面這張圖

在這裏插入圖片描述

我們說過,其中的調度類就是實現了某種調度算法,實際上內核中不止一個調度類,因爲需要實現好幾種算法,所以內核有好幾個調度類。但是不一定每種調度算法都對應一個唯一的調度類,一個調度類可以實現多種算法

內核中定義以下幾個調度類

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

extern const struct sched_class stop_sched_class;
extern const struct sched_class dl_sched_class;
extern const struct sched_class rt_sched_class;
extern const struct sched_class fair_sched_class;
extern const struct sched_class idle_sched_class;
  • stop_sched_class:這個調度類實現的是一直讓一個進程運行,不允許被搶佔
  • dl_sched_class:這個調度類實現的是deadline調度算法
  • rt_sched_class:這個調度實現了FIFO和RR調度算法
  • fair_sched_class:這個調度類實現了完全公平調度算法(CFS)
  • idle_sched_class:非常空閒的任務會使用此調度類

調度類是全局的,在 task_struct 中,定義以下變量來指向相應的調度類

const struct sched_class	*sched_class;

一個進程具體使用什麼調度類,取決於它所選擇的調度策略,調度策略和調度類之間是有映射關係的,它們的映射關係如下

調度策略 調度類
stop_sched_class
SCHED_DEADLINE dl_sched_class
SCHED_FIFO、SCHED_RR rt_sched_class
SCHED_NORMAL、SCHED_BATCH fair_sched_class
SCHED_IDLE idle_sched_class

這些調度類,從上到下,優先級從高到低。它們被按照這個順序串成一個鏈表,如下所示

在這裏插入圖片描述

在進程調度基本框架中,我們說過,schedule 會通過調度類去運行隊列中挑選下一個要運行的進程,然後搶佔 current 進程。現在這裏需要再進一步詳細說明,應該說,schedule 會按照這個優先級順序去遍歷調度類,使用相應的調度類去嘗試從運行隊列中獲取下一個要運行的進程,只要能夠獲取到一個進程,那麼就會返回

所以使用實時進程使用了更高優先級的調度類,它們總是比使用 fair_sched_class 的普通進程更優先被調用

在 schedule 函數中會通過 pick_next_task,來挑選下一個運行的進程,其定義如下

#define sched_class_highest (&stop_sched_class)

#define for_each_class(class) \
   for (class = sched_class_highest; class; class = class->next)

static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
	...
     
	/* 遍歷調度類來挑選進程 */
	for_each_class(class) {
    	p = class->pick_next_task(rq, prev, rf);
        if (p) {
            return p;
        }
    }
}

其中 for_each_class 就是遍歷調度類鏈表

下面來看一看調度類的定義,看它究竟需要實現什麼功能,定義如下(我已經刪除其中的一部分成員了)

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

struct sched_class {
	const struct sched_class *next;

	void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
	void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
	void (*yield_task)   (struct rq *rq);

	void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);

	struct task_struct * (*pick_next_task)(struct rq *rq,
					       struct task_struct *prev,
					       struct rq_flags *rf);
	void (*put_prev_task)(struct rq *rq, struct task_struct *p);

	void (*set_curr_task)(struct rq *rq);
	void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);

	void (*update_curr)(struct rq *rq);
};
  • next:指針,就像上面所說,所有的調度類會被串成一個鏈表
  • enqueue_task:將一個可運行的進程放入運行隊列中
  • dequeue_task:enqueue_task 的反操作,將一個進程從運行隊列中刪除
  • yield_task:如果當前進程想自願放棄CPU,可以通過 sched_yield 系統調用,最終會調用到這個函數
  • check_preempt_curr:在必要的情況下,會調用check_preempt_curr,用一個新喚醒的進程來搶佔當前進程,例如使用 wake_up_new_task 來喚醒新進程,就會調用到此函數
  • pick_next_task:從運行隊列中選取下一個要運行的進程
  • put_prev_task:用另一個進程代替當前運行的進程之前調用
  • set_curr_task:當進程的調度策略發生變化的時候,就會調用此函數
  • task_tick:在滴答定時器中斷處理中,週期性調度器會調用此函數
  • update_curr:用於更行運行隊列的統計信息

五、運行隊列和調度實體

上面我們講的時候,都是說調度類從運行隊列中挑選下一個任務,如下圖所示

在這裏插入圖片描述

想一想,調度算法如此複雜,而且不同的調度類它們肯定需要維護自己的數據結構,所以這個隊列肯定不是數據結構中簡單的隊列。你可以理解爲這個運行隊列只不過是一個統稱,它內部還包含了各種各樣的數據結構,我之所以這麼畫,只是爲了讓你好理解,下面我們來揭開它神祕的面紗

內核中對運行隊列的定義如下(我省略了其中許多統計信息)

struct rq {
	...
    struct cfs_rq		cfs;
    struct rt_rq		rt;
    struct dl_rq		dl;
	...
};

struct rq 中,對於不同的調度類,定義了不同的子隊列,選取不同的調度類的可運行進程,會被調度類放到相應的子隊列中

  • cfs:fair_sched_class 調度類在運行隊列中對應的子隊列
  • rt:rt_sched_class 調度類在運行隊列中對應的子隊列
  • dl:dl_sched_class 調度類在運行隊列中對應的子隊列

這裏你需要明白一個概念,調度類只是實現了某種算法,運行隊列是用來存放可運行的進程

你可能會發現 stop_sched_class 和 idle_sched_class 並沒有對應的子隊列,是的,因爲這兩種調度方式都屬於極端的情況,內核只有在某些特殊情況下才會讓進程使用這兩個調度類,所以並沒有維護它們的子隊列

所以關於運行隊列,我們可以使用下圖描述

在這裏插入圖片描述

調度類只會去操作其對應的調度類,一個可運行狀態的進程同一時間只運行存放在運行隊列的一個子隊列中,具體存放在哪一個,取決於它所選擇的調度類

爲了配合相應的隊列使用,在 task_struct 也定義了相應的調度實體,你可以理解成它是相應隊列中的一個節點,如下

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

struct task_struct {
    ...
	struct sched_entity		se;
	struct sched_rt_entity		rt;
	struct sched_dl_entity		dl;
    ...
};

在這裏我們至少稍微介紹了調度隊列二和調度實體,關於詳細的講解,將在下面具體的調度算法講解中說明

到此,內核進程調度所維護的數據結構以及各個模塊之間的關係大體介紹完了,這裏還未涉及到內核調度具體的算法實現,此外進程優先級級的作用還談到,其實它的作用將在調度算法中體現

進程調度算法的實現都在調度類中,其中 rt_sched_class 實現了 FIFO 和 RR 調度算法,fair_sched_class 實現了完全公平調度算法,我們下面也將討論這兩個調度類,其它調度類這裏就不講解了

六、FIFO 和 RR 調度算法

FIFO 和 RR 調度算法,這兩種調度算法都屬於實時進程的調度方式

FIFO 表示先進先出的調度算法,使用該調度策略的進程會一直運行,直到被更高優先級的進程搶佔或者自願讓出CPU

RR 表示時間片輪轉調度算法,也就是說每個進程都有相應的時間片,當時間片運行完之後,會將進程放入放到隊列尾,如此輪轉

rt_sched_class 調度類中實現了這兩種算法,下面我們將來分析它的運行邏輯

在討論 rt_sched_class 之前,我們先來分析其對應的隊列以及調度實體是怎樣的

運行隊列

rt_sched_class 其對應的調度隊列爲 struct rt_rq,其定義如下

struct rt_rq {
    /* 用來存放可運行的進程 */
	struct rt_prio_array	active;
	...
    /* 指向對應的運行隊列 */
    struct rq		*rq;

	...
};

我省略了許多成員變量,其中最重要的成員就是 struct rt_prio_array active,其定義如下

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

#define MAX_USER_RT_PRIO	100
#define MAX_RT_PRIO		MAX_USER_RT_PRIO

struct rt_prio_array {
	DECLARE_BITMAP(bitmap, MAX_RT_PRIO+1); /* 使用位圖標記哪個隊列中有數據 */
	struct list_head queue[MAX_RT_PRIO]; /* 每種優先級有自己的隊列 */
};

rt_prio_array 中定義了一個位圖 bitmap

還有一個數組 queue,數組有 MAX_RT_PRIO(100) 個元素,每個元素都是一個鏈表頭,表示一個隊列

數組的下表表示優先級,也就是說使用 rt_sched_class 調度類的可運行進程會根據其優先級添加到相應的隊列中。數組有 100 個元素,也就是 100 個優先級,剛好對應實時進程的優先級範圍(0-99)

在這裏插入圖片描述

因爲一般不是每個隊列上都有進程,所以使用一個 bitmap 來標記,方便快速查找

所以 rt_rq 的真正形式是下面這樣子的

在這裏插入圖片描述

調度實體

看完運行隊列後,下面再來看看對應的調度實體

在 task_struct 中,定義了這樣的成員

struct task_struct {
    ...
	struct sched_rt_entity		rt;
    ...
};

sched_rt_entity 就是 rt_rq 隊列對應的調度實體,你可以將其理解爲它是隊列中的一個節點

sched_rt_entity 的定義如下(我只保留了最重要的成員)

struct sched_rt_entity {
	struct list_head		run_list;
    ...
	unsigned int			time_slice;
	...
    unsigned short			on_rq;
	...
} __randomize_layout;
  • run_list:鏈表節點,用於插入運行隊列中
  • time_slice:時間片,此變量只用於RR調度策略,對於FIFO不使用
  • on_rq:表示當前進程是否在運行隊列上

下面我們開始來分析 rt_sched_class 調度類,爲了弄清楚 rt_sched_class 是如何工作的,我們只需要分析三個方法

  • 將進程添加到運行隊列
  • 從運行隊列挑選下一個任務
  • 週期性調度器檢查是否需要重新調度

將進程添加到運行隊列

首先來看如何將進程添加到運行隊列中

在內核中要將一個進程添加進運行隊列,總是通過下面的方法

static inline void enqueue_task(struct rq *rq, struct task_struct *p, int flags)
{
    p->sched_class->enqueue_task(rq, p, flags);
}

可見 enqueue_task 總是轉而調用進程所指的調度類的 enqueue_task

對於 rt_sched_class 的 enqueue_task,對應的就是 enqueue_task_rt,定義如下

static void
enqueue_task_rt(struct rq *rq, struct task_struct *p, int flags)
{
    /* 取得調度實體 */
    struct sched_rt_entity *rt_se = &p->rt;
    ...
	/* 將進程添加到運行隊列中 */
    enqueue_rt_entity(rt_se, flags);
	...
}

首先取得進程的調度實體,這裏使用的是 rt_sched_class,所以它的調度實體肯定是 sched_rt_entity,然後通過 enqueue_rt_entity 將其添加到運行隊列中,下面我們來看 enqueue_rt_entity 的實現

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

static void enqueue_rt_entity(struct sched_rt_entity *rt_se, unsigned int flags)
{
    ...
    __enqueue_rt_entity(rt_se, flags);
    ...
}
static void __enqueue_rt_entity(struct sched_rt_entity *rt_se, unsigned int flags)
{
    struct rt_rq *rt_rq = rt_rq_of_se(rt_se); //獲取運行隊列
    struct rt_prio_array *array = &rt_rq->active;
    struct list_head *queue = array->queue + rt_se_prio(rt_se); //根據優先級找到對應的隊列
    
    list_add_tail(&rt_se->run_list, queue); //添加到指定隊列中
    __set_bit(rt_se_prio(rt_se), array->bitmap); //設置bitmap
    
    rt_se->on_rq = 1; //設置進程在運行隊列上的標誌
}

首先得到對應的運行隊列 rt_rq,還記得我們上面講的 rt_rq,它的形式如下

在這裏插入圖片描述

然後會根據優先級找到 rt_rq 中對應的隊列,再將調度實體添加到對應隊列中,然後再設置bitmap標記存在進程的隊列

進程的優先級是通過 rt_se_prio 函數獲取的,我們來看看它的定義

static inline int rt_se_prio(struct sched_rt_entity *rt_se)
{
    return rt_task_of(rt_se)->prio;
}

它返回了 task_struct 中的 prio

好了,到這裏你應該知道了 rt_sched_class 如何將一個進程添加到運行隊列中,下面我們再來看看它是如何挑選下一個可運行的進程的

從運行隊列挑選下一個任務

上面我們說過,在 schedule 函數中,會調用 pick_next_task,按照調度類的優先級遍歷調度,獲取下一個可運行的進程,如下

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
	...
     
	/* 遍歷調度類來挑選進程 */
	for_each_class(class) {
    	p = class->pick_next_task(rq, prev, rf);
        if (p) {
            return p;
        }
    }
}

對於 rt_sched_class ,它的 pick_next_task 就是 pick_next_task_rt,其定義如下(這裏我省略了許多內容,只保留了主要的邏輯)

static struct task_struct *
pick_next_task_rt(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
    ...
	p = _pick_next_task_rt(rq);
    ...
	return p;
}

我們再來看 _pick_next_task_rt 的定義

static struct task_struct *_pick_next_task_rt(struct rq *rq)
{
    struct sched_rt_entity *rt_se;
    struct rt_rq *rt_rq  = &rq->rt;
    ...
    rt_se = pick_next_rt_entity(rq, rt_rq);
	...
	p = rt_task_of(rt_se);
    p->se.exec_start = rq_clock_task(rq);
    
    return p;
}

首先從運行隊列 struct rq 中取得 struct rt_rq,然後通過 pick_next_rt_entity 來獲取隊列中下一個運行的進程,返回的是調度實體,最後通過 rt_task_of 將調度實體轉化爲進程描述符 task_struct,然後再設置進程開始運行的時間

其中 pick_next_rt_entity 的定義如下

static struct sched_rt_entity *pick_next_rt_entity(struct rq *rq,
						   struct rt_rq *rt_rq)
{
	struct rt_prio_array *array = &rt_rq->active;
	idx = sched_find_first_bit(array->bitmap);
    
    queue = array->queue + idx;
    next = list_entry(queue->next, struct sched_rt_entity, run_list);
    
    return next;
}

首先通過 sched_find_first_bit,使用 bitmap 查找 rt_rq 第一個存在進程的隊列,然後返回隊列的第一個進程

從這裏可以看出,優先級越高的進程會優先被挑選

到這裏如果使用 rt_sched_class 調度類挑選下一個進程已經分析完了

週期性調度器檢查是否需要重新調度

我們先來回顧一下下面這張圖

在這裏插入圖片描述

我們說過,滴答定時器會週期性產生定時器中斷,然後CPU會去處理定時器中斷,中斷函數會定時的檢查是否需要重新調度,如果需要重新調度,那麼就會在current進程設置需要重新調度標誌

在內核中這個中斷處理函數爲 scheduler_tick,其定義如下

void scheduler_tick(void)
{
    int cpu = smp_processor_id();
    struct rq *rq = cpu_rq(cpu);
    struct task_struct *curr = rq->curr;
    ...
    curr->sched_class->task_tick(rq, curr, 0);
    ...
}

首先獲取當前CPU對應的運行隊列,然後調用current進程對應的調度類的 task_tick 函數

對於 rt_sched_class,對應的就是 task_tick_rt,其定義如下

static void task_tick_rt(struct rq *rq, struct task_struct *p, int queued)
{
    ...
	/* 如果調度策略不是RR,那麼就退出 */
	if (p->policy != SCHED_RR)
        return;
	...
	/* 消耗時間片 */
	if (--p->rt.time_slice)
        return; //時間片沒有用完
    
    /* 時間片用完了,重新設置時間片 */
    p->rt.time_slice = sched_rr_timeslice;
    
    ...
    /* 將進程放到隊列尾 */
    requeue_task_rt(rq, p, 0);
    
    /* 設置需要重新調度標誌 */
    resched_curr(rq);
}

首先如果進程的調度策略不是 SCHED_RR,也就是說現在的調度策略是 SCHED_FIFO,那就直接退出

這也對應了 FIFO 算法,進程在運行的時候是不會被搶佔的,只能自己讓出CPU

如果進程的調度策略是 SCHED_RR,那麼就減少時間片,如果時間片沒用完,那麼就退出

如果時間片用完了,那麼就重新設置時間片,然後將進程重新放到隊列尾部,再在current進程設置需要重新調度的標誌

這也對應了RR算法,每個進程都有時間片,一旦時間片用完後,會再次將進程添加到隊列尾部

下面看看 requeue_task_rt 的調用過程

requeue_task_rt
	requeue_rt_entity

static void
requeue_rt_entity(struct rt_rq *rt_rq, struct sched_rt_entity *rt_se, int head)
{
    struct rt_prio_array *array = &rt_rq->active;
    struct list_head *queue = array->queue + rt_se_prio(rt_se);
    
    list_move_tail(&rt_se->run_list, queue);
}

看 resched_curr 是如何在current進程設置需要重新調度標誌的

void resched_curr(struct rq *rq)
{
    ...
    set_tsk_need_resched(curr); 
	...
}
static inline void set_tsk_need_resched(struct task_struct *tsk)
{
	set_tsk_thread_flag(tsk,TIF_NEED_RESCHED);
}

最終將會在進程的 thread_info 的 flag 設置 TIF_NEED_RESCHED 標誌,至於 thread_info 是什麼,請看上一篇文章

在設置了需要重新調度的標誌後,在系統調用或者中斷返回的時候,會檢查該標誌,如果設置了,會調用 schedule 函數發生進程切換

關於 rt_sched_class 調度類的實現,到此告一段落,接下來分析及其重要的完全公平調度算法

七、完全公平調度算法(CFS)

完全公平調度算法的英文全稱爲 Completely Fair Scheduler,所以簡稱爲 CFS

在內核中,大多數進程都是普通進程,只有少部分是實時進程,所以對於普通進程的調度算法的實現尤爲重要

CFS 稱爲完全公平調度算法,聽起來就非常公平,那麼它是怎麼實現的呢?下面我將爲你一一揭曉

CFS算法的原理

在 CFS 中,已經沒有時間片的概念了,轉而變成每個進程都有自己的運行時間,我們稱之爲 vruntime

如果進程在運行,那麼 vruntime 就會增加,如果進程在睡眠,那麼 vruntime 不會增加。CFS算法的目的就是確保每個進程的 vruntime 一樣,也就是確保每個進程運行一樣長的時間

CFS 每次都會挑選隊列中 vruntime 最小的進程來運行,然後隨着時間的增加,進程的 vruntime 也在增加,當它不是當前可運行進程中 vruntime 最小的進程的時候,那麼它就會被搶佔

以上就是CFS算法的基本原理

現在思考一個問題,如果 CFS 只是上述那樣簡單的實現,那也就意味着所有的進程都被平等對待,這樣子進程的優先級也就沒有意義了,所以 CFS 不可能如此簡單地實現

實現上對於每個進程,還多了一個權重的概念,權重是和進程的優先級是息息相關的,優先級越高(優先級數值越小),權重就越大,進程運行的實際時間就越長

那麼具體是怎麼實現的呢?

其實 CFS 最基本的想法還是去保持每個進程的 vruntime 相等,只是 vruntime 並不是表示實際的運行時間,而是虛擬運行時間。vruntime 的計算是和權重息息相關的,在內核中是通過下面方法計算的

vruntime += 實際運行時間 * NICE_0_LOAD / 權重

其中的 NICE_0_LOAD 是一個常量,所以從這個式子中可以看到,進程的權重越大,那麼 vruntime 的增加就越慢。而CFS保持的是所有進程的 vruntime 相等,所以優先級越高的進程,權重越大,vruntime 增加得越慢,自然其實際運行時間就越長,看進程的優先級在此就發揮作用了

權重的設置

下面來看看權重和進程的優先級有什麼關聯(下面的代碼其實我省略了很多內容,甚至省略了調用過程,不過無傷大雅,這樣足以爲你展示這個函數的真正邏輯)

const int sched_prio_to_weight[40] = {
 /* -20 */     88761,     71755,     56483,     46273,     36291,
 /* -15 */     29154,     23254,     18705,     14949,     11916,
 /* -10 */      9548,      7620,      6100,      4904,      3906,
 /*  -5 */      3121,      2501,      1991,      1586,      1277,
 /*   0 */      1024,       820,       655,       526,       423, //nice=0時,權重等於1024
 /*   5 */       335,       272,       215,       172,       137,
 /*  10 */       110,        87,        70,        56,        45,
 /*  15 */        36,        29,        23,        18,        15,
};

static void set_load_weight(struct task_struct *p, bool update_load)
{
    int prio = p->static_prio - MAX_RT_PRIO;
    ...
	load->weight = sched_prio_to_weight[prio];
	...
}

可以看到,設置權重其實就是根據優先級從已經計算好數值的數組中挑選一項,數據剛好有40項,就是對應nice的範圍

在這裏插入圖片描述

這些權重的值有什麼關係呢?

其實相鄰兩個數之間是 1.25 倍的關係,如果優先級減1,也就是進程的優先級變高,那麼進程佔CPU份額就多10%,下面我來計算給你看

上面已經提到了,vruntime 的增量可以有下面公式計算

vruntime += 實際運行時間 * NICE_0_LOAD / 權重

其中的 NICE_0_LOAD 表示的是 nice 爲0的權重,從數組中看到的是 1024

我們可以將公式變換一下,可以得到實際的運行時間和虛擬時間增量的關係如下

實際運行時間 = vruntime增量 * 權重 / NICE_0_LOAD

我們現在取兩個相鄰的優先級,取 nice=0 和 nice = 1 的情況,假設vruntime增量是10,可以計算得下面關係

nice 權重 vruntime增量 實際運行時間
0 1024 10 10
-1 1277 10 12.4

即後者比前者多運行了 2.4 的時間,約佔總的時間的 10%,也就是多佔了10%的CPU份額

實現CFS算法的調度是 fair_sched_class,在討論它之前,我們先來來看看它對應的運行隊列和調度實體

運行隊列

CFS要求快速地選出當前隊列中 vruntime 最小的進程,Linux內核採用了紅黑樹這種數據結構,如果你不瞭解紅黑樹也沒關係,只需要知道它支持快速插入和刪除,自動排序,樹的最左邊節點的值最小就行

fair_sched_class 的隊列是以 vruntime 爲鍵值實現的紅黑樹,所以對於 cfs_rq,它其實是下面這樣子的

在這裏插入圖片描述

cfs_rq 在內核中的定義如下(我省略了絕大多數成員)

struct rb_root_cached {
	struct rb_root rb_root; //紅黑樹根節點
	struct rb_node *rb_leftmost; //緩存最左端節點
};

struct cfs_rq {
    ...
	struct rb_root_cached	tasks_timeline;
	...
};

rb_root_cached 成員中有兩個變量,一個爲紅黑樹的根節點,一個變量用於緩存紅黑樹最左邊的節點

調度實體

下面來看看調度實體,其包含在 task_struct 中,如下

struct task_struct {
	...
	struct sched_entity		se; 
	...
};

sched_entity 的定義如下(同樣的,我也省略了大部分成員)

struct sched_entity {
	struct load_weight		load; //權重
    ...
    struct rb_node			run_node; //紅黑樹節點
    unsigned int			on_rq; //進程是否在運行隊列上標記
    ...
	u64				vruntime; //虛擬運行時間
    ...
};

我想在這裏看到這些變量你應該很熟悉了吧,這些在我們上面介紹CFS算法的時候,都有涉及到

下面我們開始分析內核對CFS算法的實現,其對應的調度類爲 fair_sched_class,這裏我們將討論它的以下方法

  • 入隊列
  • 挑選下一個運行進程
  • 週期調度器處理函數

入隊列操作

fair_sched_class 調度類的入隊列操作爲 enqueue_task_fair,其定義如下

static void
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
    ...
    enqueue_entity(cfs_rq, se, flags);
    ...
}
static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    __enqueue_entity(cfs_rq, se);
}
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    /* 找到插入位置 */
 	while (*link) {
    	...
		if (entity_before(se, entry)) {
			link = &parent->rb_left;
		} else {
			link = &parent->rb_right;
			leftmost = false;
		}
    }
    
    /* 將調度實體加入紅黑樹中 */
    rb_link_node(&se->run_node, parent, link);
    rb_insert_color_cached(&se->run_node,
                           &cfs_rq->tasks_timeline, leftmost);
}

關於紅黑樹的插入過程這裏不會討論,我們來看看它是如何找到插入位置的,從上面代碼中可以看到,它是通過 entity_before 來判斷的,entity_before 的定義如下

static inline int entity_before(struct sched_entity *a,
				struct sched_entity *b)
{
	return (s64)(a->vruntime - b->vruntime) < 0;
}

其實就是比較兩個節點的 vruntime,所以這棵紅黑樹是以 vruntime 作爲鍵值的

挑選下一個運行進程

下面我們來看看 fair_sched_class 是如何挑選下一個任務的,其對應的函數爲 pick_next_task_fair,定義如下

static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
	struct cfs_rq *cfs_rq = &rq->cfs;
	struct sched_entity *se;
	struct task_struct *p;
    ...
	se = pick_next_entity(cfs_rq, curr);
    ...
	p = task_of(se);
    ...
	return p;
}

可以看到,是通過 pick_next_entity 來獲取下一個調度實體的,

static struct sched_entity *
pick_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
    struct sched_entity *left = __pick_first_entity(cfs_rq); //試圖獲取緩存的最左端節點
    struct sched_entity *se;
    
    /* 如果紅黑樹中已經沒有節點了,或者獲取的節點 vruntime 大於current進程 */
    if (!left || (curr && entity_before(curr, left)))
        left = curr;

    se = left;
    ...
    return se;
}

首先試圖去獲取紅黑樹最左端的節點,如果紅黑樹中沒有節點了,或者獲取的節點的 vruntime 大於current進程,那麼就返回current進程對應的調度實體,否則返回獲取到的節點的調度實體

週期調度器處理函數

在介紹實時進程的調度算法的時候,我們已經說過每次滴答時鐘中斷都會調用 scheduler_tick 進行處理,這個函數會轉而調用current進程指向的調度類的 task_tick,對於 fair_sched_class,就是 task_tick_fair,其定義如下

static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
    struct cfs_rq *cfs_rq;
    struct sched_entity *se = &curr->se;
    
    cfs_rq = cfs_rq_of(se); //獲取cfs的運行隊列
    entity_tick(cfs_rq, se, queued);
    ...
}

entity_tick 的定義如下

static void
entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
{
	/* 更新運行時間統計 */
    update_curr(cfs_rq);

    /* 如果運行隊列上的進程個數大於1,那就檢查是否需要發生搶佔 */
    if (cfs_rq->nr_running > 1)
        check_preempt_tick(cfs_rq, curr);
}

首先會調用 update_curr 來更新運行的統計時間,然後如果運行隊列上的進程大於1,那麼就調用 check_preempt_tick 來決定是否需要發生搶佔

我們先來看一看 update_curr,其定義如下

static void update_curr(struct cfs_rq *cfs_rq)
{
    u64 now = rq_clock_task(rq_of(cfs_rq)); //現在的時間
    delta_exec = now - curr->exec_start; //實際運行時間
    ...
    curr->exec_start = now; //更新進程開始運行的時間
    ...
    curr->vruntime += calc_delta_fair(delta_exec, curr); //由實際運行時間計算虛擬運行時間
    ...
}

從註釋中你應該可以看得很清楚了,其中的主要操作就是更新 vruntime,我這裏就不詳細說明了

下面再看看 check_preempt_tick 的定義

static void
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
    ...
    se = __pick_first_entity(cfs_rq); //獲取紅黑樹最左端的節點
    
    
    delta = curr->vruntime - se->vruntime;
    if (delta < 0) //如果current進程的 vruntime 小於紅黑樹最左端節點
        return; //那麼就退出
    
    /* 否則,設置需要重新調度的標誌 */
    resched_curr(rq_of(cfs_rq));
}

首先從紅黑樹中挑選出最左端的節點,然後和current進程的 vruntime 進行對比,如果大於,那麼就退出;如果小於,就調用 resched_curr,再 current 進程設置需要重新調度的標誌

至此,進程調度的內容基本就將完了,下面來總結以下

八、總結

  • 在學習進程調度之前,你必須先明確進程調度解決什麼問題
  • 在學習Linux內核進程調度機制之前,我希望你先把進程調度的基本框架搞明白,如果搞明白了,學起來就輕鬆許多
  • 內核中有多種調度策略可以選擇,每種調度策略都表示相應的調度算法
  • 內核中使用調度類來實現相應的算法,相應的調度策略會映射到相應的調度類

最後,上面的圖由於是我邊畫邊截的圖,所以可能有點模糊,下面給你一張高清點的圖

在這裏插入圖片描述

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