Try   HackMD

.NET Core 6 Task 筆記

以下都以 Dapper 查詢資料庫為例,資料庫有兩個資料表,一個為 Products 有文字的欄位 Name,另一個為 Employees 有數字的欄位 Salary。

基本 async await

同步方法

using Dapper; 後,SqlConnection 連線物件可用的方法中,早期查詢用 QueryQuery<T>,傳回的是多筆 IEnumerable<T>,後面接 .ToList() 來運用,這是 同步 的方法,也就是一列一列程式碼往下走,還沒得到回應就會停在那裡,直到做完才會往後繼續。

下面程式碼的方法 GetProductName,會撈出 Products 資料表的所有 Name 資料,用頓號 串起來傳回來,我們再把文字再顯示在螢幕上,而 WAITFOR DELAY '00:00:03'; 是用來模擬網路延遲,可觀察螢幕出現「開始找商品名稱」後的等待時間,會明顯等最少三秒,取得結果才會印出串起來的文字。

using Dapper; using System.Data.SqlClient; Console.WriteLine("開始找商品名稱"); string names = GetProductName(); Console.WriteLine("商品一覽:" + names); Console.ReadKey(); string GetProductName() { string sql = "WAITFOR DELAY '00:00:03'; SELECT [Name] FROM [Products]"; IList<string> products; using(SqlConnection conn = new SqlConnection("...")) { products = conn.Query<string>(sql).ToList(); } return string.Join("、", products); }

非同步方法

其實 Dapper 的方法還有一套名稱帶有 Async 的,例如 QueryAsyncExecuteAsync,VisualStudio 將「非同步」翻做「可等待」,會變得比較好理解,這種「可等待」的非同步方法讓我們可以「不等待」,工作效率就會提高,產能變好,相較起來以前的同步方法就是「強迫等待」了。

但要使用非同步方法,會有一系統東西要改,以 Dapper 的 QueryAsync 為例,方法不是回傳 IEnumerable<T> 可直接做 .ToList(),而是回傳 Task<IEnumerable<T>>,剛開始可能會被這個回傳的東西嚇到,但看完筆記再回來看這段,就會覺得沒什麼了。先看程式碼:

using Dapper; using System.Data.SqlClient; Console.WriteLine("開始找商品名稱"); string names = await GetProductName(); Console.WriteLine("商品一覽:" + names); Console.ReadKey(); async Task<string> GetProductName() { string sql = "WAITFOR DELAY '00:00:03'; SELECT [Name] FROM [Products]"; IList<string> products; using(SqlConnection conn = new SqlConnection("...")) { products = (await conn.QueryAsync<string>(sql)).ToList(); } return string.Join("、", products); }

變更的點有:

  • (16列) QueryAsync 方法前面要加上 await 修飾,表示要等待這個查詢動作。
  • (16列) 查詢結果一樣可以做 .ToList() 但要用括號把整個 await conn.QueryAsync<string>(sql) 包起來再 .ToList()
  • (9列) 方法要加上 async 宣告非同步「可等待」的修飾詞。
  • (9列) 方法回傳的類型從 string 改成 Task<string>,可想成「回傳一個工作 Task,這個工作夾帶著 string
  • (5列) 呼叫這個可等待的非同步方法 GetProductName 時要加 await,表示等待這個 Task 完成,取得 string。

結果和前面的同步方法是完全一樣的,也是顯示「開始找商品名稱」後停滯三秒後,用頓號列出商品名稱。

可等待但不等待

上面的兩個範例看不出使用 Task 的好處,我們換個做法,實作上常常要帶回很多資料,只回傳單一的類型不敷使用。所以我會用一個物件來攜帶要取回的資料,然後回傳用 void。這裡要傳遞用的物件我叫 Carry:

class Carry { internal IList<string> Products { get; set; } = new List<string>(); internal IList<int> Employees { get; set; } = new List<int>(); }

同步

古早的同步寫法還是先來一段,下面是查詢出 Products 的所有 Name,還有查詢出 Employees 的所有 Salary。都是使用 Dapper 的 Query<T> 同步方法。

using Dapper; using System.Data.SqlClient; Carry carry = new Carry(); Console.WriteLine("開始找商品名稱"); GetProductName(carry); Console.WriteLine("開始找職員薪水"); GetEmployeeSalary(carry); Console.WriteLine("商品一覽:" + string.Join("、", carry.Products)); Console.WriteLine("薪水總和:" + carry.Employees.Sum()); Console.ReadKey(); void GetProductName(Carry carry) { string sql = "WAITFOR DELAY '00:00:03'; SELECT [Name] FROM [Products]"; IList<string> products; using(SqlConnection conn = new SqlConnection("...")) { products = conn.Query<string>(sql).ToList(); } carry.Products = products; return; } void GetEmployeeSalary(Carry carry) { string sql = "WAITFOR DELAY '00:00:02'; SELECT [Salary] FROM [Employees]"; IList<int> employees; using (SqlConnection conn = new SqlConnection("...")) { employees = conn.Query<int>(sql).ToList(); } carry.Employees = employees; return; }

以上兩個 SQL 語句都有模擬延遲,會看到查詢商品名稱花了三秒,然後查詢職員薪水花了兩秒,也就是整整經過五秒才會印出最後結果。

非同步

使用 QueryAsync<T> 後,兩個查詢方法 GetProductName 和 GetEmployeeSalary 要加上 async 修飾詞讓他非同步「可等待」,但因為沒有回傳,所以將 void 改成 Task 就好,以下程式碼:

using Dapper; using System.Data.SqlClient; Carry carry = new Carry(); Console.WriteLine("開始找商品名稱"); await GetProductName(carry); Console.WriteLine("開始找職員薪水"); await GetEmployeeSalary(carry); Console.WriteLine("商品一覽:" + string.Join("、", carry.Products)); Console.WriteLine("薪水總和:" + carry.Employees.Sum()); Console.ReadKey(); async Task GetProductName(Carry carry) { string sql = "WAITFOR DELAY '00:00:03'; SELECT [Name] FROM [Products]"; IList<string> products; using(SqlConnection conn = new SqlConnection("...")) { products = (await conn.QueryAsync<string>(sql)).ToList(); } carry.Products = products; return; } async Task GetEmployeeSalary(Carry carry) { string sql = "WAITFOR DELAY '00:00:02'; SELECT [Salary] FROM [Employees]"; IList<int> employees; using (SqlConnection conn = new SqlConnection("...")) { employees = (await conn.QueryAsync<int>(sql)).ToList(); } carry.Employees = employees; return; }

變更點:

  • (24列 37列) 改成 QueryAsync 後,前面加上 await,整個包起來再 .ToList()
  • (17列 30列) 加上 async 宣告可等待,回傳從 void 改成 Task(其實維持 void 還是可編譯成功,但就沒有可等待效果)。
  • (7列 10列) 呼叫時加上 await 等待工作結束。

這結果與上面同步結果一樣,查詢商品名稱花費三秒,查詢職員薪水花了兩秒,全部要花五秒才看到最後結果。

非同步但不等待

試著將上面的 7 列和 10 列維持原來直接呼叫的寫法,不加上 await 修飾:

using Dapper; using System.Data.SqlClient; Carry carry = new Carry(); Console.WriteLine("開始找商品名稱"); GetProductName(carry); Console.WriteLine("開始找職員薪水"); GetEmployeeSalary(carry); /* ... 後面省略 ... */

發現 IDE 有警告,還是可編譯可執行,然而執行結果是 瞬間結束,商品一覽為空白,薪水總和為 0 。原因很明顯,因為呼叫時沒有加 await 所以不等待 Task 完成,就一直往下執行而印出空 List 裡的名稱字串和空 List 裡薪水總和。

一般來說實務不會希望這樣,這裡只是故意引發這個情況,來瞭解 await 的功用。

等待所有工作結束

很多時候我們希望 可分開做的事情同時去做,例如上面花三秒查詢商品,花兩秒查詢薪水,能不能同時做、在兩個工作都完成時印出結果呢?這就要用到 Task.WaitAllTask.WhenAll 方法了。

Task 物件

在用這個靜態方法之前,要先知道 GetProductName 與 GetEmployeeSalary 方法加上 async 時,回傳的是 Task,所以可以宣告一個 Task 物件來接 GetProductName 的回傳,可以先看這個物件有什麼內容。

using Dapper; using System.Data.SqlClient; Carry carry = new Carry(); Console.WriteLine("開始找商品名稱"); Task taskP = GetProductName(carry); while (true) { if (taskP.IsCompleted) { Console.WriteLine("商品一覽:" + string.Join("、", carry.Products)); break; } else { Console.Write("*"); } } Console.ReadKey(); async Task GetProductName(Carry carry) { string sql = "SELECT [Name] FROM [Products]"; IList<string> products; using(SqlConnection conn = new SqlConnection("...")) { products = (await conn.QueryAsync<string>(sql)).ToList(); } carry.Products = products; return; }

上面程式碼範例 9 列到 21 列的 while 是粗暴了點,但可以觀察 Task 物件 taskP 狀態的變化,7 列開始執行後就直接往下繼續了,進入 while 迴圈後持續印出 * 在螢幕上,直到 SQL 查詢完成後,taskP 的 IsCompleted 會變為 true,這時迴圈進入 13 列,印出商品一覽並跳出。為避免印出太多 *,原本 SQL 語句有 WAITFOR DELAY 的部份這裡暫時拿掉了,有 * 應該就可以看出 SQL 查詢還是有微量的時間花費。

Task<T>

如果是最早有回傳 string 的寫法,加上 async 後則是回傳 Task<string>

using Dapper; using System.Data.SqlClient; Console.WriteLine("開始找商品名稱"); Task<string> taskP = GetProductName(); while (true) { if (taskP.IsCompleted) { Console.WriteLine("商品一覽:" + taskP.Result); break; } else { Console.Write("N"); } } Console.ReadKey(); async Task<string> GetProductName() { string sql = "SELECT [Name] FROM [Products]"; IList<string> products; using(SqlConnection conn = new SqlConnection("...")) { products = (await conn.QueryAsync<string>(sql)).ToList(); } return string.Join("、", products); }

以上程式碼是將 GetProductName 方法改成最早的回傳字串版本,相對應的調整如下:

  • (31 列、23 列) 由於回傳字串又有 async 宣告可等待,所以回傳為 Task<string>
  • (7 列) 宣告 Task<string>taskP 來接 GetProductName 的工作
  • (13 列) 使用 .Result 來取得 Task<T>T,在這裡是取得回傳的 string 結果

靜態方法 Task.WaitAll

如果有多個 Task ,用靜態方法 Task.WaitAll 可等待這些工作都完成了,才會繼續往下做,他的引數是陣列的 Task,所以是 Task[],可以要呼叫使用的時候傳入 new Task[] { task1, task2, task3 },也可以先用一個 List<Task> 分別 .Add 最後再 .ToArray 傳入,下面示範用 new Task[] 的做法:

using Dapper; using System.Data.SqlClient; Carry carry = new Carry(); Console.WriteLine("開始找商品名稱"); Task<string> taskP = GetProductName(); Console.WriteLine("開始找職員薪水"); Task taskE = GetEmployeeSalary(carry); Task.WaitAll(new Task[] { taskP, taskE }); Console.WriteLine("商品一覽:" + taskP.Result); Console.WriteLine("薪水總和:" + carry.Employees.Sum()); Console.ReadKey(); async Task<string> GetProductName() { string sql = "WAITFOR DELAY '00:00:03'; SELECT [Name] FROM [Products]"; IList<string> products; using(SqlConnection conn = new SqlConnection("...")) { products = (await conn.QueryAsync<string>(sql)).ToList(); } return string.Join("、", products); } async Task GetEmployeeSalary(Carry carry) { string sql = "WAITFOR DELAY '00:00:02'; SELECT [Salary] FROM [Employees]"; IList<int> employees; using (SqlConnection conn = new SqlConnection("...")) { employees = (await conn.QueryAsync<int>(sql)).ToList(); } carry.Employees = employees; return; }
  • (19 列、27 列) GetProductName 方法用回傳 string 的做法,所以使用 async 後回傳物件用 Task<string>
  • (30 列、38 列) GetEmployeeSalary 方法無回傳,靠傳入的 Carry 物件來取得結果,所以使用 async 後回傳物件用 Task
  • (21 列、32 列) SQL 語句模擬網路延遲,查詢商品名稱花費三秒,查詢職員薪水花費兩秒。
  • (7 列、10 列) 用 Task<string> 的 taskP 去接 GetProductName 方法的回傳工作,用 Task 的 taskE 接 GetEmployeeSalary 方法的回傳工作。
  • (12 列) 使用 Task.WaitAll 方法,等待 taskP 和 taskE 兩個 Task 都完成,才會往下繼續 14、15 列,所以 14 列的 .Result 保證會有回傳的商品字串,15 列的 .Employees List 才不會是空的。

但是執行流程一直停在 Task.WaitAll 會有永遠離不開的風險,如果其中一個 Task 出了什麼差錯就全部都在等他了,為預防這種可能,可以加入第二個引數為 int 的毫秒數,當等待了這個毫秒數後若有 Task 還沒有完成,則會回傳 false,如果全部完成了則回傳 true,可以試著改寫以上,成為:

if(Task.WaitAll(new Task[] { taskP, taskE }, 2500)) { Console.WriteLine("工作都完成了"); } else { Console.WriteLine("有工作未完成"); }

因為查詢商品名稱花費三秒,所以等待 2500 毫秒也就是 2.5 秒,只有查詢薪水的工作完成,就會印出「有工作未完成」,可以給 Task.WaitAll 加一個防逾時的毫秒數,當期限到了還有未完成工作,可以再控制程式的流程,例如 throw 例外等等後續處理。

靜態方法 Task.WhenAll

上述的 Task.WaitAll 方法沒有回傳,加了等待毫秒數會有 bool 的回傳,比較難控制,還有另一個靜態方法 Task.WhenAll,會回傳一個 Task 代表傳入的 Task[] 工作陣列的整體執行狀態,而這個 Task 物件有一個方法 .Wait() 可以等待,以下示範程式碼:

using Dapper; using System.Data.SqlClient; Carry carry = new Carry(); Console.WriteLine("開始找商品名稱"); Task<string> taskP = GetProductName(); Console.WriteLine("開始找職員薪水"); Task taskE = GetEmployeeSalary(carry); Task allCompleted = Task.WhenAll(new Task[] { taskP, taskE }); allCompleted.Wait(); Console.WriteLine("商品一覽:" + taskP.Result); Console.WriteLine("薪水總和:" + carry.Employees.Sum()); Console.ReadKey(); /* ... 以下省略 ... */

有網友分享 Task.WhenAllTask.WaitAll 還快,不過我自己統計是覺得不相上下,但 Task.WhenAll 會回傳一個 Task,也方便後續控制,例如要做 .ContinueWith(),下面會介紹。

Task.WaitAny 和 Task.WhenAny

另外還有 Any 版本的靜態方法 Task.WaitAnyTask.WhenAny,等待一堆工作當其中一個完成就繼續,但我沒有實作的機會,也一時沒有想到明瞭的範例,所以就沒有這個的筆記了,但如果要使用應該用法也差不多。

其實 Task.WhenAll 就像 JavaScript 的 Promise.all,而 Task.WhenAny 就像 Promise.race

利用 Task 多工

上面所提的是從 Dapper 的資料庫查詢,如 QueryAsyncExecuteAsync 等非同步方法,延伸到討論 Task 如何操作,但其實不是一定要有 await 某個 async 方法才能使用 Task,例如下面兩個方法 ShowPrime1 和 ShowPrime2 傳入 int n 會印出前 n 個質數,而 ShowPrimes2 會回傳一個總結字串:

ShowPrime1(200); string result = ShowPrime2(300); Console.WriteLine("ShowPrime2 總結為:" + result); Console.ReadKey(); void ShowPrime1(int count) { Console.WriteLine(2); if(count < 2) { return; } IList<int> list = new List<int>(); list.Add(2); int next = 3; while(list.Count < count) { bool isDivided = false; foreach(int d in list) { if (d > Math.Sqrt(next)) break; if(next % d == 0) { isDivided = true; break; } } if (!isDivided) { list.Add(next); Console.WriteLine(next); } next += 2; } return; } string ShowPrime2(int count) { Stopwatch timer = new Stopwatch(); timer.Start(); Console.WriteLine("\t" +2); if (count < 2) { timer.Stop(); return string.Format("第{0}個質數是{1}花費{2}毫秒", 1, 2, timer.ElapsedMilliseconds); } IList<int> list = new List<int>(); list.Add(2); int next = 3; while (list.Count < count) { bool isDivided = false; foreach (int d in list) { if (d > Math.Sqrt(next)) break; if (next % d == 0) { isDivided = true; break; } } if (!isDivided) { list.Add(next); Console.WriteLine("\t" + next); } next += 2; } timer.Stop(); return string.Format("第{0}個質數是{1}花費{2}毫秒", count, list[list.Count - 1], timer.ElapsedMilliseconds); }

上面兩個方法的差別有兩點,第一個是 72 列 ShowPrime2 有回傳一個總結字串,第二個是 32 列和 67 列 Console.WriteLine 在 ShowPrime2 時有加上 \t,可以很明顯看出哪一列是 ShowPrime1 印的,哪一列是 ShowPrime2 印的。

而直接呼叫的結果就是同步,沒有縮排的先印完,才會印有縮排的。

2
3
5
7
.
.
.
1213
1217
1223
        2
        3
        5
        7
        .
        .
        .
        1973
        1979
        1987
ShowPrime2 總結為:第300個質數是1987花費119毫秒

靜態方法 Task.Run

靜態方法 Task.Run 可以創造一個 Task 物件,以多執行緒來執行引數的委派方法,將以上的直接呼叫,改成:

Task.Run(() => ShowPrime1(200)); Task.Run(() => ShowPrime2(300)); Console.ReadKey();

印出來的就會多執行緒來交錯印出,例如:

2
        2
3
5
        3
7
        5
        7
    .
    .
    .
1193
1201
        1933
1213
1217
1223
        1949
        1951
        1973
        1979
        1987

而且 Task.Run 方法可得到 Task 工作物件,表示可以用前面的功能,像是取得回傳值。以下示範傳入 200 和 300,利用 Task.WhenAll 等待兩個工作都完成,印出 ShowPrime2 的回傳字串:

Task task1 = Task.Run(() => ShowPrime1(200)); Task<string> task2 = Task.Run(() => ShowPrime2(300)); Task.WhenAll(task1, task2).Wait(); Console.WriteLine("ShowPrime2 總結為:" + task2.Result); Console.ReadKey(); void ShowPrime1(int count) { /* ... 省略 ... */ } string ShowPrime2(int count) { /* ... 省略 ... */ }

上面可以看到 ShowPrime2 的簽章仍然是回傳 string 並不是 async Task<string>,也是可以利用 Task.Run 達到多工。

2
        2
3
5
        3
7
        5
        7
    .
    .
    .
1193
1201
        1933
1213
1217
1223
        1949
        1951
        1973
        1979
        1987
ShowPrime2 總結為:第300個質數是1987花費207毫秒

物件方法 Start

上面靜態方法 Task.Run 是馬上就開始執行,若沒有要馬上開始,可以用 new 一個 Task 物件出來,在適當時機用 .Start() 來開始執行,示範如下:

Task task1 = new Task(() => ShowPrime1(200)); Task<string> task2 = new Task<string>(() => ShowPrime2(300)); task1.Start(); task2.Start(); Task.WhenAll(task1, task2).Wait(); Console.WriteLine("ShowPrime2 總結為:" + task2.Result); Console.ReadKey();
  • (1 列、2 列) new Task() 建構子傳入的就是要呼叫的委派方法,這裡用匿名委派夾帶變數 200 和 300,剛建構出來時還不會執行,可先做一些設定。另外 task2 在 new 的時候要寫好 Task<string>,不像前面靜態方法 Task.Run 可以省略(可以寫 Task.Run<string>(() => ShowPrime2(300)); 但 IDE 會提示可省略)。
  • (3 列、4 列) 給物件 task1 和 task2 做 .Start() 才會開始執行。

物件方法 Wait

其實 Task 物件的 .Wait() 方法前面用很多次了,因為靜態方法 Task.WhenAll 就是回傳一個 Task 物件,我們都再靠呼叫物件方法 .Wait() 來等待。這個方法顧名思義就是會停著,直到該物件執行完,才會往下。下面示範在 ShowPrime2 方法的執行過程中,觀察 Task 物件的 task2,其屬性 IsCompleted 的布林值:

Task<string> task2 = new Task<string>(() => ShowPrime2(300)); Console.WriteLine("開始前的 IsCompleted:" + task2.IsCompleted); task2.Start(); Console.WriteLine("開始後的 IsCompleted:" + task2.IsCompleted); task2.Wait(); Console.WriteLine("等待完的 IsCompleted:" + task2.IsCompleted); if(task2.IsCompleted) { Console.WriteLine("總結:" + task2.Result); } Console.ReadKey();

結果:

開始前的 IsCompleted:False
開始後的 IsCompleted:False
        2
        3
        5
        .
        .
        .
        1973
        1979
        1987
等待完的 IsCompleted:True
總結:第300個質數是1987花費81毫秒

另外物件方法 .Wait() 可以放引數 CancellationTokenint,這裡我介紹後者,傳入 int 的毫秒數後,Wait 方法會回傳 bool,表示經過這個毫秒數後 Task 物件的是否完成,而且就會往下執行了(一樣的防鎖死用法在靜態方法 Task.WaitAll 已有分享)。以下程式碼就是上面的加上 10 毫秒的引數:

Task<string> task2 = new Task<string>(() => ShowPrime2(300)); Console.WriteLine("開始前的 IsCompleted:" + task2.IsCompleted); task2.Start(); Console.WriteLine("開始後的 IsCompleted:" + task2.IsCompleted); task2.Wait(10); Console.WriteLine("等待完的 IsCompleted:" + task2.IsCompleted); if (task2.IsCompleted) { Console.WriteLine("總結:" + task2.Result); } Console.ReadKey();

結果:

開始前的 IsCompleted:False
開始後的 IsCompleted:False
        2
        3
        5
        .
        .
        .
        421
        431
        433
等待完的 IsCompleted:False
        439
        443
        .
        .
        .
        1973
        1979
        1987

這裡會很清楚看到,「等待完的 IsCompleted:False」是因為經過 10 毫秒其實還沒結束,但程式也往下走了,然後因為還沒結束故沒有用 .ResultShowPrime2 回傳的字串。

但其實把程式碼第 9 列取 Result ,搬到 if (task2.IsCompleted) 外面,也就是沒有判斷 IsCompleted 的屬性值就直接取用,也會等 Task 完整執行完才取,不會拋例外:

Task<string> task2 = new Task<string>(() => ShowPrime2(300)); Console.WriteLine("開始前的 IsCompleted:" + task2.IsCompleted); task2.Start(); Console.WriteLine("開始後的 IsCompleted:" + task2.IsCompleted); task2.Wait(10); Console.WriteLine("等待完的 IsCompleted:" + task2.IsCompleted); Console.WriteLine("總結:" + task2.Result); Console.ReadKey();

結果:

開始前的 IsCompleted:False
開始後的 IsCompleted:False
        2
        3
        5
        .
        .
        .
        421
        431
        433
等待完的 IsCompleted:False
        439
        443
        .
        .
        .
        1973
        1979
        1987
總結:第300個質數是1987花費114毫秒

Action 不用匿名委派

用 arrow function 放匿名委派是比較輕鬆的做法,下面是另一個作法:

Task task1 = new Task(ShowPrime1UseArgs, 100); Task<string> task2 = new Task<string>(ShowPrime2UseArgs, 200); task1.Start(); task2.Start(); Task.WhenAll(task1, task2).Wait(); Console.WriteLine("ShowPrime2 總結為:" + task2.Result); Console.ReadKey(); void ShowPrime1UseArgs(object? args) { int count = (int)args!; /* ... 省略 ... */ } string ShowPrime2UseArgs(object? args) { int count = (int)args!; /* ... 省略 ... */ }

1 列、2 列放入方法名稱來委派,第二個引數是要傳入的變數,但如同 10 列與 12 列,要修改傳入參數,這種寫法很難寫,但測過效率很好。

ContinueWith

既然有完成這件事,就應該有 callback 回呼函式的設計吧?有的,就是 Task 物件的 .ContinueWith() 方法,傳入委派方法,在 Task 完成的時候繼續呼叫,以下示範 task1 回呼用 arrow function 寫的匿名委派方法,印出一行的 "ShowPrime1 是否完成:" ;而 task2 回呼已寫好的 ShowResult 方法,傳入 Task<string> 物件並用 .Result 取其結果:

Task task1 = new Task(() => ShowPrime1(200)); task1.ContinueWith(task => Console.WriteLine("ShowPrime1 是否完成:" + task.IsCompleted)); task1.Start(); Task<string> task2 = new Task<string>(() => ShowPrime2(300)); task2.ContinueWith(ShowResult); task2.Start(); Console.ReadKey(); void ShowResult(Task<string> task) { Console.WriteLine("總結為:" + task.Result); }

結果像是:

2
        2
3
5
        3
7
        5
        7
    .
    .
    .
1193
1201
        1933
1213
1217
1223
        1949
        1951
ShowPrime1 是否完成:True
        1973
        1979
        1987
總結為:第300個質數是1987花費197毫秒

同時有 Task 和無 Task

上面的 ShowPrime1 和 ShowPrime2 都是本身內容沒有需要 await 其他方法的方法,本身並非 async 可等待,用 Task 搭配 Task.WaitAllTask.WhenAll 可在全部都結束時繼續後面的動作。

但是如果多個 Task 其中有方法是可等待,有些不是,要注意寫法,例如以下程式碼

Task task1 = new Task(PrintStar, 20000); Task task2 = new Task(PrintDash, 20000); task1.Start(); task2.Start(); Task.WhenAll(new Task[] { task1, task2 }).Wait(); Console.WriteLine("Done"); Console.ReadKey(); void PrintStar(object? arg) { if (arg == null) return; int amount = (int)arg; for (int i = 0; i < amount; i++) { Console.Write('*'); } } void PrintDash(object? arg) { if (arg == null) return; int amount = (int)arg; for (int i = 0; i < amount; i++) { Console.Write('-'); } }

PrintStar 會依傳入數字印出星字號 *,PrintDash 會依傳入數字印出減號 -,因為以 Task 來執行這兩個方法,所以同時交錯印,兩者完成後再印 Done 出來。

******************************----------------
--------*********----***----------*-----------
**************----------------*******-------**
------*******-------------**----**------****--
-------------**-------------------****--------
---**************-------**---*------------****
*****------**********-----*---**--------------
---***-------------------------Done

如果將 PrintDash 改成去 SQL 撈 Employee 中最大 Salary 來做為印 - 的個數,因為有 await Dapper 的 QueryFirstOrDefaultAsync 方法,所以 PrintDash 方法加了 async 且回傳改為 Task,如下:

Task task1 = new Task(PrintStar, 20000); Task<Task> task2 = new Task<Task>(PrintDash); task1.Start(); task2.Start(); Task.WhenAll(new Task[] { task1, task2 }).Wait(); Console.WriteLine("Done"); Console.ReadKey(); void PrintStar(object? arg) { if (arg == null) return; int amount = (int)arg; for (int i = 0; i < amount; i++) { Console.Write('*'); } } async Task PrintDash(){ string sql = "WAITFOR DELAY '00:00:01'; SELECT TOP(1) Salary FROM [Employee] ORDER BY Salary DESC"; int amount = 0; using (SqlConnection conn = new SqlConnection("...")) { amount = await conn.QueryFirstOrDefaultAsync<int>(sql); } for (int i = 0; i < amount; i++) { Console.Write('-'); } }

以上 task2 用 Task<Task> 的寫法會發現 Done 字樣,並不是在 PrintDash 完結後才印出來,前面就印了,明明程式碼的第 5 列有做 .Wait(),結果會像這樣:

**********************************************
**********************************************
*************************************---------
---***********------*******----Done
----------------------------------------------
----------------------------------------------
----------------------------------------------
------------------------

正確的寫法應該是下面,PrintDash 本身就是可等待會回傳 Task,直接用 task2 去接,不需要寫成 Task<Task>,而且直接開始了所以 task2 也不需要做 .Start()

Task task1 = new Task(PrintStar, 20000); Task task2 = PrintDash(); task1.Start(); Task.WhenAll(new Task[] { task1, task2 }).Wait(); Console.WriteLine("Done"); Console.ReadKey();

正確的結果:

**********************************************
**********************************************
*************************************---------
---***********------*******-------------------
----------------------------------------------
----------------------------------------------
----------------------------------------------
--------------------Done

或是如下使用 Task.Run,但是此靜態方法無法傳入引數,只能用箭頭函數

Task task1 = new Task(PrintStar, 20000); Task task2 = Task.Run(() => PrintDash()); task1.Start(); Task.WhenAll(new Task[] { task1, task2 }).Wait(); Console.WriteLine("Done"); Console.ReadKey();

如果用印出 Thread.CurrentThread.ManagedThreadId 觀察會發現,使用 Task task2 = PrintDash(); 的裡面 await 前和外面會是同一個 MangedThreadId,而裡面 await 後和外面是不同 ManagedThreadId。

而使用 Task task2 = Task.Run(() => PrintDash());,的裡面外面就是不同 MangedThreadId 了。

Queue 範例

利用 TaskContinueWith 可做一連串的排序作業。如下,PrintStar 方法有兩個參數,第一個 int 是印星字號 * 的個數,第二個 int 是顯示第幾次的呼叫。而 QueuedTasks 是一個 List 來存放 Task 物件,依序塞入五個 Task,再呼叫 ProcessQueue 方法,這個方法中會將 QueuedTasks 最前面的 Task 取出來,設定 completed 後會回呼自己,然後有做 Thread.Sleep(1000); 在執行完會暫停一秒,也因為要給 ContinueWith 做 callback,所以 ProcessQueue 方法要有 Task 參數,也就在第一次單獨呼叫時傳入 null,而 ProcessQueue 中結束後要回傳一個 Task.CompletedTask 作為 voidTask 化後的「已完成工作」。以下參考:

IList<Task> QueuedTasks = new List<Task>(); QueuedTasks.Add(new Task(() => PrintStar(10000, 1))); QueuedTasks.Add(new Task(() => PrintStar(10000, 2))); QueuedTasks.Add(new Task(() => PrintStar(10000, 3))); QueuedTasks.Add(new Task(() => PrintStar(10000, 4))); QueuedTasks.Add(new Task(() => PrintStar(10000, 5))); ProcessQueue(null); Console.ReadKey(); Task ProcessQueue(Task task) { if (QueuedTasks.Count > 0) { Task task0 = QueuedTasks[0]; QueuedTasks.RemoveAt(0); task0.ContinueWith(ProcessQueue); task0.Start(); Thread.Sleep(1000); } return Task.CompletedTask; } void PrintStar(int amount, int step) { Console.WriteLine($"這是第{step}次,開始"); for (int i = 0; i < amount; i++) { Console.Write('*'); } Console.WriteLine($"這是第{step}次,結束"); }

上面的 List QueuedTasks 的好處是一直 Add 要做的 Task,只要執行一次 ProcessQueue 方法,就會持續到做完。但是如果同時有多人在執行,例如 API 讓多人同時使用,做諸如 Log 的動作,在背景累積大量的 Task,也只能由一個人一個執行緒去執行 ProcessQueue,否則一個已經 .Start()Task 若再由其他執緒來 .Start(),是會拋出例外的。

以上可以加入一個 bool 的變數 IsQueueProcessing 來控制,呼叫 AddPrintStar 時就先檢查,若 true 表示已在執行中則只有做加入 List,若 false 表示未在執行中就啟動 ProcessQueue。

bool IsQueueProcessing = false; IList<Task> QueuedTasks = new List<Task>(); AddPrintStar(10000, 1); AddPrintStar(10000, 2); AddPrintStar(10000, 3); AddPrintStar(10000, 4); AddPrintStar(10000, 5); Console.ReadKey(); void AddPrintStar(int amount, int step) { QueuedTasks.Add(new Task(() => PrintStar(amount, step))); if (!IsQueueProcessing) { IsQueueProcessing = true; ProcessQueue(null); } } Task ProcessQueue(Task task) { if (QueuedTasks.Count == 0) { IsQueueProcessing = false; } else { Task task0 = QueuedTasks[0]; QueuedTasks.RemoveAt(0); task0.ContinueWith(ProcessQueue); task0.Start(); Thread.Sleep(1000); } return Task.CompletedTask; } void PrintStar(int amount, int step) { Console.WriteLine($"這是第{step}次,開始"); for (int i = 0; i < amount; i++) { Console.Write('*'); } Console.WriteLine($"這是第{step}次,結束"); }