C# 异步编程基础(九) 异步中的同步上下文、ValueTask<T>

此入门教程是记录下方参考资料视频的过程
开发工具:Visual Studio 2019

参考资料:https://www.bilibili.com/video/BV1Zf4y117fs

目录

C# 异步编程基础(一)线程和阻塞

C# 异步编程基础(二)线程安全、向线程传递数据和异常处理

C# 异步编程基础(三)线程优先级、信号和线程池

C# 异步编程基础(四) 富客户端应用程序的线程 和 同步上下文 Synchronization Contexts

C# 异步编程基础(五)Task

C# 异步编程基础(六)Continuation 继续/延续 、TaskCompletionSource、实现 Task.Delay

C# 异步编程基础(七)异步原理

C# 异步编程基础(八) 异步函数

C# 异步编程基础(九) 异步中的同步上下文、ValueTask

C# 异步编程基础(十) 取消(cancellation)、进度报告、TAP(Task-Based Asynchronous Pattern)、Task组合器

发布异常

  1. 富客户端应用通常依赖于集中的异常处理事件来处理UI线程上未捕获的异常
    例如WPF中的 Application.DispatcherUnhandledException
    ASP.NET Core中定制ExceptionFilterAttribute也是差不多的效果
  2. 其内部原理就是:通过在它们自己的try/catch块来调用UI事件(在ASP.NET Core里就是页面处理方法的管道)
  3. 顶层的异步方法会使事件更加复杂
async void ButtonClick(object sender,RouteEventArgs args)
{
    await Task.Delay(1000);
    throw new Exception("Will this be ignored?");
}
  1. 当点击按钮,event handler运行时,在await后,执行会正常的返回到消息循环;一秒钟之后抛出的异常无法被消息循环中的catch块捕获
  2. 为了缓解该问题,AsyncVoidMethodBuilder会捕获未处理的异常(在返回void的异步方法里),并把它们发布到同步上下文(如果出现的话),以确保全局异常处理事件能够触发

注意

  1. 编译器只会把上述逻辑应用于返回类型为void的异步方法
  2. 如果ButtonClick的返回类型是Task,那么未处理的异常将导致结果Task出错,然后Task无处可去(导致未观察到的异常)

发布异常

  1. 一个有趣的细微差别:无论你在await前面还是后边抛出异常,都没有区别
  2. 因此,下例中,异常会被发布到同步上下文(如果出现同步上下文的话),而不会发布给调用者
async void Foo()
{
    throw null;
    await Task.Delay(1000);
}
  1. 如果同步上下文没有出现,异常将会在线程池上传播,从而终止应用程序
  2. 不直接将异常抛出回调用者的原因是为了确保可预测性和一致性
  3. 在下例中,不管someCondition是什么值,InvalidOperationException将始终得到和导致Task出错同样的效果
async Task Foo()
{
    if(someCondition)
    {
        await Task.Dealy(1000);
    }
    throw new InvalidOperationException;
}
  1. Iterator也是一样的,本例中,异常绝不会直接返回给调用者,直到序列被遍历后,才会抛出异常
IEnumerable<int> Foo()
{
    throw null;
    yield return 123;
}

OperationStarted 和 OperationCompleted

  1. 如果存在同步上下文,返回void的异步函数也会在进入函数时调用其OperationStarted方法,在函数完成时调用其OperationCompleted方法
  2. 如果是为了对返回void的异步方法进行单元测试而编写一个自定义的同步上下文,那么重写这个两个方法确实很有用

优化:同步完成

  1. 异步函数可以在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);
}
  1. 如果URL在缓存中存在,那么不会有await发生,执行就会返回给调用者,方法会返回一个已经设置信号的Task,这就是同步完成
  2. 当await同步完成的Task时,执行不会返回到调用者,也不会通过continuation跳回。它会立即执行到下个语句
  3. 编译器是通过检查awaiter上的IsCompleted属性来实现这个优化的。也就是说,无论何时,当你await的时候:Console.WriteLine(await GenWebPageAsync("http://www.bing.com"));
  4. 如果是同步完成,编译器会释放可短路continuation的代码
var awaiter=GetWebPageAsync().GetAwaiter();
if(awaiter.IsCompleted)
{
    Console.WriteLine(awaiter.GetResult());
}
else
{
    awaiter.OnCompleted(()=>
    {
        Console.WriteLine(awaiter.GetResult());
    })
}

注意

  1. 对一个同步返回的异步方法进行await,仍然会引起一个小的开销(20纳秒左右,2019年的PC)
  2. 反过来,跳回到线程池,会引入上下文切换开销,可能是1-2毫秒
  3. 而跳回到UI的消息循环,至少是10倍开销(如果UI繁忙,那时间更长)

优化 同步完成

  1. 编写完全没有await的异步方法也是合法的,但编译器会发出警告,例如async Task<string> Foo(){return "abc";}
  2. 但这类方法可以用于重载virtual/abstract方法
  3. 另外一种可达到相同结果的方式是:使用Task.FromResult,它会返回一个已经设置号信号的Task
    Task<string> Foo(){return Task.FromResult("abc");}
  4. 如果从UI线程上调用,那么GetWebPageAsync方法是隐式线程安全的。您可以连续多次调用它(从而启动多个并发下载),并且不需要lock来保护缓存
  5. 有一个简单的方法可以实现这一点,而不必求助于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也可以

  1. lock的不是下载的过程,lock的是检查缓存的过程(很短暂)
lock(_cache)
{
    if(_chche.TryGetValue(uri,out var downloadTask))
    {
        return downloadTask;
    }
    else
    {
        return _cache[uri]=new WebClient().DownloadStringTaskAsync(uri);
    }
}

ValueTask

  1. ValueTask用于微优化场景,您可能永远不需要编写返回此类型的方法
  2. Task和Task是引用类型,实例化它们需要基于堆的内存分配和后续的收集3
  3. 优化的一种极端形式是编写无需分配此类内存的代码;换句话说,这不会实例化任何引用类型,不会给垃圾收集增加负担
  4. 为了支持这种模式,C#引入了ValueTask和ValueTask这两个struct,编译器允许它们代替Task和Task
    async ValueTask<int> Foo(){...}
  5. 如果操作同步完成,则await ValueTask是无分配的
    int answer=await Foo();//(可能是)无分配的
  6. 如果操作不是同步完成的,ValueTask实际上就会创建一个普通的Task(并将await转发给它)
  7. 使用AsTask方法,可以把ValueTask转化为Task(也包括非泛型版本)

使用ValueTask时的注意事项

  1. ValueTask并不常见,它的出现纯粹是为了性能
  2. 这意味着它被不恰当的值类型语义所困扰,这可能会导致意外。为避免错误行为,必须避免以下情况:
    多次await同一个ValueTask
    操作没结束的时候就调用 .GetAwaiter().GetResult()
  3. 如果你需要进行这些操作,那么先调用AsTask方法,操作它返回的Task
  4. 避免上述陷阱最简单的办法就是直接await方法调用:await Foo();
  5. 将ValueTask赋值给变量时,就可能引发错误了:ValueTask<int> valueTask=Foo();
  6. 将其立即转化为普通的Task,就可以避免此类错误的发生:Task<int> task=Foo().AsTask();

避免过度的弹回

  1. 对于循环中多次调用的方法,通过调用ConfigureAwait方法,就可以避免重复的弹回到UI消息循环所带来的开销
  2. 这强迫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控件

异步中的同步上下文、ValueTask 结束

posted @ 2021-02-10 14:56  .NET好耶  阅读(1413)  评论(0编辑  收藏  举报