C# 异步编程基础(九) 异步中的同步上下文、ValueTask<T>
此入门教程是记录下方参考资料视频的过程
开发工具:Visual Studio 2019
目录
C# 异步编程基础(六)Continuation 继续/延续 、TaskCompletionSource、实现 Task.Delay
C# 异步编程基础(十) 取消(cancellation)、进度报告、TAP(Task-Based Asynchronous Pattern)、Task组合器
发布异常
- 富客户端应用通常依赖于集中的异常处理事件来处理UI线程上未捕获的异常
例如WPF中的 Application.DispatcherUnhandledException
ASP.NET Core中定制ExceptionFilterAttribute也是差不多的效果 - 其内部原理就是:通过在它们自己的try/catch块来调用UI事件(在ASP.NET Core里就是页面处理方法的管道)
- 顶层的异步方法会使事件更加复杂
async void ButtonClick(object sender,RouteEventArgs args)
{
await Task.Delay(1000);
throw new Exception("Will this be ignored?");
}
- 当点击按钮,event handler运行时,在await后,执行会正常的返回到消息循环;一秒钟之后抛出的异常无法被消息循环中的catch块捕获
- 为了缓解该问题,AsyncVoidMethodBuilder会捕获未处理的异常(在返回void的异步方法里),并把它们发布到同步上下文(如果出现的话),以确保全局异常处理事件能够触发
注意
- 编译器只会把上述逻辑应用于返回类型为void的异步方法
- 如果ButtonClick的返回类型是Task,那么未处理的异常将导致结果Task出错,然后Task无处可去(导致未观察到的异常)
发布异常
- 一个有趣的细微差别:无论你在await前面还是后边抛出异常,都没有区别
- 因此,下例中,异常会被发布到同步上下文(如果出现同步上下文的话),而不会发布给调用者
async void Foo()
{
throw null;
await Task.Delay(1000);
}
- 如果同步上下文没有出现,异常将会在线程池上传播,从而终止应用程序
- 不直接将异常抛出回调用者的原因是为了确保可预测性和一致性
- 在下例中,不管someCondition是什么值,InvalidOperationException将始终得到和导致Task出错同样的效果
async Task Foo()
{
if(someCondition)
{
await Task.Dealy(1000);
}
throw new InvalidOperationException;
}
- Iterator也是一样的,本例中,异常绝不会直接返回给调用者,直到序列被遍历后,才会抛出异常
IEnumerable<int> Foo()
{
throw null;
yield return 123;
}
OperationStarted 和 OperationCompleted
- 如果存在同步上下文,返回void的异步函数也会在进入函数时调用其OperationStarted方法,在函数完成时调用其OperationCompleted方法
- 如果是为了对返回void的异步方法进行单元测试而编写一个自定义的同步上下文,那么重写这个两个方法确实很有用
优化:同步完成
- 异步函数可以在await之前就返回
例子
static async Task Main(string[] args)
{
Console.WriteLine(await GetWebPageAsync("http://www.bing.com"));
}
static Dictionary<string, string> _cache = new Dictionary<string, string>();
static async Task<string> GetWebPageAsync(string uri)
{
string html;
//若url存在就直接返回(同步)
if (_cache.TryGetValue(uri, out html))
{
return html;
}
return _cache[uri] = await new WebClient().DownloadStringTaskAsync(uri);
}
- 如果URL在缓存中存在,那么不会有await发生,执行就会返回给调用者,方法会返回一个已经设置信号的Task,这就是同步完成
- 当await同步完成的Task时,执行不会返回到调用者,也不会通过continuation跳回。它会立即执行到下个语句
- 编译器是通过检查awaiter上的IsCompleted属性来实现这个优化的。也就是说,无论何时,当你await的时候:
Console.WriteLine(await GenWebPageAsync("http://www.bing.com"));
- 如果是同步完成,编译器会释放可短路continuation的代码
var awaiter=GetWebPageAsync().GetAwaiter();
if(awaiter.IsCompleted)
{
Console.WriteLine(awaiter.GetResult());
}
else
{
awaiter.OnCompleted(()=>
{
Console.WriteLine(awaiter.GetResult());
})
}
注意
- 对一个同步返回的异步方法进行await,仍然会引起一个小的开销(20纳秒左右,2019年的PC)
- 反过来,跳回到线程池,会引入上下文切换开销,可能是1-2毫秒
- 而跳回到UI的消息循环,至少是10倍开销(如果UI繁忙,那时间更长)
优化 同步完成
- 编写完全没有await的异步方法也是合法的,但编译器会发出警告,例如
async Task<string> Foo(){return "abc";}
- 但这类方法可以用于重载virtual/abstract方法
- 另外一种可达到相同结果的方式是:使用Task.FromResult,它会返回一个已经设置号信号的Task
Task<string> Foo(){return Task.FromResult("abc");}
- 如果从UI线程上调用,那么GetWebPageAsync方法是隐式线程安全的。您可以连续多次调用它(从而启动多个并发下载),并且不需要lock来保护缓存
- 有一个简单的方法可以实现这一点,而不必求助于lock或信号结构。我们创建一个“futures”(Task
)的缓存,而不是字符串的缓存。注意并没有async:
static Dictionary<string,Task<string>> _cache=new Dictionary<string,Task<string>>();
Task<string> GetWebPageAsync(string uri)
{
if(_cache.TryGetValue(uri,out var downloadTask))
{
//多次调用会返回同一个Task
return downloadTask;
}
else
{
return _cache[uri]=new WebClient().DownloadStringTaskAsync(uri);
}
}
不使用同步上下文,使用lock也可以
- lock的不是下载的过程,lock的是检查缓存的过程(很短暂)
lock(_cache)
{
if(_chche.TryGetValue(uri,out var downloadTask))
{
return downloadTask;
}
else
{
return _cache[uri]=new WebClient().DownloadStringTaskAsync(uri);
}
}
ValueTask
- ValueTask
用于微优化场景,您可能永远不需要编写返回此类型的方法 - Task
和Task是引用类型,实例化它们需要基于堆的内存分配和后续的收集3 - 优化的一种极端形式是编写无需分配此类内存的代码;换句话说,这不会实例化任何引用类型,不会给垃圾收集增加负担
- 为了支持这种模式,C#引入了ValueTask和ValueTask
这两个struct,编译器允许它们代替Task和Task
async ValueTask<int> Foo(){...}
- 如果操作同步完成,则await ValueTask
是无分配的
int answer=await Foo();//(可能是)无分配的
- 如果操作不是同步完成的,ValueTask
实际上就会创建一个普通的Task (并将await转发给它) - 使用AsTask方法,可以把ValueTask
转化为Task (也包括非泛型版本)
使用ValueTask时的注意事项
- ValueTask
并不常见,它的出现纯粹是为了性能 - 这意味着它被不恰当的值类型语义所困扰,这可能会导致意外。为避免错误行为,必须避免以下情况:
多次await同一个ValueTask
操作没结束的时候就调用 .GetAwaiter().GetResult() - 如果你需要进行这些操作,那么先调用AsTask方法,操作它返回的Task
- 避免上述陷阱最简单的办法就是直接await方法调用:
await Foo();
- 将ValueTask赋值给变量时,就可能引发错误了:
ValueTask<int> valueTask=Foo();
- 将其立即转化为普通的Task,就可以避免此类错误的发生:
Task<int> task=Foo().AsTask();
避免过度的弹回
- 对于循环中多次调用的方法,通过调用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控件