這篇文章簡單介紹了 .NET 非同步工作的延續(continuation),以及取消延續工作的一個特殊案例。
這裡所謂「工作的延續」,指的是等到某個非同步工作執行完畢之後才要接著執行的工作;你要它稱為非同步工作的「延續工作」或「接續工作」也可以。有了
假設某些情況下,我們無法使用
由於
在此範例中:
此範例程式的執行結果如下(在你的機器上執行時,接續工作 B 和 C 的順序可能不同):
在此範例中,唯有當 taskA 順利執行完畢,才會接著起始
當你碰到需要「射後不理」(fire-and-forget)的非同步工作時,這種類似觸發事件的寫法便可派上用場。
考慮一個場景:假設 TaskB 是 TaskA 的延續工作,而 Task C 又是 TaskB 的延續工作,即三者的執行順序是 TaskA 然後 TaskB 然後 TaskC。理論上,延續工作應該會等到前項工作執行完畢之後才會執行,故 TaskB 以及 TaskC 應該都是在 TaskA 完成之後才會執行。
然而,當 TaskB 被中途取消、而且 TaskA 尚未完成,此時便很可能會出現一個特殊情形:TaskA 和 TaskC 都會繼續執行,可是 TaskC 會比它的「前項的前項工作」TaskA 更早執行完畢。請看以下範例:
執行結果:
在此範例中,taskA 起始之後,接著立刻建立了延續工作 taskB,然後又建立 taskB 的延續工作 taskC,最後緊接著取消 taskB(第 11 行)。這裡用 ThreadSleep 方法來確保 taskA 要花一秒以上的時間才能執行完畢,故在取消 taskB 的時候,taskA 肯定還沒結束;在此同時,taskC 會因為前項工作 taskB 的取消而立刻開始執行,於是便會形成 taskC 比最初之前項工作 taskA 更早執行完畢的情形。
如果你要希望 taskA 不會因為子工作 taskB 的取消而導致「孫代」工作 taskC 提前執行,可以在建立 taskB 的時候使用
結果就會變成 TaskA 先執行完畢,然後才是 TaskC:
圖片來源:pngitem.com |
這裡所謂「工作的延續」,指的是等到某個非同步工作執行完畢之後才要接著執行的工作;你要它稱為非同步工作的「延續工作」或「接續工作」也可以。有了
async
和 await
語法的幫忙,在處理延續工作這件事情上,程式寫起來變得輕鬆許多。假設某些情況下,我們無法使用
async
和 await
語法,此時雖然能夠使用 Task
的 Result
屬性來等待工作的執行結果,或者呼叫 Wait
方法來等待工作完成,但它們都會阻擋當前的執行緒,如此便失去了非同步工作的意義。那麼,是否還有其他方法能夠通知我們某個非同步工作已經完成、以便繼續執行後續的工作?答案就是 Task
相關類別所提供的 ContinueWith
多載方法。ContinueWith
方法可以讓我們輕易的銜接兩個非同步工作,例如:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
webDownloadTask.ContinueWith(task => | |
{ | |
string content = task.Result; | |
Console.WriteLine($"網頁內容長度:{content.Length}"); | |
}); |
ContinueWith
方法會建立新的 Task
物件來封裝接續的工作,而且這些工作在創建之時,是處於等待狀態(TaskStatus.WaitingForActivation
),須等到前項工作(如上面範例的 webDownloadTask)執行完畢才會開始執行。此外,在預設情況下,透過 ContinueWith
方法所建立的工作都會動用到執行緒(由 TaskScheduler
從執行緒集區調用執行緒);也就是說,它們都會在各自的執行緒上面執行(無論前項工作是否有動用執行緒)。由於
ContinueWith
方法會傳回它所建立的 Task 物件,而該物件也能夠拿來做為其他工作的前項工作(antecedent),故可層層串接,不斷延續。例如:task.ContinueWith(...).ContinueWith(...)
。串接與組合多項工作
實務上,應用程式執行時可能會起始多個非同步工作,而這些工作之間亦可能有先後順序的關係,例如工作 A 執行完畢之後才能執行 B 和 C——亦即非同步工作 B 和 C 是工作 A 的延續。透過ContinueWith
方法,我們便能夠串接任意數量的非同步工作,並確保這些非同步工作的執行順序,或者把多個小工作組合起來完成一項複雜工作。參考以下範例:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
static void Main(string[] args) | |
{ | |
var taskA = Task.Run(() => Console.WriteLine("起始工作....")); | |
Task taskB = taskA.ContinueWith( antecedentTask => | |
{ | |
Console.WriteLine("從 Task A 接續 Task B."); | |
System.Threading.Thread.Sleep(4000); // 等待幾秒 | |
}); | |
Task taskC = taskA.ContinueWith( antecedentTask => | |
{ | |
Console.WriteLine("從 Task A 接續 Task C."); | |
}); | |
Task taskD = taskA.ContinueWith( antecedentTask => | |
{ | |
Console.WriteLine("從 Task A 接續開始 Task D."); | |
}); | |
Task.WaitAll(taskB, taskC); | |
Console.WriteLine("程式結束"); | |
} |
在此範例中:
- taskA 起始之後,第 8~13 行有一個延續工作,而等到 taskA 完成之後,會接著起始兩個延續工作:taskB 和 taskC。對 B 和 C 而言,A 是他們的前項工作。
- 前項工作執行完畢後,以
ContinueWith
方法所指定的延續工作將自動以非同步的方式開始執行。此例的 taskA 執行完畢之後,接著會分別以非同步的方式起始 taskB 和 taskC;至於哪一個延續工作會先執行則不一定(無法在編譯時期確定)。 - 以
ContinueWith
方法所指定的延續工作可以透過參數 antecedentTask 來獲取前項工作,以便得知前項工作當前的狀態。
此範例程式的執行結果如下(在你的機器上執行時,接續工作 B 和 C 的順序可能不同):
TaskContinuationOptions
Task.ContinueWith
方法有數個多載版本,其中有些可以傳入列舉型別 TaskContinuationOptions
所組成的旗號來改變預設的延續行為。比如說,你可以使用 OnlyOnRanToCompletion
旗號來確保唯有在前項工作順利完成(沒有發生錯誤)的情況下才能執行延續工作,或者用 OnlyOnFaulted
和 OnlyOnCanceled
來指定唯有在前項工作「發生錯誤」或者「被取消」的情況。又如 NotOnRanToCompletion
,則是限定前項工作「沒有順利完成」的情況才能執行延續工作。
TaskContinuationOptions
的完整定義可參考 MSDN 線上文件。
底下是一個簡單範例,示範如何利用 TaskContinuationOptions
來設定只有當前項工作發生某種狀況時才去觸發(起始)某個延續工作——這等於是非同步的事件模型。範例程式如下:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
static void Main(string[] args) | |
{ | |
Task taskA = Task.Run( | |
() => Div(10, 5) ); // 若把第二個參數改成 0,接續的工作會變成 taskOnFailed。 | |
Task taskOnFailed = taskA.ContinueWith( | |
antecedentTask => | |
{ | |
Console.WriteLine("Task A 已失敗! IsFaulted={0}", antecedentTask.IsFaulted); | |
}, | |
TaskContinuationOptions.OnlyOnFaulted); // 當前置工作失敗時才起始此工作 | |
Task taskOnCompleted = taskA.ContinueWith( | |
antecedentTask => | |
{ | |
Console.WriteLine("Task A 已完成! IsCompleted={0}", antecedentTask.IsCompleted); | |
}, | |
TaskContinuationOptions.OnlyOnRanToCompletion); // 當前置工作完成後才起始此工作 | |
taskOnCompleted.Wait(); | |
} | |
static int Div(int dividend, int divisor) | |
{ | |
return dividend / divisor; | |
} |
在此範例中,唯有當 taskA 順利執行完畢,才會接著起始
taskOnCompleted
所代表的非同步工作。如果 taskA 執行失敗,則只有 taskOnFailed
工作會接著執行。當你碰到需要「射後不理」(fire-and-forget)的非同步工作時,這種類似觸發事件的寫法便可派上用場。
你還可以用 ExecuteSynchronously
旗號來告訴工作排程器:當前項工作執行完畢時,請立刻以當前的執行緒來執行此延續工作,而不要將它排入佇列,亦即不要將它執行於另一條執行緒。一般而言,只有在延續工作需要盡快執行的情況才需要使用這個旗號。
取消「中間的」延續工作
預設情況下,當你取消一項工作,則與該項工作關聯的「延續工作」都會立刻變成可執行的狀態(這句話可以簡單理解成:一旦前項工作取消,它的子工作便會「幾乎」立刻接著執行)。然而,當 TaskB 被中途取消、而且 TaskA 尚未完成,此時便很可能會出現一個特殊情形:TaskA 和 TaskC 都會繼續執行,可是 TaskC 會比它的「前項的前項工作」TaskA 更早執行完畢。請看以下範例:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
static void Main(string[] args) | |
{ | |
Task taskA = Task.Run(DoSomething); | |
var cancelManager = new CancellationTokenSource(); | |
Task taskB = taskA.ContinueWith( | |
_ => Console.WriteLine("這裡不會執行"), | |
cancelManager.Token); | |
Task taskC = taskB.ContinueWith( | |
_ => Console.WriteLine("TaskC 執行完畢。")); | |
cancelManager.Cancel(); // 取消 taskB | |
Console.ReadKey(); | |
} | |
static void DoSomething() | |
{ | |
Thread.Sleep(1500); | |
Console.WriteLine("TaskA 執行完畢。"); | |
} |
執行結果:
TaskC 執行完畢。
TaskA 執行完畢。
在此範例中,taskA 起始之後,接著立刻建立了延續工作 taskB,然後又建立 taskB 的延續工作 taskC,最後緊接著取消 taskB(第 11 行)。這裡用 ThreadSleep 方法來確保 taskA 要花一秒以上的時間才能執行完畢,故在取消 taskB 的時候,taskA 肯定還沒結束;在此同時,taskC 會因為前項工作 taskB 的取消而立刻開始執行,於是便會形成 taskC 比最初之前項工作 taskA 更早執行完畢的情形。
如果你要希望 taskA 不會因為子工作 taskB 的取消而導致「孫代」工作 taskC 提前執行,可以在建立 taskB 的時候使用
TaskContinuationOptions
的 LazyCancellation
旗號來告訴工作排程器:當我(taskB)被取消時,請延後這個取消動作,直到前項工作結束之後才執行取消動作。故前面範例可以修改成:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Task taskB = taskA.ContinueWith( | |
_ => Console.WriteLine("這裡不會執行"), | |
cancelManager.Token, | |
TaskContinuationOptions.LazyCancellation, | |
TaskScheduler.Current); |
結果就會變成 TaskA 先執行完畢,然後才是 TaskC:
TaskA 執行完畢。
TaskC 執行完畢。
參考資料
- Programming C# 8.0 by Ian Griffiths