ContinueWith 与 await






4.29/5 (8投票s)
下面将讨论 TPL 的 Task 类中可用的 ContinueWith 函数以及 C# 5.0 中引入的 await 关键字,用于支持异步调用。
TPL 是 C# 4.0 版本中引入的新库,用于提供对线程的良好控制,通过线程并行执行来利用多核 CPU。下面的讨论不是关于 TPL,而是关于 TPL 的 Task 类中可用的 ContinueWith
函数以及 C# 5.0 中引入的 await 关键字,用于支持异步调用。
ContinueWith - 它是 Task 上可用的一个方法,允许在 Task 完成执行后执行代码。简单来说,它允许延续。
这里需要注意的是,ContinueWith
也返回一个 Task
。这意味着你可以将 ContinueWith
附加到此方法返回的 Task 上。
示例
public void ContinueWithOperation()
{
Task<string> t = Task.Run(() => LongRunningOperation("Continuewith", 500));
t.ContinueWith((t1) =>
{
Console.WriteLine(t1.Result);
});
}
在上面的代码中,新创建的 Task 运行 LongRunningOperation
,一旦 Task 执行完成,ContinueWith
就在返回的 Task 上执行操作,并打印 Task 的结果。
ContinueWith
操作的任务默认由线程调度器执行。你也可以提供其他调度器来在上面运行任务,这将在本文稍后讨论。
注意:下面的代码是 Task 调用 LongRunningOperation
。这里的 LongRunningOpertion
只是一个示例,在实际程序中,你不能在 Task 上调用长时间运行的操作,如果你想调用长时间运行的任务,则需要传递 TaskCreationOperation.LongRunning
。
private string LongRunningOperation(string s, int sec)
{
Thread.Sleep(sec);
return s + " Completed";
}
await – 这是一个关键字,它导致运行时在新的 Task 上运行操作,并导致执行线程返回并继续执行(在大多数情况下,执行的是应用程序的主线程)。一旦 await 操作完成,它会返回到它离开的地方(即返回调用者,返回保存的状态),然后开始执行语句。
因此,await 会等待新创建的 Task 完成,并在等待的任务执行完成后确保延续。
await 关键字与 async 一起用于在 C# 中实现异步编程。之所以称为异步编程,是因为运行时会在遇到 await 关键字时捕获程序的[状态](https://codeproject.org.cn/Articles/747835/ContinueWith-Vs-await)(这类似于迭代器中的 yield),并在等待的任务完成后恢复状态,以便延续在正确的上下文中运行。
示例
public async void AsyncOperation()
{
string t = await Task.Run(() => LongRunningOperation("AsyncOperation", 1000));
Console.WriteLine(t);
}
在上面的示例中,新创建的 Task 调用 LongRunningOperation
,当主线程遇到 await 关键字时,执行会中断(即主线程返回到 AsyncOpetion
的调用者)并继续执行。一旦 LongRunningOpertaion
完成,Task 的结果就会打印到控制台。
因此,由于在遇到 await 时保存了状态,流程会在同一上下文中返回,并且 Task 的一个操作就会被执行。
注意:状态包含有关 executioncontext/synchronizationcontext 的详细信息。
因此,从上面可以清楚地看出,Task.ContinueWith
和 await Task 都会等待 Task 完成,并在 Task 完成后允许延续。但是它们的工作方式不同。
ContinueWith 和 await 之间的区别
- 保存状态以在执行上下文中返回
ContinueWith 不保存任何状态,除非提供了调度器,否则使用 ContinueWith 附加的延续操作会在默认线程调度器上运行。
await – 遇到此关键字时,状态会被保存,并且一旦 await 所在的 Task 完成,执行流就会加载保存的状态数据并开始执行 await 之后的语句。(注意:状态包含有关 executioncontext/synchronizationcontext 的详细信息。)
- 将完成的 Task 结果发布到 UI 控件
下面是使用
ContinueWith
和 await 在 UI 控件上显示已完成 Task 结果的示例。ContinueWith
考虑下面的代码,它会在 UI 标签上显示已完成 Task 的结果。
public void ContinueWithOperation() { CancellationTokenSource source = new CancellationTokenSource(); source.CancelAfter(TimeSpan.FromSeconds(1)); Task<string> t = Task.Run(() => LongRunningOperation("Continuewith", 500 )); t.ContinueWith((t1) => { if (t1.IsCompleted && !t1.IsFaulted && !t1.IsCanceled) UpdateUI(t1.Result); }); } private void UpdateUI(string s) { label1.Text = s; }
注意:
LogRunningOperation
已在上面提供。当上述代码被执行时,会出现以下运行时异常。
此异常发生是因为 Continuation 操作 UpdateUI 操作在不同的线程上运行,在这种情况下,线程将由默认线程调度器(ThreadPool)提供,它没有任何关于要在其上运行的 Synchronization context 的信息。
要避免异常,必须传递一个线程调度器,该调度器将数据传递到 UI
SynchronizationContenxt
。在下面的代码中,TaskScheduler.FromCurrentSynchronizationContext()
方法传递了与 UI 相关的线程调度器。t.ContinueWith((t1) => { if (t1.IsCompleted && !t1.IsFaulted && !t1.IsCanceled) UpdateUI(t1.Result); }, TaskScheduler.FromCurrentSynchronizationContext());
await
考虑下面的代码,它会在 UI 上显示完成结果。
public async void AsyncOperation() { try { string t = await Task.Run(() => LongRunningOperation("AsyncOperation", 10000)); UpdateUI(t); } catch (Exception ex) { MessageBox.Show(ex.Message); } }
使用 await 编写的代码在将数据发布到 UI 控件时不会引发任何异常,await 不需要特殊代码。这是因为正如在差异的第 1 点中所讨论的,在遇到 await 时,会保存包含
SynchronizationContext
信息的状态数据。因此,如果要将数据发布到 UI,await 是一个不错的选择,因为不需要额外的护理/代码来将数据发布到 UI。
- 处理异常和取消
请考虑下面的代码示例,用于
ContinueWith
和 await 方法处理异常和取消的任务。ContinueWith
下面是如何使用
ContinueWith
处理异常/取消的示例代码。public void ContinueWithOperationCancellation() { CancellationTokenSource source = new CancellationTokenSource(); source.Cancel(); Task<string> t = Task.Run(() => LongRunningOperationCancellation("Continuewith", 1500, source.Token), source.Token); t.ContinueWith((t1) => { if (t1.Status == TaskStatus.RanToCompletion) Console.WriteLine(t1.Result); else if (t1.IsCanceled) Console.WriteLine("Task cancelled"); else if (t.IsFaulted) { Console.WriteLine("Error: " + t.Exception.Message); } }); }
在上面的代码中,通过使用
TaskStatus
处理了取消/异常。另一种方法是利用TaskContinuationOptions.OnlyOnRanToCompletion
。t.ContinueWith( (antecedent) => { }, TaskContinuationOptions.OnlyOnRanToCompletion);
await
下面是如何使用 await 处理异常/取消的示例代码。
public async void AsyncOperationCancellation() { try { CancellationTokenSource source = new CancellationTokenSource(); source.Cancel(); string t = await Task.Run(() => LongRunningOperationCancellation("AsyncOperation", 2000, source.Token), source.Token); Console.WriteLine(t); } catch (TaskCanceledException ex) { Console.WriteLine(ex.Message); } catch (Exception ex) { Console.WriteLine(ex.Message); } }
上面的代码不使用 Task 状态,如 continuewith。它使用 try ..catch 块来处理异常。要处理取消,需要一个带有
TaskCanceledException
的 catch 块。因此,从上面的示例来看,我的观点是,当使用延续时,异常/取消处理会以非常干净的方式完成。
下面是
LongRunningOperationCancellation
方法的代码。private string LongRunningOperationCancellation(string s, int sec, CancellationToken ct) { ct.ThrowIfCancellationRequested(); Thread.Sleep(sec); return s + " Completed"; }
以上三种情况显示了
ContinueWith
和 await 之间编码的差异。但下面的情况显示了为什么 await 比ContinueWith
更好。 - 复杂流程
考虑下面的函数,它用于计算数字的阶乘
public KeyValuePair<int, string> Factorial(int i) { KeyValuePair<int, string> kv; int fact = 1; for (int j = 1; j <= i; j++) fact *= j; string s = "factorial no " + i.ToString() + ":" + fact.ToString(); kv = new KeyValuePair<int, string>(i, s); return kv; }
上面的函数计算数字的阶乘,并将
KeyValuePair
返回给调用者以显示计算结果。现在的问题陈述是,上述函数用于计算 1 到 5 的数字的阶乘。
ContinueWith
下面是计算 1 到 5 之间数字阶乘的代码。下面代码的期望是计算阶乘,按顺序显示它,并在完成后在控制台上打印“Done”消息。
public void ContinueWithFactorial() { for (int i = 1; i < 6; i++) { int temp = i; Task<KeyValuePair<int, string>> t = Task.Run(() => Factorial(temp)); t.ContinueWith((t1) => { KeyValuePair<int, string> kv = t1.Result; Console.WriteLine(kv.Value); }); } Console.WriteLine("Done"); }
代码执行后,你会发现“Done”消息立即打印出来(即先打印),然后数字的阶乘显示在屏幕上。另一个问题是数字的阶乘没有按顺序显示。下面是代码执行的输出。
因此,为了解决上述代码的问题,需要像这样重构代码。
public void FactContinueWithSeq(int i) { Task<KeyValuePair<int, string>> t = Task.Run(() => Factorial(i)); var ct = t.ContinueWith(((t1) => { KeyValuePair<int, string> kv = t1.Result; int seed = kv.Key; if (seed < 6) { Console.WriteLine(kv.Value); seed++; FactContinueWithSeq(seed); } else { Console.WriteLine("Done"); return; } })); }
上面的函数将被这样调用:p.FactContinueWithSeq(1)。
在上面的代码中,为了保持顺序,需要一个接一个地触发 Task。为此,一旦一个 Task 完成其执行,就会在 Continuation 中使用
ContinueWith
方法再次调用函数。这就像在进行递归调用。并且要在最后显示“Done”消息,需要检查函数的种子。这会检查种子值是否增加到 6。
但现在需要连接到
FactContinueWithSeq
完成后的 continuation,为此需要这样做:TaskCompletionSource<string> tcs = new TaskCompletionSource<string>(); public Task<string> FactAsyncTask { get { return tcs.Task; } } public void FactContinueWithSeqAsync(int i) { Task<KeyValuePair<int, string>> t = Task.Run(() => Factorial(i)); var ct = t.ContinueWith(((t1) => { KeyValuePair<int, string> kv = t1.Result; int seed = kv.Key; if (seed < 5) { Console.WriteLine(kv.Value); seed++; FactContinueWithSeqAsync(seed); } else { tcs.SetResult("Execution done"); } })); }
调用上述函数
p.FactContinueWithSeqAsync(1);
Task<string> t = p.FactAsyncTask;
t.ContinueWith((t1)=> Console.WriteLine(t.Result));
在上面的代码中,TaskCompletionSource
用于实现为阶乘计算的完成提供 continuation 的代码。
因此,需要编写大量代码才能获得预期的结果,即按顺序计算阶乘,等待计算,并在计算完成后在控制台上打印“Done”消息。
await
现在,使用 await 相同代码可以这样完成:
public async void AwaitWithFactorial()
{
for (int i = 1; i < 6; i++)
{
int temp = i;
Task<KeyValuePair<int, string>> t = Task.Run(() => Factorial(temp));
await t;
Console.WriteLine(t.Result.Value);
}
Console.WriteLine("Done");
}
上面的代码简单明了,不需要进行大量重构,而使用 ContinueWith
进行相同操作则需要。
摘要
从上面 ContinueWith
vs await 的区别/比较可以看出,在许多场景下使用 await 非常有帮助。
但也有一些场景,不需要更复杂的处理,并且错误/取消处理也很简单,在这种情况下,continuewith 是有用的。但这种情况非常罕见。
始终选择简单、容易和清晰的解决方案是明智的,对此,我的建议是始终选择 await 而不是 ContinueWith
。