大家好,本次继续分享自己的学习经历。本文主要分享Task异步编程内容,如果能帮助大家希望多多关注文章末尾的微信公众号和知乎三连。各位举手之劳是对我更新技术文章最大的支持。
调用图(call graph)
为了获得上述好处,下列操作建议异步编写:
注:
代码例子:
//例子1,同步方法进行Compute-bound操作
static void Main(string[] args)
{
DisplayCounts();
//粗粒度异步调用
//Task.Run(()=>{ DisplayCounts(); });
Console.ReadKey();
}
static void DisplayCounts()
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine(GetPrimesCount(i*1000000 + 2,1000000) + "between" + (i * 1000000) + "and" + ((i + 1) * 1000000 - 1));
}
Console.WriteLine("Done!");
}
static int GetPrimesCount(int start , int count)
{
return ParallelEnumerable.Range(start, count).Count(n=> Enumerable.Range(2,(int)Math.Sqrt(n)-1).All(i=>n%i>0));
}
//例子2,异步方法执行Compute-bound操作
static void Main(string[] args)
{
//细粒度异步调用
DisplayCounts();
Console.ReadKey();
}
static void DisplayCounts()
{
for (int i = 0; i < 10; i++)
{
var awaiter = GetPrimesCount(i * 1000000 + 2, 1000000).GetAwaiter();
awaiter.OnCompleted(()=>
Console.WriteLine(awaiter.GetResult())
);
}
Console.WriteLine("Done!");
}
static Task<int> GetPrimesCount(int start , int count)
{
return Task.Run(()=> ParallelEnumerable.Range(start, count).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0)));
}
在例子1中同步执行是有序输出,例子2中的执行输出顺序是乱的且Done是最先输出出来的,这个结果并不是我们想要的接下来需要进行一些优化。
代码示例:
//例子3,有序异步执行
static void Main(string[] args)
{
//这里的调用非完全异步
DisplayCounts();
Console.ReadKey();
}
static void DisplayCounts()
{
DisplayCountsFrom(0);
}
static void DisplayCountsFrom(int i)
{
var awaiter = GetPrimesCount(i * 1000000 + 2, 1000000).GetAwaiter();
awaiter.OnCompleted(() =>
{
Console.WriteLine(awaiter.GetResult());
if (++i < 10)
{
DisplayCountsFrom(i);
}
else
Console.WriteLine("Done!");
});
}
static Task<int> GetPrimesCount(int start, int count)
{
return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0)));
}
//例子4,完全异步执行
static void Main(string[] args)
{
DisplayPrimeCountsAsync();
Console.ReadKey();
}
public static Task DisplayPrimeCountsAsync()
{
var machine = new PrimesStateMachine();
machine.DisplayCountsFrom(0);
return machine.Task;
}
public static void DisplayCountsFrom(int i)
{
var awaiter = GetPrimesCount(i * 1000000 + 2, 1000000).GetAwaiter();
awaiter.OnCompleted(() =>
{
Console.WriteLine(awaiter.GetResult());
if (++i < 10)
{
DisplayCountsFrom(i);
}
else
Console.WriteLine("Done!");
});
}
public static Task<int> GetPrimesCount(int start, int count)
{
return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0)));
}
}
class PrimesStateMachine
{
TaskCompletionSource<object> _tcs = new TaskCompletionSource<object>();
public Task Task { get { return _tcs.Task; } }
public void DisplayCountsFrom(int i)
{
var awaiter = Program.GetPrimesCount(i * 1000000 + 2, 1000000).GetAwaiter();
awaiter.OnCompleted(() =>
{
Console.WriteLine(awaiter.GetResult());
if (++i < 10)
{
DisplayCountsFrom(i);
}
else
{
_tcs.SetResult(null);
Console.WriteLine("Done!");
}
});
}
}
以上的写法,依旧过于繁琐接下来通过异步关键字来进行下一步优化减少代码量。
//例子5
static async Task Main(string[] args)
{
await DisplayPrimeCountsAsync();
Console.ReadKey();
}
public async static Task DisplayPrimeCountsAsync()
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine(await GetPrimesCountAsync(i * 1000000 + 2,1000000) + "");
}
Console.WriteLine("Done");
}
public static Task<int> GetPrimesCountAsync(int start, int count)
{
return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0)));
}
async和await关键字可以让你写出和同步代码一样简介且结构相同的异步代码
await关键字简化了附加continuation(继续体)的过程。
结构如下:
var result = await expression;
statement(s);
它的作用相当于:
var awaiter = expression.GetAwaiter();
awaiter.OnCompleted(()=>{
var result = await expression;
statement(s);
})
async修饰符会让编译器把await当做关键字而不是修饰符(c#5以前可能会使用await作为标识符)
async 修饰符只能应用于方法(包括lambad表达式)。
async 修饰符对方法的签名或public元数据没有影响(和unsafe一样),它只会影响方法内部。
使用了async修饰符的方法就是“异步函数”。
//WPF示例非异步代码1
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="25"></RowDefinition>
<RowDefinition></RowDefinition>
</Grid.RowDefinitions>
<Button Content="ok" VerticalAlignment="Top" Click="Button_Click_1"></Button>
<DockPanel x:Name="myPanel" Grid.Row="1">
</DockPanel>
</Grid>
public partial class MainWindow : Window
{
TextBlock textBlock;
public MainWindow()
{
InitializeComponent();
textBlock = new TextBlock();
myPanel.Children.Add(textBlock);
}
void Go()
{
for (int i = 1; i < 5; i++)
{
textBlock.Text += GetPrimesCount(i * 1000000 + 2, 1000000) + "" + Environment.NewLine;
}
}
int GetPrimesCount(int start, int count)
{
return ParallelEnumerable.Range(start, count).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0));
}
private void Button_Click_1(object sender, RoutedEventArgs e)
{
Go();
}
}
//WPF示例异步代码2
//将示例1中的GetPrimesCount方法修改为异步则不会阻塞UI
public static Task<int> GetPrimesCountAsync(int start, int count)
{
return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0)));
}
伪代码:
为本线程设置同步上下文(基于WPF)
while(!线程结束)
{
等着消息队列中发生一些事情
如果发生了事情,看看是哪种消息?
如果是键盘/鼠标消息->触发 event handeler
如果是用户BeginInvoke/Invoke 消息->执行委托
}
public partial class MainWindow : Window
{
TextBlock textBlock;
public MainWindow()
{
InitializeComponent();
textBlock = new TextBlock();
myPanel.Children.Add(textBlock);
}
void Go()
{
for (int i = 1; i < 5; i++)
{
textBlock.Text += GetPrimesCount(i * 1000000 + 2, 1000000) + "" + Environment.NewLine;
}
}
int GetPrimesCount(int start, int count)
{
return ParallelEnumerable.Range(start, count).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0));
}
private void Button_Click_1(object sender, RoutedEventArgs e)
{
//这里的Task是粗粒度调用,将go这种同步方法统一都放到了worker线程中执行。语法看起来好像并没有任何坏处其实会引用race condition
Task.Run(()=> Go());
}
public static Task<int> GetPrimesCountAsync(int start, int count)
{
return Task.Run(() => ParallelEnumerable.Range(start, count).Count(n => Enumerable.Range(2, (int)Math.Sqrt(n) - 1).All(i => n % i > 0)));
}
}
示例代码:
public void Go(){}
public async Task Go(){}
示例代码:
public async Task Go(){ //这里不要return,但必须有await的方法在Go的函数体内 }
示例代码:
//编译器层将会处理以下代码实现
Task Do()
{
var tcs = new TaskCompletionSource<object>();
var awaiter = Task.Delay(5000).GetAwaiter();
awaiter.OnCompleted(()=>
{
try
{
awaiter.GetResult();
int answer = 21 * 2;
tcs.SetResult(null);
Console.WriteLine(answer);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
return tcs.Task;
}
编写异步函数-富客户端场景下
非void返回类型的方法,返回Task
如果方法体返回TResult,那么异步方法就可以返回Task。
async Task<int> Get()
{
await Task.Delay(5000);
int anwser = 21 * 2;
return anwser;
}
其原理就是给TaskCompletionSource发送的信号带有值,而不是null
async Task Do()
{
//调用时加上await返回的值则是int型
int anwser = await Get();
//调用时不加上await返回的值则是Task<int>型
Task<int> anwser = Get();
Console.WriteLine(anwser);
}
async Task<int> Get()
{
await Task.Delay(5000);
int anwser = 21 * 2;
return anwser;
}
与同步编程很相似,微软官方就是这么设计的。
c#中如何设计异步函数
编译器能对异步函数生成Task意味着什么?
异步调用图的执行
整个执行与之前的同步例子中调用图执行的顺序一样,因为我们对每个异步函数的调用都进行了await。
在调用图中创建了一个没有并行和重叠的连续流。
每个await在执行中都创建了一个间隙,在间隙后,程序可以从中断处恢复执行。
async Task Main()
{
//这一整个task调用链都是在主线程同步执行
//对所有的异步方法进行await,达到对所有方法同步调用的效果
await Go();//main thread
}
async Task Go()
{
var task = Doit();
await task;
Console.WriteLine("done");
}
async Task Doit()
{
var task = GetAnswer();
int answer = await task;
Console.WriteLine(answer);
}
async Task<int> GetAnswer()
{
var task = Task.Delay(5000);
await task;
int answer = 21 * 2;
return answer;
}
并行(parallelism)
不使用await来调用异步函数会导致并行执行的发生。
例如:_button.Click +=(sender,args)=>Go();
同样,可以并行跑两个操作:
var task1 = GetAnswer();
var task2 = GetAnswer();
await task1;
await task2;
异步Lambad表达式
匿名方法(包括Lambda表达式),通过使用async也可以编程异步方法。
调用方式也一样。
async Task Main()
{
Func<Task> unnamed = async () =>
{
await Task.Delay(1000);
Console.WriteLine("FOO");
};
await unnamed();
await NamedMethod();
}
async Task NamedMethod()
{
await Task.Delay(1000);
Console.WriteLine("Foo");
}
附加event handler的时候也可以使用异步Lambda表达式
异步的Lambda表达式也可以返回Task。
//wpf中按钮的触发的时间
private async void Button_Click_1(object sender, RoutedEventArgs e)
{
await Task.Run(()=> Go());
}
发布异常
富客户端应用通常依赖于几种的异常处理时间来处理UI线程上未捕获的异常。
其中内部原理就是:通过他们在自己的Try/Catch块来调用UI时间(在ASP.NET Core里就是页面处理的方法管道)
顶层的异步方法会使事情更加复杂,在这里Button_Click()是顶层方法因为没有再await它的地方了,所以它的返回类型是void就可以了。但当该方法被触发时下面声明的Exception则不会被发现。
private async void Button_Click_1(object sender, RoutedEventArgs e)
{
await Task.Run(()=> Go());
throw new Exception("will this be ignored?");
}
当点击按钮,event handler运行时,在await后,执行会正常的返回到消息循环1秒钟之后抛出的异常无法被消息循环中的catch块捕获。
为了缓解该问题,AsyncVoidMethodBuilder会捕获未处理的异常(在返回void的异步方法里),并把它们发布到同步上下文(如果出现的话),以确保全局异常处理时间能够触发。
注意
编译器只会把上述逻辑应用于返回类型为void的异步方法。
如果ButtonClick的返回类型是Task,那么未处理的异常将导致结果Task出错,然后Task无处可去(导致未观察到的异常出现)
一个有趣的细微差别:无论你在await前面还是后面抛出异常,都没有区别。
因此,下例中,异常会被发布到同步上下文(如果出现的话),而不会发布给调用者。
不直接将异常抛出回调用者的原因是为了确保可预测性和一致性。
在下例中,不管SomeCondition是什么值,InvalidOperationException将始终得到和导致Task出错同样的效果
async Task Foo()
{
if (someCondition) await Task.Delay(100);
throw new InvalidOperationException();
}
iterator 也是一样的:IEnumerable Foo(){ throw null; yield return 123; }
异步函数可以在await之前就返回。例子
static async Task Main(string[] args)
{
Console.WriteLine(await GetWebPageAsync("http://baidu.com"));
}
static Dictionary<string, string> _cache = new Dictionary<string, string>();
static async Task<string> GetWebPageAsync(string uri)
{
string html;
if (_cache.TryGetValue(uri, out html))
{
return html;
}
return _cache[uri] = await new WebClient().DownloadStringTaskAsync(uri);
}
如果URI在缓存中存在,那么不会有await发生,执行就会返回给调用者,方法会返回一个已经设置信号的Task,这就是同步完成。
当await同步完成的Task时,执行不会返回到调用者,也不同通过Continuation跳回。它会;立即执行到下个语句。
编译器是通过检查awaiter上的IsCompleted属性来实现这个优化的。也就是说无论何时,当你await的时候:
如果是同步完成,编译器会释放可短路Continuation的代码,
var awaiter = GetWebPageAsync().GetAwaiter();
if (awaiter.IsCompleted)
{
Console.WriteLine(awaiter.GetResult());
}
else
{
awaiter.OnCompleted(() => Console.WriteLine(awaiter.GetResult()));
}
注意
对一个同步返回的异步方法进行await,任然会引起一个小的开销(20纳秒左右)
反过来,跳回线程池,会引入上下文切换开销,可能是1-2毫秒
而跳回到UI的消息循环,至少是10倍开销(如果UI繁忙,那时间更长)
编写完全没有await的异步方法也是合法的,但是编译器会发出警告
但这类方法可以用于重载virtual/abstract方法
另外一种可以达到相同结果的方式是:使用Task.FromResult,它会返回一个已经设置好信号的Task。
Task<string> Foo(){ return Task.FromResult("bbb"); }
如果是从UI线程上调用,那么GetWebPageAsync方法是隐式线程安全的。可以连续调用它(从而启动多个并发下载),并且不需要lock来保护缓存。
有一种简单的方法可以实现这一点,而不必求助于lock或信令结构。我们创建一个“futures”(Task)的缓存,而不是字符串的缓存。注意并没有async:
static Dictionary<string, string> _cache2 = new Dictionary<string, string>();
static async Task<string> GetWebPageAsync2(string uri)
{
if (_cache.TryGetValue(uri, out var downloadTask))
{
return downloadTask;
}
return _cache[uri] = await new WebClient().DownloadStringTaskAsync(uri);
}
lock的不是下载过程,lock的是检查缓存的过程(很短暂),这个过程不影响并发
lock (_cache2)
{
if (_cache2.TryGetValue(uri,out var downloadTask))
{
return downloadTask;
}
else
{
return _cache2[uri] = new WebClient().DownloadStringTaskAsync(uri);
}
}
使用ValueTask注意事项
避免过度的弹回
对于在循环中多次调用的方法,通过调用ConfigureAwait方法,就可以避免重复的弹回到UI消息循环所带来的的开销。
这强迫Task不把continuation弹回给同步上下文。从而将开销削减到接近上下文切换的成本(如果您await的方法同步完成,则开销会小得多):
async void A() { await B(); };
async Task B()
{
for (int i = 0; i < 1000; i++)
{
await C().ConfigureAwait(false);
}
}
async Task C() { ... }
这意味着对于方法B和C,我们取消了UI线程中简单线程安全模型,即代码在UI线程上运行,并且只能在await语句期间被抢占。但是,方法A不收影响,如果在一个UI线程上启动,它将保留在UI线程上。
这种优化在编写库时特别重要:您不需要简化线程安全性带来的好处,因为您的代码通常不与调用方共享状态,也不访问UI控件。
使用取消标志来实现对并发进行取消,可以封装一个类:
//语法定义
class CancellationToken
{public void IsCancellationRequested { get; private set; }
public void Cancel(){ IsCancellationRequested = true; }
public void ThrowIfCancellationRequested()
{
if(IsCancellationRequested)
throw new OperationCanceledExcption();
}
}
//调用代码
async Task Foo(CancellationToken cancellationToken)
{
for(int i = 0; i < 10; i++)
{
Console.WriteLine(i);
await Task.Delay(1000);
cancellationToken.ThrowIfCancellationRequested();
}
}
当调用者想取消的时候,它调用CancellationToken上的Cancel方法。这就会把IsCancellationRequested设置为true,即会导致短时间后Foo会通过OperationCanceledException引发错误。
想获得取消标志(cancellation token),先实例化CancellationTokenSource:
var cancelSource = new CancellationTokenSource();
这会暴露一个token属性,它会返回一个cancellationtoken,所以我们可以这样调用:
var cancelSource = new CancellationTokenSource();
Task foo = Foo(cancelSource.Token);
...
...(some time later)
cancelSource.Cancel();
CLR里大部分的异步方法都支持CancellationToken,包括Delay方法。
async Task Foo(CancellationToken cancellationToken)
{
for(int i = 0; i<10; i++)
{
Console.WriteLine(i);
await Task.Delay(1000,cancellationToken);
}
}
这时,task在遇到请求时会立即停止(而不是1秒钟之后才停止)
这里,我们无需调用ThrowIfCancellationRequested,因为Delay会替我们做。
同步方法也支持取消(例如Task的Wait方法)。这种情况下,取消指令需要异步发出(例如,来自另一个Task)
var cancelSource = new CancellationTokenSource();
Task.Delay(5000).ContinueWith(ant=>cancelSource.Cancel());
...
事实上,您可以在构造CancellationTokenSource时指定一个时间间隔,以便在一段时间后启动取消。它对于实现超时非常有用,无论是同步还是异步:
var cancelSource = new CancellationTokenSource();
try{ await Foo(cancelSource.Token); }
catch(OperationcanceledException ex){ Console.WriteLine("Cancelled"); }
CancellationToken这个struct提供了一个Register方法,它可以让你注册一个回调委托,这个委托会在取消时触发。它会返回一个对象,这个对象在取消注册时可以被Dispose掉。
编译器的异步函数生成的Task在遇到未处理的OperationCanceledException异常时会自动进入取消状态(IsCanceled返回true,IsFaulted返回false)
使用Task.Run创建Task也是如此。这里是指向构造函数传递(相同的)CnacellationToken。
在异步场景中,故障Task和取消的Task之间的区别并不重要,因为它们在await时都会抛出一个OperationcanceledException。但这在高级并行编程场景(特别是条件continuation)中很重要。
假设定义了方法如下:
aync Task<int> Delay1() { await Task.Delay(1000); return 1; }
aync Task<int> Delay2() { await Task.Delay(1000); return 2; }
aync Task<int> Delay3() { await Task.Delay(1000); return 3; }
当一组Task中任何一个Task完成时,Task.WhenAny会返回完成的Task。
Task<int> winningTask = await Task.WhenAny(Delay1(),Delay2(),Delay3());
Console.WirteLine("Done");
Console.WirteLine(winningTask.Result);
因为Task.WhenAny本身就返回一个Task,我们对他进行await,就会返回最先完成的Task。
上例完全是非阻塞的,包括最后一行(当访问result属性时,winningTask已完成),但最好还是对winningTask进行await,因为异常无需AggregateExceotion包装就会重新抛出:
Console.WirteLine(await winningTask);
实际上,我们可以在一步中执行两个await:
Task<int> winningTask = await await Task.WhenAny(Delay1(),Delay2(),Delay3());
如果“没赢”的Task后续发生了错误,那么异常将不会被观察到,除非你后续对它们进行await(或者查询其Exception属性)
WhenAny很适合为不支持超时或取消的操作添加这些功能:
Task<string> task = SomeAsyncFunc();
Task winner = await (Task.WhenAny(task,Task.Delay(5000)));
if(winner != task) throw new TimeoutException();
string reuslt = await task;//Unwrap result/re-throw
注意:本例子中返回的结果是Task类型。
当传给它的所有的Task都完成后,Task.WhenAll会返回一个Task。
await Task.WhenAll(Delay1(),Delay2(),Delay3());
通过轮流对3个task进行awiat,也可以得到类似的结果:
Task task1 = Delay1(), task2 = Delay2(), task3 = Delay3();
await task1;await task2;await task3;
不同点是(除了3个await的低效):如果task1出错,我们就无需等待task2和task3了,它的错误也不会被观察到。
与之相对,Task.WhenAll直到所有Task完成,它才会完成,及时有错误发生。如果有多个错误,他们在的异常会包裹在Task的AggregateException里
await组合的Task,只会抛出第一个异常,想要看到所有的异常,你需要这样做:
Task task1 = Task.Run(()=>{ throw null; });
Task task2 = Task.Run(()=>{ throw null; });
Task all = Task.WhenAll(task1,task2);
try{ await all; }
catch
{
Console.writeLine(all.Exception.InnerExceptions.Count);
}
对一组Task调用WhenAll会返回Task,也就是所有Task的组合结果。
如果进行await,那么就会得到TResult[]:
Task<int> task1 = Task.Run(()=>1);
Task<int> task2 = Task.Run(()=>2);
int[] results = await Task.WhenAll(task1,task2);
实例
async Task<int> GetTotalSize(string[] uris)
{
IEnumerable<Task<byte[]>> downloadTasks = uris.Select(uri=>new WebClient().DownloadDataTaskAsync(uri));
byte[][] contents = await Task.WhenAll(downloadTasks);
return contents.Sum(c=>c.Lenght);
}
//语法优化
async Task<int> GetTotalSize(string[] uris)
{
IEnumerable<Task<int>> downloadTasks = uris.Select(async uri=>await new WebClient().DownloadDataTaskAsync(uri).Length);
int[] contentLengths = await Task.WhenAll(downloadTasks);
return contentLengths.Sum();
}
可以编写自定义的Task组合器。最简单的组合器接收一个task,看下例:
async static Task<TResult> WithTimeout<TResult>(this Task<TResult> task,TimeSpan timeout)
{
Task winner = await Task.WhenAny(task,TaskDelay(timeout)).ConfigureAwait(false);
if(winner != task) throw new TimeoutException();
return await task.ConfigureAwait(false);
}
这就是为等待的task添加了超时功能
因为这可能是一个库方法,无需与外界共享状态,所以在await时我们使用了ConfigureAwait(false)来避免弹回到UI的同步上下文。
通过在Task完成时取消Task.Delay我们可以改进上例的效率(避免了计时器的小开销):
async static Task<TResult> WithTimeout<TResult>(this Task<TResult> task,TimeSpan timeout)
{
var cancelSource = new CancellationTokenSource();
var delay = Task.Delay(timeout,cancelSource.Token);
Task winner = await Task.WhenAny(task,delay).ConfigureAwait(false);
if(winner == task)
cancelSource.Cancel();
else
throw new TimeoutException();
return await task.ConfigureAwait(false);
}
static Task<TResult> WithCancellation<TResult>(this Task<TResult> task, CancellationToken cancelToken)
{
var tcs = new TaskCompletionSource<TResult>();
var reg = cancelToken.Register(()=> tcs.TrySetCanceled());
task.ContinueWith(ant =>
{
reg.Dispose();
if (ant.IsCanceled)
tcs.TrySetCanceled();
else if (ant.IsFaulted)
tcs.TrySetException(ant.Exception.InnerException);
else
tcs.TrySetResult(ant.Result);
});
return tcs.Task;
}
接下来在看一个例子,这个组合器功能类似WhenAll,如果一个Task出错,那么其余的Task也立即出错:
async Task<TResult[]> WhenAllOrError<TResult>(params Task<TResult>[] tasks)
{
var killJoy = new TaskCompletionSource<TResult[]>();
foreach (var task in tasks)
{
task.ContinueWith(ant=>
{
if (ant.IsCanceled)
killJoy.TrySetCanceled();
else if (ant.IsFaulted)
killJoy.TrySetException(ant.Exception.InnerException);
});
}
return await await Task.WhenAny(killJoy.Task,Task.WhenAll(tasks)).ConfigureAwait(false);
}
上述代码中,TaskCompletionSourced的任务就是当任意一个Task出错时,结束工作。所以我们没有调用SetResult方法,只调用了它的TrySetCanceled和TrySetException方法。这里ContinueWith要比GetAwaiter().OnCompleted更方便,因为我们不访问Task的result,并且此刻不想弹回到UI线程。