第5章 编写异步代码

第5章 编写异步代码

5.1 异步函数简介

C# 5 引入了异步函数的概念。异步函数可以指某个由 async 修饰符修饰的方法或者匿名函数,它可以对 await 表达式使用 await 运算符。

5.2 对异步模式的思考

5.2.1 关于异步执行本质的思考

await 在 C#中的任务本质上是请求编译器为我们创建 续延 。尽管构想简单,却能显著增强代码可读性,让开发人员更从容。

在真实的异步模型中,续延并没有传递给异步操作,而是由异步操作发起并返回了一个令牌(即 Task​ 或 Task<TResult>​),该令牌可供续延使用。

C#5 的异步方法典型的执行流程如下:

  1. 执行某些操作;
  2. 启动一个异步操作,并记录其返回的令牌;
  3. 执行某些其他操作(通常在异步操作完成前不能进行后续操作,对应这一步应该为空);
  4. (利用令牌)等待异步操作完成;
  5. 执行其他操作;
  6. 完成执行。

Summary

等待异步操作其实是在表达:现在代码不能往下执行了,等待操作完成后再继续执行。那么如何才能不阻塞线程呢?答案很简单,那就是立即返回,之后继续异步地执行自身。如果想让调用方知道异步方法何时完成,则需要传递一个令牌给调用方,这样调用方就可以选择阻塞于该令牌上,或者(更有可能)将该令牌用于另一个续延。通常最终都会得到一批相互调用的异步方法,感觉就像进入了某段代码的一种“异步模式”。语言规范中并没有要求如此实现,但事实上对于调用异步操作的代码,其行为也和异步操作相一致,于是无形中就形成这样一条调用链。

5.2.2 同步上下文

异步函数使用 SynchronizationContext​ 类确保代码能够返回正确的线程中 , SynchronizationContext​ 类诞生于 .NET 2.0,用于像 BackgroundWorker​ 这样的组件中。SynchronizationContext​ 类负责在正确的线程中执行委托。该类中的 Post(异步)和 Send(同步)消息机制类似于 Windows Forms 的 Control.BeginInvoke() ​ 方法和 Control.Invoke() ​ 方法。

5.2.3 异步方法模型

对于异步编程,只有两点新语法:async 修饰符和 await 运算符。 async 用于修饰异步方法的声明, await 用于消费异步操作。

接下来分 3 节探讨异步方法,这 3 个阶段逐层递进。

  1. 声明 async 方法。
  2. 使用 await 运算符等待异步操作执行完成。
  3. 方法执行完成后返回值。

5.3 async 方法声明

除了新增了 async 关键字,async 方法声明的语法与其他方法声明没有区别。async 关键字可以放在 返回类型前的任意 位置。例如以下声明 均合 法:

public static async Task<int> FooAsync() { ... }
public async static Task<int> FooAsync() { ... }
async public Task<int> FooAsync() { ... }
public async virtual Task<int> FooAsync() { ... }

实际上,在异步设计之初,async 关键字不是必须的。为了代码的可维护性,设计团队引入了该关键字。而在生成的 IL 代码中,async 修饰符是被省略的。我们可以把现有普通方法(方法签名合适)改成 async 方法,或者反向操作。这种改动是源码和二进制 兼容 的。

async 属于方法的实现细节,因此 不能 声明抽象方法或者接口中的方法为 async。不过这些方法的返回值完全可以 是 Task<TResult>​ 类型的,它们的具体实现可以使用 async/await,也可以只是普通方法。

5.3.1 async 方法的返回类型

async 方法和调用它的方法之间通过返回值进行交互。在 C#5 中,异步函数的返回值仅限于以下 3 个类型:

  • void
  • Task ​:表示没有返回值的操作
  • Task<TResult> ​:表示返回值为 TResult​ 类型的操作

Info

C#7 新增了 ValueTask​,将在5.8.1 99.9% 的情况:ValueTask进行讲解

async 方法之所以可以返回 void 类型,是为了与 事件处理器 兼容。例如以下 UI 按钮点击的事件处理器:

private async void LoadStockPrice(object sender, EventArgs e)
{
    string ticker = tickerInput.Text;
    decimal price = await stockPriceService.FetchPriceAsync(ticker);
    priceDisplay.Text = price.ToString("c");
}

Warn

返回 void 类型的异步方法最好只用于事件订阅中。

5.3.2 async 方法的参数

async 方法的参数不能由 out 或者 ref 修饰。这是因为 outref 参数是用于与调用方交换信息的,有时 async 方法在控制流返回到调用方时,操作可能还未开始执行,因此引用参数可能尚未赋值。

此外, 指针 类型也不能用作 async 方法的参数。

5.4 await 表达式

await 所搭配的表达式有一个条件限制:它必须是可等待的。

5.4.1 可等待模式

“可等待模式”是基于模式实现的(而非类似于 using 语句+IDisposable​ 接口的形式)。假设有一个返回类型为 T​ 的表达式需要使用 await,编译器会执行以下检查步骤:

  1. T​ 必须具备一个无参数的 GetAwaiter() ​ 实例方法,或者存在 T​ 的扩展方法,该方法以类型 T​ 作为唯一参数。 GetAwaiter() ​ 方法的返回类型被称为 awaiter​ 类型。

  2. awaiter​ 类型必须实现 System.Runtime.INotifyCompletion ​ 接口,该接口中只有一个方法: void OnCompleted(Action) ​。

  3. awaiter​ 类型必须具有一个可读的实例属性 IsCompleted ​,其类型为 bool​。

  4. awaiter​ 类型必须具有一个非泛型、无参数的实例方法 GetResult() ​。

    该方法若返回 void ​,await 表达式会被视为无结果表达式;否则 await 表达式的返回类型与该方法保持一致。

  5. 上述成员不必为 public,但是这些成员需要能被调用 await 的 async 方法访问到。

    因此存在这样的可能性:对于某个类型,在某些代码中可以使用 await,但在其他代码中不可行,不过这种情况十分罕见。

如果 T​ 满足所有上述条件,就可以使用 await 运算符了。

Info

扩展方法的历史重要性

GetAwaiter()​ 之所以也可以是扩展方法,主要是由历史原因而不是现实原因决定的。C#5 是与 .NET 4.5 同期发布的,正是在这一版本中,C# 将 GetAwaiter()​ 方法引入了 Task​ 和 Task<TResult>​ 中。如果 GetAwaiter()​ 必须是根红苗正的实例方法,开发人员就不得不继续使用 .NET 4.0;而一旦支持扩展方法,就可以通过提供了这些扩展方法的 NuGet 包来实现 Task 和 Task<TResult>​ 的异步化。这样也能让社区不用测试 .NET 4.5 预览版,便能测试 C#5 编译器。

如今 framework 中的代码,早已具备相应的 GetAwaiter()​ 方法,因此以后几乎不再需要通过扩展方法来为某个类型添加可等待属性了。

5.4.2 await 表达式的限制条件

await 表达式的使用有如下限制:

  • 只能用于 async 方法或异步匿名函数中(见5.7 异步匿名函数);

  • await 运算符不能用于 非安全(unsafe) 上下文中;

    如下代码演示了 await 和 unsafe 使用时的限制:

    static async Task DelayWithResultOfUnsafeCode(string text)
    {
        int total = 0;
        unsafe                                    // async 方法中可以有非安全的上下文
        {
            fixed (char* textPointer = text)
            {
                char* p = textPointer;
                while (*p != 0)
                {
                    total += *p;
                    p++;
                }
            }
        }
        Console.WriteLine("Delaying for " + total + "ms");
        await Task.Delay(total);                // 但是 await 表达式不能位于非安全的上下文中
        Console.WriteLine("Delay complete");
    }
    
  • await 无法在锁中使用;

    如果确实需要锁,应使用 SemaphoreSlim​ 的 WaitAsync()​ 方法代替。

Info

还有一些上下文无法使用 await,在 C#6 解禁了:

  • 所有带有 catch​ 块的 try​ 块。
  • 所有 catch​ 块。
  • 所有 finally​ 块。

5.6 返回值的封装

5.6.2 await 表达式的运算

执行 await 表达式时,有两种可能:

  • 异步操作已经完成

    • 操作失败并捕获了表示失败的异常:会 抛出异常
    • 执行成功:获取 操作结果

    上述操作不涉及上下文切换和添加延续

  • 异步操作仍在进行

    此时 await 会异步等待操作完成(此时方法已经停止执行),之后在某个合适的上下文中继续执行其余代码(给异步操作附加一个延续)。

关于执行顺序,有如下几个重点。

  • async 方法在 await 已完成的 task 时不返回,此时方法还是按照 同步 方式执行。
  • 当 async 方法 await 延迟 task 时,async 方法会 立即返回
  • async 方法返回的 task 只有在 方法完成 后才完成。

image

按照上述规则,思考如下代码的输出:

static void Main()
{
    Task task = DemoCompletedAsync();
    Console.WriteLine("Method returned");
    task.Wait();
    Console.WriteLine("Task completed");
}
static async Task DemoCompletedAsync()
{
    Console.WriteLine("Before first await");
    await Task.FromResult(10);
    Console.WriteLine("Between awaits");
    await Task.Delay(1000);
    Console.WriteLine("After second await");
}

5.6.2.1 从异步方法中返回的含义

从异步方法返回意味着两种可能:

  • 目前是执行中遇到的第一个 await 表达式,最初的调用方 在调用栈中。

    最后得到的一般是返回给调用方的 Task​ 或者 Task<TResult>​,即将返回的 task 必须是 未完成的

  • 已处于等待某个操作完成期间,因此正处于某个被调起的 续延 之中。此时的调用栈与方法刚开始执行时的调用栈 大不相同

    回调方是谁取决于 当前上下文 。在 Windows Forms UI 中,如果在 UI 线程中调用某个 async 方法,且没有主动切换出 UI 线程,整个方法将在 UI 线程中执行

Notice

在真正执行到第一个异步 await 表达式之前,方法是完全同步执行的。

5.6.3 可等待模式成员的使用

5.4.1 可等待模式将可等待模式描述为一种 需要实现的类型 ,可以对该类型的表达式使用 await 运算符。下面把异步行为模式的几块拼图合到一起,用可等待模式替代原先比较宽泛的描述:

image

5.6.4 异常拆封

async/await 的基础架构会尽量让处理异步失败接近于处理同步失败。如果把失败看作一种特殊形式的结果,异常和 返回值 的处理就很相似了。

GetResult()​ 方法不仅负责返回结果,也负责生成异步操作抛出的异常并将其返回给调用方。下面以 Task​ 和 Task<TResult>​ 为例展开分析:

  • 当某个操作失败时,任务的 Status​ 变成 Faulted ​(并且 IsFaulted ​ 的值为 true)。
  • Exception​ 属性返回一个 AggregateException ​,它包含导致任务失败的所有(可能多个)异常。如果任务没有 faulted,则该属性值为 null )。
  • 如果任务最后的状态为 Faulted​,Wait()​ 方法会抛出一个 AggregateException ​。
  • Task<TResult>​(也在等待完成)的 Result​ 属性可能抛出一个 AggregateException ​。

Tips

如果通过 CancellationTokenSource​ 和 CancellationToken​ 取消任务,Wati()​ 方法和 Result​ 属性会抛出 AggregateException ​ 异常,内含 OperationCanceledException​,task 的状态为 canceled ​。

在 await 某个 task 时,如果其状态变为 Faulted​ 或者 Canceled​,那么抛出的异常将不是 AggregateException​,而是 AggregateException内部的第一个 异常。

对于 Task.WhenAll()​ 这类方法,想获取全部异常可以通过遍历每个 Task​ 的 Exception ​ 属性。

5.6.5 完成方法

5.6.5.1 成功返回

执行成功有:

  • Task<TResult>​ 类型:return 语句需要返回一个类型为 T​(或可以转换为 TResult​ 的类型)的结果,然后由异步基础架构为 task 生成结果;
  • Task​ 或 void​:与同步 void 方法类似,无 return 语句或 return 无返回值,但要根据实际情况更新 task 的状态。

5.6.5.2 延迟异常和实参校验

我们前文有提到:

实际上,在异步设计之初,async 关键字不是必须的。为了代码的可维护性,设计团队引入了该关键字。而在生成的 IL 代码中,async 修饰符是被省略的。我们可以把现有普通方法(方法签名合适)改成 async 方法,或者反向操作。这种改动是源码和二进制 兼容 的。

实际上,二者在面对异步方面仍有差别。即便 async 方法执行的第一步就是抛出异常,那它也只是返回一个 faulted task 。(此时该 task 立即变为 faulted )。这导致只有调用方等待该任务时,才能获取异常信息:

static async Task MainAsync()
{
    Task<int> task = ComputeLengthAsync(null);  // 故意传入错误的实参
    Console.WriteLine("Fetched the task");
    int length = await task;
    Console.WriteLine("Length: {0}", length);   // await 结果
}

static async Task<int> ComputeLengthAsync(string text)  // 尽早抛出异常
{
    if (text == null)
    {
        throw new ArgumentNullException("text");
    }
    await Task.Delay(500);      // 模拟真实的异步操作
    return text.Length;
}

这导致异常的抛出到暴漏存在时间差,多数时候这种时间差可以容忍。消除这种时间差可以编写一个返回 task非 async 方法,专门负责 参数校验 ,校验完成后调用另一个异步函数。实现的形式有 3 种:

  • 参数校验与具体实现剥离为各自的方法;

  • 将具体实现改为异步匿名函数;

  • 将具体实现改为局部 async 方法。

    作者倾向于这种方式,它有如下好处:

    1. 不会向类中额外引入新方法;
    2. 避免创建委托的麻烦

如下代码演示了第一种形式:

static Task<int> ComputeLengthAsync(string text)    // 非 async 方法。异常
{                                                   // 不会被封装至 task 中
    if (text == null)
    {
        throw new ArgumentNullException("text");
    }
    return ComputeLengthAsyncImpl(text);    // 校验完成后调用实现方法
}

static async Task<int> ComputeLengthAsyncImpl(string text)
{
    await Task.Delay(500);      // async 方法的实现中
    return text.Length;         // 假设无需校验输入值
};

使用 null 作为实参调用 ComputeLengthAsync()​ 方法,异常会以 同步 方式抛出,而不是返回一个 faulted task。

5.6.5.3 处理取消

任务并行库为.NET 4 引入了一个统一的取消模型,主要依靠以下两个类型:

  • CancellationTokenSource
  • CancellationToken

我们一般调用 CancellationToken​ 的 ThrowIfCancellationRequested() ​ 方法进行取消操作,该方法利用“异步方法抛出 OperationCanceledException ​ 异常(或其子类,如 TaskCanceledException​),返回的 task 状态就是 Canceled ​”这一特点取消任务。

虽然 task 的状态是 Canceled​,await 时异常仍会暴漏。如果使用 Wait()​ 方法或通过 Result​ 属性请求结果,异常还是会在 AggregateException ​ 内部被抛出。

5.7 异步匿名函数

异步匿名函数的创建与匿名方法、lambda 表达式相同,在前面加上 async 即可。例如:

Func<Task> lambda = async () => await Task.Delay(1000);
Func<Task<int>> anonMethod = async delegate()
{
    Console.WriteLine("Started");
    await Task.Delay(1000);
    Console.WriteLine("Finished");
    return 10;
};

与普通匿名函数类似,异步匿名函数也可以捕获变量、添加参数等。

异步匿名函数的使用场景较少,它主要用于与 LINQ 搭配。

5.8 C#7 自定义 task 类型

在 C#5 和 C#6 中,异步函数只能返回 void​、Task​ 或 Task<TResult>​。C#7 对于这一限制有所放宽:某些通过特定方式修饰的类型也可以用作异步函数的返回类型。

5.8.1 99.9% 的情况:ValueTask<TResult>

ValueTask<TResult>​ 的诞生是出于对性能的极致优化,相比 Task<TResult>​ 优势体现在 内存分配和 垃圾回收 上。

有时 task 在 await 之前便完成,最常见的场景便是从流中读取数据,而流刚好缓存了数据。此时若使用 Task<TResult>​ 作为返回值,会给 垃圾回收器 带来负担。使用 ValueTask<TResult>​,只有在需要把流数据重新填充到缓存时才需要堆内存分配。

如下代码演示了 ValueTask<TResult>​ 的使用:

public sealed class ByteStream : IDisposable
{
    private readonly Stream stream;
    private readonly byte[] buffer;
    private int position;           // 待返回的缓冲区下一个索引
    private int bufferedBytes;      // 缓冲区读取的字节数

    public ByteStream(Stream stream)
    {
        this.stream = stream;
        buffer = new byte[1024 * 8];    // 8KB 缓冲区大小,意味着
    }                                   // 几乎不需要 await 操作

    public async ValueTask<byte?> ReadByteAsync()
    {
        if (position == bufferedBytes)  // 根据需要重新填充缓冲区
        {
            position = 0;
            bufferedBytes = await                           // 从流中
                stream.ReadAsync(buffer, 0, buffer.Length)  // 异步读取
                    .ConfigureAwait(false);         // 配置 await 操作
            if (bufferedBytes == 0)                 // 忽略上下文
            {
                return null;        // 指示已经读取到流的末尾
            }
        }
        return buffer[position++];      // 返回缓冲区中的下一个字节
    }

    public void Dispose()
    {
        stream.Dispose();
    }
}

5.8.2 剩下 0.1% 的情况:创建自定义 task 类型

自定义 task 类型必须实现可等待模式。以下方代码为例,实现的内容包括:

  • CustomTask

    核心作用:自定义的异步任务 容器 ,类似原生 Task<T>​,但使用自定义构建逻辑。

    关键方法

    • GetAwaiter()​:返回 CustomTaskAwaiter<T>​,使该类型支持 await ​ 关键字。

    特性标记[AsyncMethodBuilder]​ 指定编译器使用 CustomTaskBuilder<T> ​ 构建异步状态机。

  • CustomTaskAwaiter

    核心作用:实现 INotifyCompletion​ 接口,提供 await ​ 关键字的底层等待逻辑。

    关键成员

    • IsCompleted​:判断异步操作是否已完成(无需挂起)。
    • GetResult()​:获取异步结果(可能阻塞或抛出异常)。
    • OnCompleted(Action)​:注册延续回调,用于异步完成时恢复执行。
  • CustomTaskBuilder

    核心作用:编译器生成的异步状态机的控制器,管理状态流转和任务生命周期。

    关键方法

    • Create()​:工厂方法,创建构建器实例。
    • Start()​:启动状态机,触发首次 MoveNext()​ 调用。
    • SetResult(T)​/SetException()​:标记任务成功完成或失败。
    • AwaitOnCompleted()​/AwaitUnsafeOnCompleted()​:处理 await​ 挂起逻辑,绑定 Awaiter 与状态机。
    • Task​ 属性:暴露关联的 CustomTask<T>​ 实例。

    状态机交互:通过IAsyncStateMachine​接口与编译器生成的状态机协作。

[AsyncMethodBuilder(typeof(CustomTaskBuilder<>))]
public class CustomTask<T>
{
    public CustomTaskAwaiter<T> GetAwaiter();
}

public class CustomTaskAwaiter<T> : INotifyCompletion
{
    public bool IsCompleted { get; }
    public T GetResult();
    public void OnCompleted(Action continuation);
}

public class CustomTaskBuilder<T>
{
    public static CustomTaskBuilder<T> Create();

    public void Start<TStateMachine>(ref TStateMachine stateMachine)
        where TStateMachine : IAsyncStateMachine;

    public void SetStateMachine(IAsyncStateMachine stateMachine);
    public void SetException(Exception exception);
    public void SetResult(T result);

    public void AwaitOnCompleted<TAwaiter, TStateMachine>
        (ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine;

    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>
        (ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine;

    public CustomTask<T> Task { get; }
}

其中,泛型、非泛型的主要差别在于 CustomTaskAwaiter.GetResult()​ 方法的返回值和 CustomTaskBuilder.SetResult()​ 的入参。

5.9 C#7.1 中的异步 Main()​ 方法

C# 语言中程序入口方法一直以来都有如下要求:

  • 方法名必须是 Main() ​;
  • 必须为 态;
  • 返回类型必须是 void ​ 或者 int ​;
  • 必须是无参方法或者只能有一个 string[] ​ 类型的参数(不能是 ref 和 out 参数);
  • 必须是非泛型并且在一个非泛型类中声明(如果是嵌套类,那么涉及的类也必须都是非泛型);
  • 不能是没有实现的局部类;
  • 不能有 async 修饰符。

从 C# 7.1 开始,废止了最后一条要求,同时略微修改了对返回类型的要求:

  • 可以有 async 修饰符;

  • async 入口方法返回类型必须是 Task ​ 或 Task<int> ​。

    不能是 void​,也不能使用自定义 task 类型

编译器在处理该 async 入口方法时,会创建一个同步的封装方法,该封装方法作为程序集真正的入口方法。封装方法依然满足前面所说的几个要求:无参数或者只有一个 string[] ​ 参数,返回值类型是 void ​ 或者 int ​(取决于 async 方法的参数和返回值类型)。封装方法会调用这段代码,然后对返回的 task 调用 GetAwaiter() ​ 方法,并且 在 awaiter 上调用 GetResult() 方法。封装方法的代码如下:

static void < Main >()    // 方法的名称在 C#中是非法的,但在 IL 中是合法的
{
    Main().GetAwaiter().GetResult();
}

5.10 使用建议

5.10.1 使用 ConfigureAwait()​ 避免上下文捕获(择机使用)

ConfigureAwait()​ 方法用于避免异步执行结束后回到 UI 线程,这对非 UI 类库十分有用。

调用 ConfigureAwait(false)​ 的结果是不会把续延安排到最初的同步上下文中执行,而是为它安排一个线程池的线程。该方法的返回值类型是 ConfiguredTaskAwaitable<int>​。

读者可能会担心这样的配置会对调用方有什么影响,不必多虑。即使异步方法内部的续延需要在线程池线程中运行,UI 代码中的 await 表达式也能够捕获 UI 的上下文,然后安排自己的续延在 UI 线程中执行,这样在 task 完成之后 UI 仍然可以正常更新。

Notice

注意,只有 await 的 task 还未完成时,两段代码的行为才有区别。如果 task 已经完成,那么方法会以 同步 方式继续执行,无论是否使用了 ConfigureAwait(false)​。

因此,库中每个 await task 都应当 以此进行配置 ,不能指望只对 async 方法的第一个task 使用 ConfigureAwait(false)​ 之后,其余代码就都能在线程池线程中执行了。

Suggest

NuGet Gallery | ConfigureAwaitChecker.Analyzer 5.0.0.1 Roslyn 分析器可以协助开发者检查是否进行了 ConfigureAwait()​ 配置。开发者编写类库时可以通过它协助检查是否所有的 await task 都进行了配置。

5.10.2 启动多个独立 task 以实现并行

试比较如下两段代码:

Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
decimal hourlyRate = await hourlyRateTask;
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
int hoursWorked = await hoursWorkedTask;
AddPayment(hourlyRate * hoursWorked);
Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
decimal hourlyRate = await hourlyRateTask;
int hoursWorked = await hoursWorkedTask;
AddPayment(hourlyRate * hoursWorked);

显然,第 段代码发挥了并行执行的优势。多数时候,应当尽可能 行执行独立的 task。需要注意的是,这种模式下想要记录所有 task 的失败结果,应使用 Task.WhenAll() ​ 方法等待。

5.10.3 避免同步代码和异步代码混用

在两种模式之间切换困难重重,并且困难程度随情况而异。

特别需要注意:通过 Task<TResult>.Result​ 属性和 Task.Wait()​ 方法以同步方式从异步操作获取结果容易导致 死锁

5.10.4 根据需要提供取消机制

5.10.5 测试异步模式

posted @ 2025-03-31 22:53  hihaojie  阅读(113)  评论(0)    收藏  举报