?????????本教程對應學習工程:魔術師Dix / HandsOnParallelProgramming · GitCode????????
????????在 .NET 的初始版本中,我們只能依賴線程(線程可以直接創(chuàng)建或者使用 ThreadPool 類創(chuàng)建)。ThreadPool 類提供了一個托管抽象層,但是開發(fā)人員仍然需要依靠 Thread 類來進行更好的控制。而 Thread 類維護困難,且不可托管,給內存和 CPU 帶來沉重負擔。
????????因此,我們需要一種方案,既能充分利用 Thread 類的優(yōu)點,又規(guī)避它的困難。這就是任務 (Task)。
? ? ? ? (另:本章篇幅較大,將分為上種下三部分發(fā)表。)
1、任務(Task)的特性
????????任務(Task)是 .NET 中的抽象,一個異步單位。從技術上講,任務不過是對線程的包裝,并且這個線程還是通過 ThreadPool 創(chuàng)建的。但是任務提供了諸如等待、取消和繼續(xù)之類的特性,這些特性可以在任務完成后運行。
????????任務具有以下重要特性:
-
任務由 TaskScheduler (任務調度程序)執(zhí)行,默認的調度僅在 ThreadPool 上運行。
-
可以從任務中返回值。
-
任務在完成時有通知(ThreadPool 和 Thread 都沒有)。
-
可以使用 ContinueWith() 構造連續(xù)執(zhí)行的任務。
-
可以通過調用 Task.Wait() 等待任務的執(zhí)行,這將阻塞調用線程,直到任務完成為止。
-
與傳統(tǒng)線程或 ThreadPool 相比,任務可以使代碼的可讀性更高。他們還為在 C# 5.0 中引入異步編程構造鋪平了道路。
-
當一個任務從另一個任務啟動時,可以建立它們之間的父子級關系。
-
可以將子任務的異常傳播到父任務。
-
可以使用 CancellationToken 類取消任務。
2、創(chuàng)建和啟動任務
????????我們可以通過多種方式使用任務并行庫(TPL)創(chuàng)建和運行任務。
2.1、使用 Task
????????Task 類是作為 ThreadPool 線程異步執(zhí)行工作的一種方式。它采用的是基于任務的異步模式( Task-Based Asynchronous Pattern,TAP)。非通用 Task 類不會返回結果,因此當需要從任務中返回值時,就需要使用通用版本的 Task<T> 。Task 需要調用 Start 方法來調度運行。
????????具體的 Task 調用代碼如下:
/// <summary>
/// 測試方法,打印10次,等待10秒
/// </summary>
public static void DebugAndWait()
{
int length = 10;
for (int i = 0; i < length; i++)
{
Debug.Log($"執(zhí)行第:{i + 1}/{length} 次打印!");
Thread.Sleep(1000);
}
}
//使用任務執(zhí)行
private void RunByNewTask()
{
//創(chuàng)建任務
Task task = new Task(TestFunction.DebugAndWait);
task.Start();//不調用 Start 則不會執(zhí)行
}
????????最終結果也沒有什么意外:
2.2、使用 Task.Factory.StartNew
????????TaskFactory 類的 StartNew 方法也可以創(chuàng)建任務。這種方式創(chuàng)建的任務將安排在 ThreadPool 中執(zhí)行,然后返回該任務的引用:
private void RunByTaskFactory()
{
//使用 Task.Factory 創(chuàng)建任務,不需要調用 Start
var task = Task.Factory.StartNew(TestFunction.DebugAndWait);
}
????????當然打印的結果和上述一樣的。
2.3、使用 Task.Run
????????這個原理和 Task.Factory.StartNew 一樣:
private void RunByTaskRun()
{
//使用 Task.Run 創(chuàng)建任務,不需要調用 Start
var task = Task.Run(TestFunction.DebugAndWait);
}
2.4、Task.Delay
????????使用 Task.Delay 也可以創(chuàng)建一個任務,但是這個任務有點特別。它可以在指定時間間隔后完成,可以使用
????????CacellationToken 類隨時取消。與 Thread.Sleep 不同,Task.Delay 不需要利用 CPU 周期,且可以異步運行。
????????為了體現(xiàn)兩者的不同,我們直接寫個例子:
public static void DebugWithTaskDelay()
{
Debug.Log("TaskDelay Start");
Task.Delay(2000);//等待2s
Debug.Log("TaskDelay End");
}
????????然后我們直接在程序中直接同步調用此方法:
private void RunWithTaskDelay()
{
Debug.Log("開始測試 Task.Delay !");
TestFunction.DebugWithTaskDelay();
Debug.Log("結束測試 Task.Delay !");
}
????????結果如下:
?????????可以看到4條打印按照順序一瞬間被打印出來了,根本沒有任何等待。而如果我們把上述的 Task.Delay 替換成 Thread.Sleep,結果會如何呢?
?????????在運行此方法后,Unity直接卡住,然后2s后打印出4條信息。并且,顯然線程等待生效了,但是是以阻塞主線程的方式生效的。
????????讓我們換回 Task.Delay ,并使用 Task.Run 來運行這個方法,打印結果如下:
?????????顯然線程等待命令生效了,說明在子線程中的 Delay 是可以正常工作的。
2.5、Task.Yield
????????Task.Yiled 是創(chuàng)建 await 任務的另一種方法。使用此方法可以讓方法強制變成異步的,并將控制權返回給操作系統(tǒng)。
????????怎么理解呢?我們這里需要一個很耗時的函數(shù):
public static async void DebugWithTaskYield()
{
int length = 27;//這個方法不能執(zhí)行很多次
string str = "";
for (int i = 0; i < length; i++)
{
//以下是耗時函數(shù)
str += "1,1";
var arr = str.Split(',');
foreach (var item in arr)
{
str += item;
}
await Task.Yield();
Debug.Log($"執(zhí)行第:{i + 1}/{length} 次打??!");
}
}
????????這里我直接用簡單的字符串拼接來實現(xiàn)了耗時函數(shù)。
????????我們在主線程調用 Task.Run 來執(zhí)行,Debug 的結果如下:
?????????可以看到隨著字符串的增加,單次耗時越來越長。但是無論單次耗時時長有多少,都沒有阻礙主線程!可能大家第一感覺和 Unity 的協(xié)程是一樣的,但是 Unity 的協(xié)程使用是在主線程運行的,使用協(xié)程并不代表不會阻塞主線程。這里我們直接將這段代碼用協(xié)程的邏輯實現(xiàn):
public static IEnumerator DebugWithCoroutine()
{
int length = 27;//這個方法不能執(zhí)行很多次
string str = "";
for (int i = 0; i < length; i++)
{
//以下是耗時函數(shù)
str += "1,1";
var arr = str.Split(',');
foreach (var item in arr)
{
str += item;
}
yield return null;
Debug.Log($"執(zhí)行第:{i + 1}/{length} 次打??!");
}
}
????????邏輯上沒有任何區(qū)別,就是把 await Task.Yield(); 改成了 yield return null 。當然,日志打印上看起來差不多,但是對主線程而言有本質區(qū)別。當運行到后面時,每次迭代都會造成主線程的卡頓。這一點在 Profiler 上看起來非常明顯:
?(可以看到協(xié)程調用的顯然耗時)
2.6、Task.FromResult
????????FromResult<T> 是在 .NET Framework 4.5 中才被引入的方法,這在 Unity 2022.2.5 f1c1 使用的 .NET Standard 2.1 是支持的。
public static int FromResultTest()
{
int length = 100;
int result = 0;
for (int i = 0; i < length; i++)
result += Random.Range(0, 100);
Debug.Log($"FromResultTest 運算結果:{result} ");
return result;
}
private void RunWithFromResult()
{
Debug.Log("RunWithFromResult Start !");
Task<int> resultTask = Task.FromResult<int>(TestFunction.FromResultTest());
Debug.Log("RunWithFromResult End ! Result : " + resultTask.Result);
}
????????如上述代碼所示 RunWithFromResult 的結果如下:
?????????與一般的Task異步不同,這里是按照執(zhí)行順序依次打印的。如果這個函數(shù)是個耗時函數(shù),會阻塞主線程嗎?我把 2.5 里測試的耗時函數(shù)搬過來測試了一下(就不貼代碼了):
?????????顯然已經阻塞主線程了。
????????也就是說這個 FromResult 將異步的方法拿到主線程中調度了(也可以理解為把子線程直接拿到父線程)。既然已經是 Unity 主線程了,那么 Task.Delay 就不會生效;而 Thread.Sleep 會生效,且會阻塞主線程。
????????與前面的幾個創(chuàng)建Task任務的方法不同,這個Task.FromResult 是可以調用帶參函數(shù)的(Task.Run 只能運行無參函數(shù))。但即便如此,因為其會阻塞父線程,也不建議在 Unity 主線程中使用。
2.7、Task.FromException 和 Task.FromException<T>
????????這兩個方法都可以拋出異步任務中的異常,在單元測試中很有用。
????????(這里暫時不會用到,就先不講了,在后面學單元測試的時候再詳細學習這兩個)
2.8、Task.FromCanceled 和 Task.FromCanceled<T>
????????????????這個和 Task.FromException 的情況有點類似,都是看起來不知道有啥用其實很有用的方法。為了方便學習,這里還是展開講講。
????????首先看下面一段代碼,這個也是 Task.FromCanceled 的示例代碼:
CancellationTokenSource source = new CancellationTokenSource();//構建取消令牌源
source.Cancel();//設置為取消
//返回標記為取消的任務。
//注意!使用此方法要確保 CancellationTokenSource 已經調用過 Cancel 方法 ,否則會出錯!
Task.FromCanceled(source.Token);
????????當我們把這個最后得到的Task狀態(tài)(Task.Status)打印出來,其結果是便是 Created 。
????????肯定就有人會問了,這個有啥用???我是創(chuàng)建了一個取消的任務?那我執(zhí)行這段代碼的意義是什么呢?
????????單看這段代碼,確實沒什么意義,但是我們這里提出一個需求:
?????????邏輯很簡單,但是問題就出在最后,要維護一個Task。我們假設預計執(zhí)行的任務A是某個長期的異步函數(shù),外部需要檢測他的狀態(tài)和結果。那我們在輸入偶數(shù)的時候,該返回什么呢?首先肯定不能返回一個空的Task,這個返回就和正常的Task一樣的了,外部監(jiān)控的狀態(tài)要么是 WaitingToRun, 要么就是 RanToCompletion,要么就是 Running 。我根本無法知道我是執(zhí)行了 任務A 還是沒有執(zhí)行 任務A。
????????這時候就發(fā)現(xiàn) Task.FromCanceled 的作用了:
private void RunWithFromCanceled()
{
var val = commonPanel.GetInt32Parameter();
//這里測試輸入雙數(shù)就取消執(zhí)行,單數(shù)就正常執(zhí)行。
CancellationTokenSource source = new CancellationTokenSource();
if (val % 2 == 0)
source.Cancel();
var task = TestFunction.TestCanceledTask(source);
Debug.Log($"Task State 1: {task.Status}");
}
/// <summary>
/// 測試用于取消任務
/// </summary>
public static Task TestCanceledTask(CancellationTokenSource source)
{
if (source.IsCancellationRequested)
{
Debug.Log($"任務取消 !");
var token = source.Token;
return Task.FromCanceled(token);
}
else
{
Debug.Log($"任務執(zhí)行 !");
return Task.Run(DebugWithTaskDelay);
}
}
????????當輸入偶數(shù)時,就會返回一個已取消的任務,而奇數(shù)則會正常執(zhí)行。
????????當我們對任務進行了封裝,內部的判斷邏輯會比較復雜,而外部也只需要知道任務執(zhí)行情況而不需要知道其內部邏輯。此時使用 Task.FromCanceled 和 Task.FromException 就能返回給外部一個通用的“異?!盩ask。
3、從完成的任務中獲取結果
????????任務并行庫(TPL)中提供的API有如下幾個:
/// <summary>
/// 獲取任務并行結果
/// </summary>
private void GetTaskResult()
{
int inputParam = commonPanel.GetInt32Parameter();
Debug.Log($"get task result start ! paramter : {inputParam}");
//方法1 :new Task
var task_1 = new Task<int>(()=>TestFunction.FromResultTest(inputParam));
task_1.Start();
Debug.Log($"task_1 result : {task_1.Result}");
//方法2:Task.Factory
var task_2 = Task.Factory.StartNew<int>(()=> TestFunction.FromResultTest(inputParam));
Debug.Log($"task_2 result : {task_2.Result}");
//方法3:
var task_3 = Task.Run<int>(()=>TestFunction.FromResultTest(inputParam));
Debug.Log($"task_3 result : {task_3.Result}");
//方法4:
var task_4 = Task.FromResult<int>(TestFunction.FromResultTest(inputParam));
Debug.Log($"task_4 result : {task_4.Result}");
}
????????這次測試終于出現(xiàn)了一個熟悉的錯誤:
?????????Random.Range 只能在Unity主線程使用。
????????這個以前就知道 UnityEngine 的類不能在子線程使用,這里遇到了。但是沒關系,我們直接修改這個方法即可,用System的Random就行了。
????????但是這能說明我們的程序確實在子線程運行了,但是實際上這4個方法都是會阻塞主線程的!
?????????所有的運算流程都是和 2.6 的 FromResult 一樣,已經將子線程調回主線程使用了。顯然這幾種方法都是提供一種同步的結果獲取,而真正做到異步計算還不能直接這么使用。
????????限于篇幅,任務并行性(上)到此為止。文章來源:http://www.zghlxwxcb.cn/news/detail-484877.html
? ? ? ? 本教程對應學習工程:魔術師Dix / HandsOnParallelProgramming · GitCode文章來源地址http://www.zghlxwxcb.cn/news/detail-484877.html
到了這里,關于【C#】并行編程實戰(zhàn):任務并行性(上)的文章就介紹完了。如果您還想了解更多內容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關文章,希望大家以后多多支持TOY模板網(wǎng)!