Intel Threading Building Blocks 編程指南:任務調度

概述:

Intel Threading Building Blocks (Intel® TBB)是基於任務(task)驅動的。一般來說,只有在TBB提供的算法模板中找不到合適的模板時,才考慮使用任務調度器自行實現。任務(task)是一個邏輯概念,操作系統並沒有提供對應的實現。你可以把它當作線程池的進化。實現時,一個thread可對應多個task。在非阻塞編程時,相對於線程(thread),基於任務的編程有很多優點,比如:

  • task的啓動、停止通常比thread更快
  • task更能匹配有效資源(因爲有TBB的任務調度器)
  • task在編程時使程序員更能專注業務實現而不是底層細節
  • task實現了負載均衡

但是,要記住,task的應用場景是並行,而不是併發(不要企圖把TBB用於Socket之類的併發敲打)。如果一個task被阻塞,其對應的thread也將被阻塞,這樣,運行於thread之上的所有task都將被阻塞。task與thread的關係如下圖:

任務對象的生成

task的定義在task.h中,派生類必須要實現純虛函數execute

//! Should be overridden by derived classes.
    virtual task* execute() = 0;

task對象不能直接new,而是要使用TBB中重載的new操作符:

inline void *operator new( size_t bytes, const tbb::internal::allocate_root_proxy& ) 
inline void *operator new( size_t bytes, const tbb::internal::allocate_root_with_context_proxy& p ) 
inline void *operator new( size_t bytes, const tbb::internal::allocate_continuation_proxy& p ) 
inline void *operator new( size_t bytes, const tbb::internal::allocate_child_proxy& p ) 
inline void *operator new( size_t bytes, const tbb::internal::allocate_additional_child_of_proxy& p )

下面是TBB Tutorial中的示例:

#include <tbb/task.h>
#include <tbb/tick_count.h>
#include <cstdio>

using tbb::task;

long SerialFib(long n)
{
	if (n < 2)
		return n;
	else
		return SerialFib(n - 1) + SerialFib(n - 2);
}

class FibTask : public task
{
public:
	const long n;
	long* const sum;
	FibTask(long n_, long* sum_) :
		n(n_), sum(sum_)
	{
	}
	task* execute()
	{   
		if (n < 10)
		{
			*sum = SerialFib(n);
		}
		else
		{
			long x, y;
			FibTask& a = *new(allocate_child()) FibTask(n - 1, &x);
			FibTask& b = *new(allocate_child()) FibTask(n - 2, &y);
			// ref_count的值爲2+1(a+b+後面函數sapwn_and_wait_for_all產生的等待任務) 
			set_ref_count(3);
			spawn(b);			 
			spawn_and_wait_for_all(a);
			*sum = x + y;
		}
		return NULL;
	}
};

long ParallelFib(long n)
{
	long sum;
	FibTask& a = *new(task::allocate_root()) FibTask(n, &sum);
	task::spawn_root_and_wait(a);
	return sum;
}



int main(int argc, char** argv)
{
	using namespace tbb;
	tick_count start = tick_count::now();
	ParallelFib(10);
	tick_count end = tick_count::now();
	printf("tick count = %f\n", (end - start).seconds());

	return 0;
}


任務的調度

調度器持有一個定向圖表,每個節點對應一個任務對象。每個task指向它的繼任者(successor),也就是指向等待它完成的任務(可以爲空)。successor可以通過task::parent()得到。每個任務對象都包含一個引用計數,用來統計將此任務作爲繼任者的任務數量”。下圖是斐波那契計算的任務圖形快照:


任務A、B、C都產生了子任務並等待其完成。它們的引用計數爲子任務的數目+1.

任務D正在運行,但是沒有產生子任務,所以不需要設置引用計數

任務E、F、G都沒有開始執行(spawned,當時沒有excuting)

調度器運行任務的方式傾向於最小化內存需求以及跨線程通訊。但也需要在兩種執行方式(深度優先、廣度優先)間達到平衡。假定樹是固定的,深度優先就是最佳的順序執行方式:

  • 趁熱打鐵  最深層次的通常是最新創建的任務,因此在緩存(cache)中處於活躍狀態。如果他們能完成,緊接着他們的任務就會被執行(比如D執行完後執行C),雖然不如第一個任務在緩存中的狀態活躍,但相比創建事件更久的任務,它是最有效的。
  • 最小化空間佔用  執行最淺節點的任務會將樹按照廣度優先展開。這將同時創建指數級數量的節點。於此相比,深度優先只創建同等數量的節點,而且同一時間存在一個線性數量,因爲它將其他準備好的任務壓入堆棧。

雖然廣度優先有着嚴重的內存佔用問題,但在如果你擁有無數個物理線程,它能最大並行化。一般來說物理線程都是有限的,所以廣度優先執行的數量讓有效的處理器保持繁忙就夠了。調度器實現了廣度優先、深度優先的混合執行模式。每個線程都有自己的就緒任務隊列。當一個線程產出一個任務時,就將此任務推入隊列的底部。下圖展示了上述任務圖形快照中某個線程的任務隊列,按照時間先後自頂向下排列:

                                                   

任務 G
任務 F
任務 E


線程的隊列

線程執行任務的時候,按照以下規則從任務隊列取得任務:

  • 規則1:獲取上一個task的execute方法返回的task,如果爲空繼續獲取
  • 規則2:從自身的隊列底部彈出一個task,如果隊列爲空,繼續下一條判斷
  • 規則3:隨機選擇一個任務隊列,從其頂部“偷”一個task。如果選擇的隊列爲空,繼續遍歷其餘的隊列,直到成功


規則2的效果就是執行本線程最近產出的任務,屬於深度優先執行任務。規則3會從別的線程任務隊列中選擇最先產出的任務,發生廣度優先任務執行,將潛在的並行變爲實際的並行執行。作爲任務演進圖的一部分,獲取任務是自動的。任務入隊可以是顯式的,也可以是隱式的。一個線程總是把任務加入自己隊列的底部(不會加入另外線程的隊列)。只有偷竊器才能把一個線程產出的任務傳送到另外一個線程。在以下條件下,一個線程會將一個任務壓入它的隊列:

  • 任務被此線程顯式產出,比如方法spawn
  • 一個任務被方法task::recycle_to_reexecute標記爲再執行
  • 一個線程執行完最後的前任任務,並且此後隱式地將任務的引用計數減少到0。如果這種情況發生,線程隱式的將後續任務推入他的隊列底部。如果一個任務有外部引用,執行完它所有的孩子任務並不會導致它的引用計數爲0

總體來說,任務調度的基本策略是“廣度優先竊取,深度優先運行”。廣度優先竊取準則會使線程保持繁忙,提升並行效率。深度優先運行準則會使每個線程在有足夠工作需要做時,保持高效操作。

有用的任務技術

遞歸鏈式反應

    如果任務圖爲樹形結構,調度器能工作的最好。因爲此時“廣度優先竊取、深度優先執行”策略非常適合。而且,樹形結構的任務圖也能很快地爲很多任務創建出來。比如,一個主控任務需要創建N個孩子,如果直接創建,需要O(N)個步驟。但使用樹形結構叉分建立,只需要O(lg(N))個步驟。

    一般情況下,問題都不是明顯的樹形結構,但可以輕鬆將他們映射到樹。比如,parallel_for工作在迭代空間(比如,一個整數隊列)。模板函數parallel_for使用定義將一個迭代空間遞歸映射到一個二叉樹。

持續傳遞

spawn_and_wait_for_all方法使正在執行的父任務等待所有的子任務完成,但是會稍微影響一些性能。當一個線程調用這個函數時,它會保持繁忙直到所有的孩子任務完成。有些時候,父任務準備就緒,可以繼續執行,但卻不能馬上開始,因爲它的線程還在執行其他任務中的一個任務。解決方案是父任務不再等待它的孩子,而是產出子任務後返回。子任務不是被作爲父任務的孩子被分配,而是作爲父任務的持續任務(continuation task)。這樣,空閒的線程在它的子任務完成後就能偷竊並運行持續任務。上述FibTask的“持續傳遞”變體如下:

struct FibContinuation : public task
{
	long* const sum;
	long x, y;
	FibContinuation(long* sum_) : sum(sum_) {}
	task* execute()
	{
		*sum = x + y;
		return NULL;
	}
};
struct FibTask : public task
{
	const long n;
	long* const sum;
	FibTask(long n_, long* sum_) :
		n(n_), sum(sum_)
	{
	}
	task* execute()
	{
		if (n<10)
		{
			*sum = SerialFib(n);
			return NULL;
		}
		else
		{
			FibContinuation& c =
				*new(allocate_continuation()) FibContinuation(sum);
			FibTask& a = *new(c.allocate_child()) FibTask(n - 2, &c.x);
			FibTask& b = *new(c.allocate_child()) FibTask(n - 1, &c.y);
			// 這裏的引用計數是2,而不是2+1. 
			c.set_ref_count(2);
			spawn(b);
			spawn(a);
			return NULL;
		}
	}
};

兩個版本的以下不同點需要了解:

    最大的區別是,在execute方法中,原來版本的x、y都是局部變量。在持續傳遞版本,它們就不能是局部變量了,因爲父任務在子任務完成之前就返回了。作爲替代方案,他們都是持續任務FibContinuation的字段。

    改爲使用allocate_continuation分配持續的任務。它與allocate_child類似,只是它的繼任者(successor)是c而不是this,並且設置this的繼任者爲NULL,下面的圖示了這種轉換:


這種轉換的一個屬性就是它不改變繼任者的引用計數,這樣就避免了涉入引用計數邏輯。

引用計數被設置爲2,子任務的數量。在初始版本,它被設置爲3,因爲spawn_and_wait_for_all需要增加計數。而且,代碼設置持續任務(FibContinuation)而不是父任務的引用計數,因爲是持續任務對象在等待子任務。

指針sum通過FibContinuation的構造函數傳遞給持續任務對象,因爲現在是FibContinuation把計算結果保存到*sum。子任務仍然使用allocate_child分配,但是都作爲c,而不是父節點的孩子。這樣,當兩個子任務完成後,就是c而不是this作爲繼任者被產出。如果你湊巧使用this.allocate_child(),父任務就會在兩個子任務完成後再次運行。

如果大家還記得初始版本中的ParallelFib是怎麼編寫的,就也許會擔心持續傳遞風格會打破這段代碼,因爲現在根FibTask在子任務完工之前完成,並且實現代碼使用spawn_root_and_wait來等待根FibTask。這算不上問題,因爲spawn_root_and_wait被設計的能與持續傳遞風格很好的工作。調用spawn_root_and_wait(x)並不真的等待x結束。實際上,它構造了X的一個亞元(dummy)繼任者,並且等待繼任者的引用計數被消減。因爲allocate_continuation將此亞元繼任者傳遞給持續任務,亞元繼任者的引用計數會在持續任務完成後才遞減。

調度旁路   

調度旁路(scheduler bypass)是一種優化手段,此時你直接指定下一個要運行的任務。持續傳遞風格經常會爲調度旁路開啓機會。例如,在持續傳遞例子的最後,方法execute()產出任務“a”後返回。這會導致正在執行的線程做以下事情:

1. 將任務“a”入棧線程的任務隊列

2. 從方法execute()返回

3. 將任務“a”出棧,如果它被別的線程“偷竊”

步驟1、3都是不必要的隊列操作,更壞的是,允許“偷竊”會損害局部性而沒有顯著增加並行。方法execute()能通過返回一個指向“a”的指針而不是產出它來避免這些問題。由線程執行任務的規則1可知,“a”變爲此線程的下一個要執行的任務。而且,這種方法保證執行任務“a”的是此線程,而不是另外的線程。

下面的示例顯示了前一節的例子中必須要做的變更:

struct FibTask : public task
{
	...
		task* execute()
	{
		if (n<CutOff)
		{
			*sum = SerialFib(n);
			return NULL;
		}
		else
		{
			FibContinuation& c =
				*new(allocate_continuation()) FibContinuation(sum);
			FibTask& a = *new(c.allocate_child()) FibTask(n - 2, &c.x);
			FibTask& b = *new(c.allocate_child()) FibTask(n - 1, &c.y);
			// Set ref_count to "two children". 
			c.set_ref_count(2);
			spawn(b);
			spawn(a);
			//return NULL;
			return &a;
		}
	}
};


任務再生

不但可以繞過調度器,也可以繞過任務分配與再分配。這在遞歸任務執行調度旁路時,會有相應的更高几率發生。考慮前面的例子。當它創建了一個持續任務“c”,會執行下面的步驟:

1. 創建子任務“a”

2. 創建併產出子任務“b”

3. 從execute()方法返回指向任務“a”的指針

4. 銷燬父任務

如果把“a”當作父任務,就可以避免上述的步驟1、4. 在很多場景中,步驟1需要從父任務中拷貝狀態。將“a”當作父任務會消除拷貝開銷。下面的例子顯示了使用任務再生改造調度旁路的代碼:

struct FibTask : public task
{
	/*const*/ long n;
	long* /*const*/ sum;
	...
		task* execute()
	{
		if (n<10)
		{
			*sum = SerialFib(n);
			return NULL;
		}
		else
		{
			FibContinuation& c =
				*new(allocate_continuation()) FibContinuation(sum);
			FibTask& a = *new(c.allocate_child()) FibTask(n - 2, &c.x);
			FibTask& b = *new(c.allocate_child()) FibTask(n - 1, &c.y);
			recycle_as_child_of(c);
			n -= 2;
			sum = &c.x;
			// Set ref_count to "two children". 
			c.set_ref_count(2);
			spawn(b);
			//return &a;
			return this;
		}
	}
};

execute()方法現在返回this,而不是"a" 任務。調用recycle_as_child_of(c)有幾種作用:

  • 標記this在execute()返回後不能自動銷燬
  • 設置this的繼任者爲c

爲了防止引用計數問題,recycle_as_child_of有個前置條件,那就是this的繼任者必須爲空。這是在allocate_continuation發生後的情況。下圖顯示了allocate_continuation、recycle_as_child_of如何轉換任務圖:

使用任務再生時,確保原始任務的字段在任務開始運行後不能處於被使用狀態。例子使用調度旁路技術來確保這點。可以在產出時,當它的字段沒有被使用時再產出再生任務。這個限制甚至適用於任何const字段,因爲產出(spawning)後,任務可能在父任務沒有任何動作的情況下運行並銷燬。

一個類似的方法,task::recycle_as_continuation(),將一個任務作爲一個持續任務而不是孩子任務。

總結

由於任務調度的複雜性,官方並不鼓勵直接使用調度器,採用parallel_for、parallel_reduce等模板是個好主意。以下細節需要謹記:

  • 使用new(allocation_method)T來分配一個task (allocation_method是task類的一種分配方法)。不要創建局部或者文件作用域的task實例
  • 除非使用allocate_additional_child_of,否則在運行任何任務前,它的兄弟任務都必須分配完畢。
  • 採用持續傳遞、繞過調度器,以及任務再生等技術榨取最大性能
  • 如果一個任務完成了,並且沒有被標記爲再執行,就會自動銷燬。同樣,它的繼任者的引用計數會減少,如果到了0,繼任者會被自動產出



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