C# Async/Await 最佳实践

分享
C# Async/Await 最佳实践

异步不是魔法,也不需要另一个线程等待

很多不理解异步的人总觉得异步是一种魔法操作,认为使用了异步能够增加程序的处理速度。这是一个常见的误解,异步不是魔法,无法加速你程序的运行速度,如果你的方法以前需要一秒钟返回结果使用异步后一样需要一秒钟返回结果,这些人会觉得奇怪,说好的异步会使程序性能提升呢?另一种常见的误解则是,异步也需要线程来执行,只不过使用了线程池线程,会有一个后台线程继续等待异步结果,但事实总是出乎意料:没有额外线程在等待。

让我们一步步解剖异步的线程无需等待到底是什么,假设我们现在需要对文件进行读取:

async Task MyMethodAsync() {
    //异步读取文件
    var text = await System.IO.File.ReadAllTextAsync(@"file.text");
    //输出文件内容到控制台
    System.Console.WriteLine(text);
}

当线程执行到 await System.IO.File.ReadAllTextAsync(@"file.text")时,线程会调用操作系统一个标准的异步I/O接口,此时线程返回线程池,并未在此等待。疑惑的地方在此,读文件的操作是谁来完成的?操作系统会调用硬件驱动的异步 I/O方法,如果是个机械硬盘,此时机械硬盘的磁头移动到正确的磁道对数据进行读取,这里是 CPU所不需要参与的,操作系统没有任何线程在等待。而当磁盘数据读取后,通过中断通知 CPUCPU响应中断,操作系统通知应用程序 I/O完成,程序从线程池取出一个线程继续执行 await后的代码既 System.Console.WriteLine(text),过程中是没有任何线程在等待 I/O操作的。

认为异步能够使程序获得额外处理速度的人对异步的理解就好像是给程序加了个 Buff,这个 Buff能够使得 CPU主频瞬间提高以提高程序的速度。这其实是对概念有误解,异步使得线程无需等待,并不代表能提高运行速度,而是能提高程序对线程的利用率。这里有两个概念:

  • I/O Bound(操作受到 I/O限制,I/O速度越快则操作速度越快)
  • CPU Bound (操作受到 CPU限制,CPU速度越快则操作速度越快)

对于 I/O Bound的操作,使用异步编程的方式编写非阻塞代码。对于 CPU Bound的操作,使用 Task.Run来运行多个任务。

异步到底

异步到底的意思是:从底层代码一直到程序调用的入口点都为异步,没有同步与异步代码的混合,没有阻塞的调用异步方法。
如果程序入口点的方法是非异步的,则底层的异步都会失效,对于 Web项目,需要从控制器开始一路异步,对于控制台程序,则从 Main方法开始就得异步,否则即使调用异步方法,也会以阻塞的方式执行:

static void Main (string[] args) {
    //这里的异步方法将会以阻塞方式执行
    MyMethodAsync();
}

static async Task MyMethodAsync() {
    //异步读取文件
    var text = await System.IO.File.ReadAllTextAsync(@"file.text");
    //输出文件内容到控制台
    System.Console.WriteLine(text);
}

异步/同步混搭风

一个复杂的大型项目往往没法一次将所有方法都改为异步,一些人则会混合使用异步和同步代码,例如一个同步方法调用异步方法,通过 Task.Wait()或者 Rask.Result来使异步代码变为同步代码执行,这对于那些只将应用程序的一小部分转换为异步代码并将其包装在同步方法中的程序员来说,这是个特别常见的做法,使得应用程序的其余部分不受异步的更改所影响。不幸的是,阻塞异步代码可能会造成死锁,考虑下面 ASP.NET WebAPI(Framework)代码:

public class ValuesController : ApiController
{
    public string Get()
    {
        //使用Task.Result阻塞异步代码,同步返回结果
        return GetStringAsync().Result;
    }

    private async Task<string> GetStringAsync()
    {
        //等待1秒
        await Task.Delay(1000);
        return "Successful";
    }
}

一个编写异步代码的新手很可能写出上面的代码,并且在进行接口测试时会惊讶的发现死锁问题。其根本原因在于,当使用 await等待时,将捕获当前线程上下文,用来在任务完成后恢复该方法(继续执行 await后的代码),此上下文默认是 SynchronizationContext,如果没有 SynchronizationContext则使用 TaskScheduler分配线程,而 ASP.NET Framework具有 SynchronizationContext,当等待完成时,尝试使用 SynchronizationContext继续执行异步方法的剩余部分,然而 SynchronizationContext正在(同步)等待异步方法返回,异步方法的剩余部分也在等待 SynchronizationContext执行,这就导致死锁的发生。

此问题常在 GUIASP.NET应用发生(仅为 Framework程序,NET Core程序不具有 SynchronizationContext),因为它们都具有 SynchronizationContext,而 Console程序则具有 Thread Pool SynchronizationContext,因此当等待完成时,将调度线程池的线程执行异步方法的剩余部分,所以当你进行单元测试时,你能够通过测试,但是部署到 ASP.NETGUI程序中,则会发生死锁的情况,这种行为上的差异会让人觉得困惑。

解决此问题的最佳方案自然是上文所说的 异步到底,在控制器方法中也使用异步等待,则当异步任务返回时,SynchronizationContext并未在等待异步任务,可以用来执行异步方法的剩余部分,自然不会发生死锁。但是这意味着要对应用程序进行大量的改进工作使得整个应用程序完全成为异步,而一次更改整个应用程序工作太过庞大且容易变得不可控,这种时候我们可能需要使用 Task.ResultTask.Wait()进行部分转换。那么如果避免同步、异步代码混合的死锁问题?

ConfigureAwait

为了避免在同步方法中阻塞异步代码时发生死锁,可以使用 ConfigureAwait方法配置还原异步方法的剩余部分时,不是非要使用原始上下文线程进行还原:

public string Get()
{
    //使用Task.Result阻塞异步代码,同步返回结果
    return GetStringAsync().Result;
}

private async Task<string> GetStringAsync()
{
    //配置ConfigureAwait(false),当等待完成时不必须使用原始上下文执行此方法的剩余部分
    await Task.Delay(1000).ConfigureAwait(false);
    return "Successful";
}

这能够避免死锁问题,且当我们在编写一个公共的 Standard或特定平台的类库时,也应当在 await的地方使用 ConfigureAwait来避免第三方进行同步调用时的死锁问题。

但并不是所有情况下都一定要使用 ConfigureAwait,如果 await后的代码依赖于原始上下文的数据,则应当避免使用 ConfigureAwait,因为每个异步方法的上下文都是独立的,例如在 ASP.NET中如果在使用 ConfigureAwait后的代码调用 HttpContext.Current则获取不到数据,因为这是存在于原始线程的数据:

public string Get()
{
    //使用Task.Result阻塞异步代码,同步返回结果
    return GetStringAsync().Result;
}

private async Task<string> GetStringAsync()
{
    //配置ConfigureAwait(false),当等待完成时不必须使用原始上下文执行此方法的剩余部分
    await Task.Delay(1000).ConfigureAwait(false);
    //所得到的httpContext为null
    var httpContext = HttpContext.Current;
    return "Successful";
}

幸运的是,大部分情况下异步代码都不需要原始上下文中的数据,如果在某些情况下需要使原始线程上下文的数据流向配置了 ConfigureAwait后的代码,可以通过 AsyncLocal等进行线程间的数据同步,具体操作可以查看我的另一篇文章ThreadStaticAttribute vs ThreadLocal vs AsyncLocal

结束语

异步是一个很棒的功能,请记住没有线程在等待,也没有任何魔法。

Reference

阅读更多

以太坊黑暗森林-抢跑(front running)

以太坊黑暗森林-抢跑(front running)

前言 鸽了很久之后的今天突然心血来潮,准备写一个系列:以太坊黑暗森林,它介绍以太坊生态上的各种奇思妙想和逆天的攻击方式,会从简单的、常见的攻击方式开始介绍。取这个名字是因为我接触以太坊不久后看的一篇文章 Ethereum is a Dark Forest ,让我想起了《三体》小说中刘慈欣描述的黑暗森林,以太坊是一个弱肉强食的、没有规则的世界,猎人们总是躲在背后监听所有的交易,一旦发现猎物,它们会把它的血给吸干。 开盘抢币 相信进入以太坊生态的韭菜们,一定有过在 uniswap 上买刚开盘新币的经历,新开盘的币,一般会上涨几倍甚至十几倍,越早买入则越能低价买入。你守着时间,等着项目方添加流动性后第一时间买入代币,但是你发现,无论你的手速多块,总是看到一开盘,价格已经飚了几倍,你骂骂咧咧,开始不断拉高 gas 费用,尝试继续买入,但是你眼睁睁的看着代币涨到十倍,自己的交易却一直失败,你开始怀疑项目方自己抢跑,怀疑项目方捣鬼:肯定是项目方吃相难看,用老鼠仓提前买了。另一些聪明人,研究了以太坊的基本技术,他们在 ethscan

By FatTiger
C#:IDisposable 和 析构函数

C#:IDisposable 和 析构函数

C# 中有两种释放资源的方式:实现 IDisposable 或使用析构函数。通常,必须在特定时间释放资源的场景中,我们实现 IDisposable,像这样: public class ExampleDispose : IDisposable { // 非托管资源 private IntPtr _handle; // 使用的其它托管资源 private readonly Stream _stream; private bool disposed = false; public ExampleDispose(Stream stream, IntPtr handle) { this._stream = stream; this._handle = handle; } public void Dispose() { if (disposed) { return; } disposed = t

By FatTiger
ThreadLocal引发的灾难

ThreadLocal引发的灾难

在 Java 里有个称之为线程本地变量的类型叫做 ThreadLocal,它与 ThreadLocal 之于 C# 中是一样的作用,可以在线程范围内设置变量,这个变量只会在当前线程可被访问,但是它们有一点不同的是,在 Java 中,当你设置好变量后,在线程使用完毕回到线程池之前,需要手动调用 ThreadLocal.remove() 方法去清除线程本地变量,否则变量随着线程回到线程池,并且在下次使用此线程时此变量继续存在,而在 C# 中,线程回到线程池时会自动清除本地变量,因此无需手动去清除。 我们的业务有这样一个场景:某个业务 UserService 类中,具有多个方法会频繁(甚至循环)调用一个获取用户标签的接口,具体原因是因为某些方法会进行递归,数据结构有个树状结构,因此,为了优化接口响应时间以及看起来不那么蠢,我使用 ThreadLocal 将用户标签接口的返回数据存储到当前线程,因为在单个请求中,多次调用此接口获取数据是不必要的,它看起来像这样: /** * 此静态变量ThreadLocal会为每个线程创建本地副本, 因此USER_TAGS_THREAD_

By FatTiger
我在币安智能链的日子-区块链基础

我在币安智能链的日子-区块链基础

区块和链 无论是比特币还是以太坊,都是具有一个个区块(称之为Block)的链式结构,学过<数据结构>的肯定明白链表,区块链就像一个链表,每个区块都存储上一个区块哈希。 链(称之为Chain),有非常多的链,他们的协议不同,技术也不尽相同,比特币网络是一个链,以太坊网络是另一个链,每个链都有自己的目标(甚至目标只是为了圈钱),每个链也都有自己的代币,比特币网络的代币是比特币,每次交易都需要比特币作为手续费,以太坊网络代币是以太币,每次在以太坊网络的交易都需要以太币作为手续费。所以,链实际上作为基础设施,非常多的团队喜欢创建新的链,但是一个链光有网络光有代币不行,没有生态,很难成功。 币安智能链(Binance Smart Chain:BSC) 我的主要操作都是在BSC上,没有其它原因,只因为一个穷字。在BTC网络交易,需要BTC用作手续费,这个我可用不起,在以太坊(Ethereum)网络交易,需要以太币(ETH)作为用作手续费,按照以太币目前(

By FatTiger