Linux程式排程邏輯與原始碼分析

lumin發表於2019-02-13

本文是《Linux核心設計與實現》第四章的閱讀筆記,程式碼則是摘自最新的4.6版本linux原始碼(github),轉載請註明出處。

多工

併發和並行

Linux作為一個多工作業系統,必須支援程式的併發執行。

分類

  1. 非搶佔式多工

    除非任務自己結束,否則將會一直執行。

  2. 搶佔式多工(Linux)

    這種情況下,由排程程式來決定什麼時候停止一個程式的執行,這個強制的掛起動作即為搶佔 。採用搶佔式多工的基礎是使用時間片輪轉機制來為每個程式分配可以執行的時間單位。

Linux程式排程

發展歷史

Linux從2.5版本開始引入一種名為O(1)的排程器,後在2.6版本中將公平的的排程概念引入了排程程式,代替之前的排程器,稱為CFS演算法(完全公平排程演算法)。

策略

I/O消耗型和處理器消耗型

I/O消耗型程式是指那些大部分時間都在等待I/O操作的程式,處理器耗費型的程式則是指把大多數時間用於執行程式碼的程式,除非被搶佔,他們一般都一直在執行。

為了保證互動式應用和桌面系統的效能,一般Linux更傾向於優先排程I/O消耗型程式。

程式優先順序

Linux採用了兩種不同的優先順序範圍。

  1. 使用nice值:越大的nice值意味著更低的優先順序。 (-19 ~ 20之間)

  2. 實時優先順序:可配置,越高意味著程式優先順序越高。

    任何實時的程式優先順序都高於普通的程式,因此上面的兩種優先順序範圍處於互不相交的範疇。

  3. 時間片:Linux中並不是以固定的時間值(如10ms)來分配時間片的,而是將處理器的使用比作為“時間片”劃分給程式。這樣,程式所獲得的實際CPU時間就和系統的負載密切相關。

Linux中的搶佔時機取決於新的可執行程式消耗了多少處理器使用比,
如果消耗的使用比當前程式小,則立刻投入執行,否則將推遲其執行。
複製程式碼

舉例

現在我們來看一個簡單的例子,假設我們的系統只有兩個程式在執行,一個是文字編輯器(I/O消耗型),另一個是視訊解碼器(處理器消耗型)。

理想的情況下,文字編輯器應該得到更多的處理器時間,至少當它需要處理器時,處理器應該立刻被分配給它(這樣才能完成使用者的互動),這也就意味著當文字編輯器被喚醒的時候,它應該搶佔視訊解碼程式。

按照普通的情況,OS應該分配給文字編輯器更大的優先順序和更多的時間片,但在Linux中,這兩個程式都是普通程式,他們具有相同的nice值,因此它們將得到相同的處理器使用比(50%)。

但實際的執行過程中會發生什麼呢?CFS將能夠注意到,文字編輯器使用的處理器時間比分配給它的要少得多(因為大多時間在等待I/O),這種情況下,要實現所有程式“公平”地分享處理器,就會讓文字編輯器在需要執行時立刻搶佔視訊解碼器(每次都是如此)。

Linux排程演算法

排程器類

Linux的排程器是以模組的方式提供的,這樣使得不同型別的程式按照自己的需要來選擇不同的排程演算法。

上面說講到的CFS演算法就是一個針對普通程式的排程器類,基礎的排程器會按照優先順序順序遍歷排程類,擁有一個可執行程式的最高優先順序的排程器類勝出,由它來選擇下一個要執行的程式。

Unix中的程式排程

存在的問題:

  1. nice值必須對映到處理器的絕對時間上去,這意味著同樣是瓜分100ms的兩個同樣優先順序的程式,發生上下文切換的次數並不相同,可能會差別很大。優先順序越低的程式分到的時間片單位越小,但是實際上他們往往是需要進行大量後臺計算的,這樣很不合理。

  2. 相對的nice值引發的問題:兩個nice值不同但差值相同的程式,分到的時間片的大小是受到其nice值大小影響的:比如nice值18和19的兩個程式分到的時間片是10ms和5ms,nice值為0和1的兩個程式分到的卻是100ms和95ms,這樣的對映並不合理。

  3. 如果要進行nice值到時間片的對映,我們必須能夠擁有一個可以測量的“絕對時間片”(這牽扯到定時器和節拍器的相關概念)。實際上,時間片是會隨著定時器的節拍而改變的,同樣的nice值最終對映到處理器時間時可能會存在差異。

  4. 為了能夠更快的喚醒程式,需要對新的要喚醒的程式提升優先順序,但是這可能會打破“公平性”。

為了解決上述的問題,CFS對時間片的分配方式進行了根本性的重新設計,摒棄了時間片,用處理器使用比重來代替它。

公平排程(CFS)

出發點:程式排程的效果應該如同系統具備一個理想的多工處理器——我們可以給任何程式排程無限小的時間週期,所以在任何可測量範圍內,可以給n個程式桐鄉多的執行時間。

舉個例子來區分Unix排程和CFS:有兩個執行的優先順序相同的程式,在Unix中可能是每個各執行5ms,執行期間完全佔用處理器,但在“理想情況”下,應該是,能夠在10ms內同時執行兩個程式,每個佔用處理器一半的能力。

CFS的做法是:在所有可執行程式的總數上計算出一個程式應該執行的時間,nice值不再作為時間片分配的標準,而是用於處理計算獲得的處理器使用權重。

接下來我們考慮排程週期,理論上,排程週期越小,就越接近“完美排程”,但實際上這必然會帶來嚴重的上下文切換消耗。在CFS中,為能夠實現的最小排程週期設定了一個近似值目標,稱為“目標延遲”,於此同時,為了避免不可接受的上下文切換消耗,為每個程式所能獲得的時間片大小設定了一個底線——最小粒度(通常為1ms)。

在每個程式的平均執行時間大於最小粒度的情況下,CFS無疑是公平的,nice值用於計算一個程式在當前這個最小排程週期中所應獲得的處理器時間佔比,這樣就算nice值不同,只要差值相同,總是能得到相同的時間片。我們假設一個最小排程週期為20ms,兩個程式的nice值差值為5:

  • 兩程式的nice值分別為0和5,後者獲得的時間片是前者的1/3,因此最終分別獲得15ms和5ms
  • 兩程式的nice值分別為10和15,後者獲得的時間片是前者的1/3,最終結果也是15ms和5ms

關於上面這個推論,可能有些難以理解,所以我們深入一下,看看在底層nice差值究竟是如何影響到處理區佔比的。

首先,在底層,在實際計算一個程式的處理器佔比之前,核心會先把nice值轉換為一個權重值weight,這個轉換的公式如下:

weight = 1024/(1.25^nice)
複製程式碼

舉個例子,預設nice值的程式得到的權重就是1024/(1.25^0) = 1024/1 = 1024。

這個轉換公式保證了我們可以得到非負的權重值,並且nice對權重的影響是在指數上的。

好,現在假設我們的可執行程式佇列中有n個程式,他們的權重和w(1)+w(2)+...+w(n)記為w(queue),那麼任意一個程式i最終得到的處理器佔比將是w(i)/w(queue)

接著,我們不難推匯出,任意兩個程式i和j所分配的到的處理器佔比的比例應該是w(i)/w(j),經過簡單的數學推導就可以得到最後的結果:1.25^(nice(i)-nice(j)),這意味著只要兩個nice值的差值相同,兩個程式所獲得處理器佔比永遠是相同的比例,從而解決了上面的第3點問題。

上述的轉換公式參考自:https://oakbytes.wordpress.com/2012/06/06/linux-scheduler-cfs-and-nice

總結一下,任何程式所獲得的處理器時間是由它自己和所有其他可執行程式nice值的相對差值決定的,因此我們可以說,CFS至少保證了給每個程式公平的處理器佔用比,算是一種近乎完美的多工排程方式了。

Linux排程的實現

下面我們來看看CFS是如何實現的,一般我們把它分為4個主要的部分來分析。

時間記賬

所有的排程器都必須對程式的執行時間記賬,換句話說就是要知道當前排程週期內,程式還剩下多少個時間片可用(這將會是搶佔的一個重要標準)

1. 排程器實體結構

CFS中用於記錄程式執行時間的資料結構為“排程實體”,這個結構體被定義在<linux/sched.h>中:

struct sched_entity {
	/* 用於進行排程均衡的相關變數,主要跟紅黑樹有關 */
	struct load_weight		load; // 權重,跟優先順序有關
	unsigned long			runnable_weight; // 在所有可執行程式中所佔的權重
	struct rb_node			run_node; // 紅黑樹的節點
	struct list_head		group_node; // 所在程式組
	unsigned int			on_rq; // 標記是否處於紅黑樹執行佇列中

	u64				exec_start; // 程式開始執行的時間
	u64				sum_exec_runtime; // 程式總執行時間
	u64				vruntime; // 虛擬執行時間,下面會給出詳細解釋
	u64				prev_sum_exec_runtime; // 程式在切換CPU時的sum_exec_runtime,簡單說就是上個排程週期中執行的總時間

	u64				nr_migrations;

	struct sched_statistics		statistics;
	
	// 以下省略了一些在特定巨集條件下才會啟用的變數
}
複製程式碼

注:本文中所有用到的linux原始碼均來自linux在github上官方的git庫(2018.01)

2. 虛擬實時 (vruntime)

現在我們來談談上面結構體中的vruntime變數所表示的意義。我們稱它為“虛擬執行時間”,該執行時間的計算是經過了所有可執行程式總數的標準化(簡單說就是加權的)。它以ns為單位,與定時器節拍不再相關。

可以認為這是CFS為了能夠實現理想多工處理而不得不虛擬的一個新的時鐘,具體地講,一個程式的vruntime會隨著執行時間的增加而增加,但這個增加的速度由它所佔的權重load來決定。

結果就是權重越高,增長越慢:所得到的排程時間也就越小 —— CFS用它來記錄一個程式到底執行了多長時間以及還應該執行多久。

下面我們來看一下這個記賬功能的實現原始碼(kernel/sched/fair.c)

/*
 * Update the current task`s runtime statistics.
 */
static void update_curr(struct cfs_rq *cfs_rq)
{
	struct sched_entity *curr = cfs_rq->curr;
	u64 now = rq_clock_task(rq_of(cfs_rq));
	u64 delta_exec;

	if (unlikely(!curr))
		return;
	
	// 獲得從最後一次修改負載後當前任務所佔用的執行總時間
	delta_exec = now - curr->exec_start;
	if (unlikely((s64)delta_exec <= 0))
		return;
		
	// 更新執行開始時間
	curr->exec_start = now;

	schedstat_set(curr->statistics.exec_max,
		      max(delta_exec, curr->statistics.exec_max));

	curr->sum_exec_runtime += delta_exec;
	schedstat_add(cfs_rq->exec_clock, delta_exec);

	// 計算虛擬時間,具體的轉換演算法寫在clac_delta_fair函式中
	curr->vruntime += calc_delta_fair(delta_exec, curr);
	update_min_vruntime(cfs_rq);

	if (entity_is_task(curr)) {
		struct task_struct *curtask = task_of(curr);

		trace_sched_stat_runtime(curtask, delta_exec, curr->vruntime);
		cgroup_account_cputime(curtask, delta_exec);
		account_group_exec_runtime(curtask, delta_exec);
	}

	account_cfs_rq_runtime(cfs_rq, delta_exec);
}
複製程式碼

該函式計算了當前程式的執行時間,將其存放在delta_exec變數中,然後使用clac_delta_fair函式計算對應的虛擬執行時間,並更新vruntime值。

這個函式是由系統定時器週期性呼叫的(無論程式的狀態是什麼),因此vruntime可以準確地測量給定程式的執行時間,並以此為依據推斷出下一個要執行的程式是什麼。

程式選擇

這裡便是排程的核心部分,用一句話來梗概CFS演算法的核心就是選擇具有最小vruntime的程式作為下一個需要排程的程式。

為了實現選擇,當然要維護一個可執行的程式佇列(教科書上常說的ready佇列),CFS使用了紅黑樹來組織這個佇列。

紅黑樹是一種非常著名的資料結構,但這裡我們不討論它的實現和諸多特性(過於複雜),我們記住:紅黑樹是一種自平衡二叉樹,再簡單一點,它是一種以樹節點方式儲存資料的結構,每個節點對應了一個鍵值,利用這個鍵值可以快速索引樹上的資料,並且它可以按照一定的規則自動調整每個節點的位置,使得通過鍵值檢索到對應節點的速度和整個樹節點的規模呈指數比關係。

1. 找到下一個任務節點

先假設一個紅黑樹儲存了系統中所有的可執行程式,節點的鍵值就是它們的vruntime,CFS現在要找到下一個需要排程的程式,那麼就是要找到這棵紅黑樹上鍵值最小的那個節點:就是最左葉子節點。

實現此過程的原始碼如下(kernel/sched/fair.c):

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;

	/*
	 * If curr is set we have to see if its left of the leftmost entity
	 * still in the tree, provided there was anything in the tree at all.
	 */
	if (!left || (curr && entity_before(curr, left)))
		left = curr;

	se = left; /* ideally we run the leftmost entity */

	/*
	 * 下面的過程主要針對一些特殊情況,我們在此不做討論
	 */
	if (cfs_rq->skip == se) {
		struct sched_entity *second;

		if (se == curr) {
			second = __pick_first_entity(cfs_rq);
		} else {
			second = __pick_next_entity(se);
			if (!second || (curr && entity_before(curr, second)))
				second = curr;
		}

		if (second && wakeup_preempt_entity(second, left) < 1)
			se = second;
	}

	if (cfs_rq->last && wakeup_preempt_entity(cfs_rq->last, left) < 1)
		se = cfs_rq->last;

	if (cfs_rq->next && wakeup_preempt_entity(cfs_rq->next, left) < 1)
		se = cfs_rq->next;

	clear_buddies(cfs_rq, se);

	return se;
}
複製程式碼

2. 向佇列中加入新的程式

向可執行佇列中插入一個新的節點,意味著有一個新的程式狀態轉換為可執行,這會發生在兩種情況下:一是當程式由阻塞態被喚醒,二是fork產生新的程式時。

將其加入佇列的過程本質上來說就是紅黑樹插入新節點的過程:

static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
	bool renorm = !(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_MIGRATED);
	bool curr = cfs_rq->curr == se;

	/*
	 * 如果要加入的程式就是當前正在執行的程式,重新規範化vruntime
	 * 然後更新當前任務的執行時統計資料
	 */
	if (renorm && curr)
		se->vruntime += cfs_rq->min_vruntime;

	update_curr(cfs_rq);

	/*
	 * Otherwise, renormalise after, such that we`re placed at the current
	 * moment in time, instead of some random moment in the past. Being
	 * placed in the past could significantly boost this task to the
	 * fairness detriment of existing tasks.
	 */
	if (renorm && !curr)
		se->vruntime += cfs_rq->min_vruntime;

	/*
	 * 更新對應排程器實體的各種記錄值
	 */
	 
	update_load_avg(cfs_rq, se, UPDATE_TG | DO_ATTACH);
	update_cfs_group(se);
	enqueue_runnable_load_avg(cfs_rq, se);
	account_entity_enqueue(cfs_rq, se);

	if (flags & ENQUEUE_WAKEUP)
		place_entity(cfs_rq, se, 0);

	check_schedstat_required();
	update_stats_enqueue(cfs_rq, se, flags);
	check_spread(cfs_rq, se);
	if (!curr)
		__enqueue_entity(cfs_rq, se); // 真正的插入過程
	se->on_rq = 1;

	if (cfs_rq->nr_running == 1) {
		list_add_leaf_cfs_rq(cfs_rq);
		check_enqueue_throttle(cfs_rq);
	}
}
複製程式碼

上面的函式主要用來更新執行時間和各類統計資料,然後呼叫__enqueue_entity()來把資料真正插入紅黑樹中:

static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
	struct rb_node **link = &cfs_rq->tasks_timeline.rb_root.rb_node;
	struct rb_node *parent = NULL;
	struct sched_entity *entry;
	bool leftmost = true;

	/*
	 * 在紅黑樹中搜尋合適的位置
	 */
	while (*link) {
		parent = *link;
		entry = rb_entry(parent, struct sched_entity, run_node);
		/*
		 * 具有相同鍵值的節點會被放在一起
		 */
		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);
}
複製程式碼

while()迴圈是遍歷樹以尋找匹配鍵值的過程,也就是搜尋一顆平衡樹的過程。找到後我們對要插入位置的父節點執行rb_link_node()來將節點插入其中,然後更新紅黑樹的自平衡相關屬性。

3. 從佇列中移除程式

從佇列中刪除一個節點有兩種可能:一是程式執行完畢退出,而是程式受到了阻塞。

static void
dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
	/*
	 * 更新“當前程式”的執行統計資料
	 */
	update_curr(cfs_rq);

	/*
	 * When dequeuing a sched_entity, we must:
	 *   - Update loads to have both entity and cfs_rq synced with now.
	 *   - Substract its load from the cfs_rq->runnable_avg.
	 *   - Substract its previous weight from cfs_rq->load.weight.
	 *   - For group entity, update its weight to reflect the new share
	 *     of its group cfs_rq.
	 */
	update_load_avg(cfs_rq, se, UPDATE_TG);
	dequeue_runnable_load_avg(cfs_rq, se);

	update_stats_dequeue(cfs_rq, se, flags);

	clear_buddies(cfs_rq, se);

	if (se != cfs_rq->curr)
		__dequeue_entity(cfs_rq, se);
	se->on_rq = 0;
	account_entity_dequeue(cfs_rq, se);

	/*
	 * 重新規範化vruntime
	 */
	if (!(flags & DEQUEUE_SLEEP))
		se->vruntime -= cfs_rq->min_vruntime;

	/* return excess runtime on last dequeue */
	return_cfs_rq_runtime(cfs_rq);

	update_cfs_group(se);

	/*
	 * Now advance min_vruntime if @se was the entity holding it back,
	 * except when: DEQUEUE_SAVE && !DEQUEUE_MOVE, in this case we`ll be
	 * put back on, and if we advance min_vruntime, we`ll be placed back
	 * further than we started -- ie. we`ll be penalized.
	 */
	if ((flags & (DEQUEUE_SAVE | DEQUEUE_MOVE)) == DEQUEUE_SAVE)
		update_min_vruntime(cfs_rq);
}
複製程式碼

和插入一樣,實際對樹節點操作的工作由__dequeue_entity()實現:

static void __dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
	rb_erase_cached(&se->run_node, &cfs_rq->tasks_timeline);
}

複製程式碼

可以看到刪除一個節點要比插入簡單的多,這得益於紅黑樹本身實現的rb_erase()函式。

排程器入口

正如上文所述,每當要發生程式的排程時,是有一個統一的入口,從該入口選擇真正需要呼叫的排程類。

這個入口是核心中一個名為schedule()的函式,它會找到一個最高優先順序的排程類,這個排程類擁有自己的可執行佇列,然後向其詢問下一個要執行的程式是誰。

這個函式中唯一重要的事情是執行了pick_next_task()這個函式(定義在kenerl/sched/core.c中),它以優先順序為順序,依次檢查每一個排程類,並且從最高優先順序的排程類中選擇最高優先順序的程式。

static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
	const struct sched_class *class;
	struct task_struct *p;

	/*
	 * 優化:如果當前所有要排程的程式都是普通程式,那麼就直接採用普通程式的排程類(CFS)
	 */
	if (likely((prev->sched_class == &idle_sched_class ||
		    prev->sched_class == &fair_sched_class) &&
		   rq->nr_running == rq->cfs.h_nr_running)) {

		p = fair_sched_class.pick_next_task(rq, prev, rf);
		if (unlikely(p == RETRY_TASK))
			goto again;

		/* Assumes fair_sched_class->next == idle_sched_class */
		if (unlikely(!p))
			p = idle_sched_class.pick_next_task(rq, prev, rf);

		return p;
	}

// 遍歷排程類
again:
	for_each_class(class) {
		p = class->pick_next_task(rq, prev, rf);
		if (p) {
			if (unlikely(p == RETRY_TASK))
				goto again;
			return p;
		}
	}

	/* The idle class should always have a runnable task: */
	BUG();
}
複製程式碼

每個排程類都實現了pick_next_task()方法,它會返回下一個可執行程式的指標,沒有則返回NULL。排程器入口從第一個返回非NULL的類中選擇下一個可執行程式。

睡眠和喚醒

睡眠和喚醒的流程在linux中是這樣的:

  • 睡眠:程式將自己標記成休眠狀態,然後從可執行紅黑樹中移除,放入等待佇列,然後呼叫schedule()選擇和執行一個其他程式。
  • 喚醒:程式被設定為可執行狀態,然後從等待佇列移到可執行紅黑樹中去。

休眠在Linux中有兩種狀態,一種會忽略訊號,一種則會在收到訊號的時候被喚醒並響應。不過這兩種狀態的程式是處於同一個等待佇列上的。

1.等待佇列

和可執行佇列的複雜結構不同,等待佇列在linux中的實現只是一個簡單的連結串列。所有有關等待佇列的資料結構被定義在include/linux/wait.h中,具體的實現程式碼則被定義在kernel/sched/wait.c中。

核心使用wait_queue_head_t結構來表示一個等待佇列,它其實就是一個連結串列的頭節點,但是加入了一個自旋鎖來保持一致性(等待佇列在中斷時可以被隨時修改)

struct wait_queue_head {
	spinlock_t		lock;
	struct list_head	head;
};
typedef struct wait_queue_head wait_queue_head_t;
複製程式碼

而休眠的過程需要程式自己把自己加入到一個等待佇列中,這可以使用核心所提供的、推薦的函式來實現。

一個可能的流程如下:

  1. 呼叫巨集DEFINE_WAIT()建立一個等待佇列的項(連結串列的節點)
  2. 呼叫add_wait_queue()把自己加到佇列中去。該佇列會在程式等待的條件滿足時喚醒它,當然喚醒的具體操作需要程式自己定義好(你可以理解為一個回撥)
  3. 呼叫prepare_to_wait()方法把自己的狀態變更為上面說到的兩種休眠狀態中的其中一種。

下面是上述提到的方法的原始碼:

void add_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry)
{
	unsigned long flags;

	wq_entry->flags &= ~WQ_FLAG_EXCLUSIVE;
	spin_lock_irqsave(&wq_head->lock, flags);
	__add_wait_queue(wq_head, wq_entry);
	spin_unlock_irqrestore(&wq_head->lock, flags);
}

static inline void __add_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry)
{
	list_add(&wq_entry->entry, &wq_head->head);
}
複製程式碼
void
prepare_to_wait(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry, int state)
{
	unsigned long flags;

	wq_entry->flags &= ~WQ_FLAG_EXCLUSIVE;
	spin_lock_irqsave(&wq_head->lock, flags);
	if (list_empty(&wq_entry->entry))
		__add_wait_queue(wq_head, wq_entry);
	// 標記自己的程式狀態
	set_current_state(state);
	spin_unlock_irqrestore(&wq_head->lock, flags);
}
複製程式碼

2.喚醒

喚醒操作主要通過wake_up()實現,它會喚醒指定等待佇列上的所有程式。內部由try_to_wake_up()函式將對應的程式標記為TASK_RUNNING狀態,接著呼叫enqueue_task()將程式加入紅黑樹中。

wake_up()系函式由巨集定義,一般具體內部由下面這個函式實現:

/*
 * The core wakeup function. Non-exclusive wakeups (nr_exclusive == 0) just
 * wake everything up. If it`s an exclusive wakeup (nr_exclusive == small +ve
 * number) then we wake all the non-exclusive tasks and one exclusive task.
 *
 * There are circumstances in which we can try to wake a task which has already
 * started to run but is not in state TASK_RUNNING. try_to_wake_up() returns
 * zero in this (rare) case, and we handle it by continuing to scan the queue.
 */
static int __wake_up_common(struct wait_queue_head *wq_head, unsigned int mode,
			int nr_exclusive, int wake_flags, void *key,
			wait_queue_entry_t *bookmark)
{
	wait_queue_entry_t *curr, *next;
	int cnt = 0;

	if (bookmark && (bookmark->flags & WQ_FLAG_BOOKMARK)) {
		curr = list_next_entry(bookmark, entry);

		list_del(&bookmark->entry);
		bookmark->flags = 0;
	} else
		curr = list_first_entry(&wq_head->head, wait_queue_entry_t, entry);

	if (&curr->entry == &wq_head->head)
		return nr_exclusive;

	list_for_each_entry_safe_from(curr, next, &wq_head->head, entry) {
		unsigned flags = curr->flags;
		int ret;

		if (flags & WQ_FLAG_BOOKMARK)
			continue;

		ret = curr->func(curr, mode, wake_flags, key);
		if (ret < 0)
			break;
		if (ret && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
			break;

		if (bookmark && (++cnt > WAITQUEUE_WALK_BREAK_CNT) &&
				(&next->entry != &wq_head->head)) {
			bookmark->flags = WQ_FLAG_BOOKMARK;
			list_add_tail(&bookmark->entry, &next->entry);
			break;
		}
	}
	return nr_exclusive;
}
複製程式碼

搶佔與上下文切換

上下文切換

上下文切換是指從一個可執行程式切換到另一個可執行程式。由定義在kernel/sched/core.ccontext_switch()實現:

static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
	       struct task_struct *next, struct rq_flags *rf)
{
	struct mm_struct *mm, *oldmm;

	prepare_task_switch(rq, prev, next);

	mm = next->mm;
	oldmm = prev->active_mm;
	/*
	 * For paravirt, this is coupled with an exit in switch_to to
	 * combine the page table reload and the switch backend into
	 * one hypercall.
	 */
	arch_start_context_switch(prev);
	
	// 把虛擬記憶體從上一個記憶體對映切換到新程式中
	if (!mm) {
		next->active_mm = oldmm;
		mmgrab(oldmm);
		enter_lazy_tlb(oldmm, next);
	} else
		switch_mm_irqs_off(oldmm, mm, next);

	if (!prev->mm) {
		prev->active_mm = NULL;
		rq->prev_mm = oldmm;
	}

	rq->clock_update_flags &= ~(RQCF_ACT_SKIP|RQCF_REQ_SKIP);

	/*
	 * Since the runqueue lock will be released by the next
	 * task (which is an invalid locking op but in the case
	 * of the scheduler it`s an obvious special-case), so we
	 * do an early lockdep release here:
	 */
	rq_unpin_lock(rq, rf);
	spin_release(&rq->lock.dep_map, 1, _THIS_IP_);

	/* Here we just switch the register state and the stack. */
	// 切換處理器狀態到新程式,這包括儲存、恢復暫存器和棧的相關資訊 
	switch_to(prev, next, prev);
	barrier();

	return finish_task_switch(prev);
}

複製程式碼

上下文切換由schedule()函式在切換程式時呼叫。但是核心必須知道什麼時候呼叫schedule(),如果只靠使用者程式碼顯式地呼叫,程式碼可能會永遠地執行下去。

為此,核心為每個程式設定了一個need_resched標誌來表明是否需要重新執行一次排程,當某個程式應該被搶佔時,scheduler_tick()會設定這個標誌,當一個優先順序高的程式進入可執行狀態的時候,try_to_wake_up()也會設定這個標誌位,核心檢查到此標誌位就會呼叫schedule()重新進行排程。

使用者搶佔

核心即將返回使用者空間的時候,如果need_reshced標誌位被設定,會導致schedule()被呼叫,此時就發生了使用者搶佔。意思是說,既然要重新進行排程,那麼可以繼續執行進入核心態之前的那個程式,也完全可以重新選擇另一個程式來執行,所以如果設定了need_resched,核心就會選擇一個更合適的程式投入執行。

簡單來說有以下兩種情況會發生使用者搶佔:

  • 從系統呼叫返回使用者空間
  • 從中斷處理程式返回使用者空間

核心搶佔

Linux和其他大部分的Unix變體作業系統不同的是,它支援完整的核心搶佔。

不支援核心搶佔的系統意味著:核心程式碼可以一直執行直到它完成為止,核心級的任務執行時無法重新排程,各個任務是以協作方式工作的,並不存在搶佔的可能性。

在Linux中,只要重新排程是安全的,核心就可以在任何時間搶佔正在執行的任務,這個安全是指,只要沒有持有鎖,就可以進行搶佔。

為了支援核心搶佔,Linux做出瞭如下的變動:

  • 為每個程式的thread_info引入了preempt_count計數器,用於記錄持有鎖的數量,當它為0的時候就意味著這個程式是可以被搶佔的。
  • 從中斷返回核心空間的時候,會檢查need_reschedpreempt_count的值,如果need_resched被標記,並且preempt_count為0,就意味著有一個更需要排程的程式需要被排程,而且當前情況是安全的,可以進行搶佔,那麼此時排程程式就會被呼叫。

除了響應中斷後返回,還有一種情況會發生核心搶佔,那就是核心中的程式由於阻塞等原因顯式地呼叫schedule()來進行顯式地核心搶佔:當然,這個程式顯式地呼叫排程程式,就意味著它明白自己是可以安全地被搶佔的,因此我們不用任何額外的邏輯去檢查安全性問題。

下面羅列可能的核心搶佔情況:

  • 中斷處理正在執行,且返回核心空間之前
  • 核心程式碼再一次具有可搶佔性時
  • 核心中的任務顯式地呼叫schedule()
  • 核心中的任務被阻塞

相關文章