C#中的异步编程是一个强大且复杂的特性,它允许开发者编写非阻塞的代码,从而显著提升应用程序的响应性和吞吐量。本文将深入剖析异步编程的底层原理,从asyncawait关键字的工作机制,到状态机、任务调度、线程管理和异常处理等核心概念。


1. 异步编程的基础

1.1 什么是异步编程?

异步编程是一种编程范式,旨在解决传统同步编程中因等待操作(如I/O或计算)而导致的线程阻塞问题。在同步模型中,调用一个耗时操作会使当前线程暂停,直到操作完成。而在异步模型中,程序可以在等待操作完成的同时继续执行其他任务,从而提高资源利用率和程序的响应性。

例如,在处理网络请求时,同步调用会阻塞线程直到响应返回,而异步调用则允许线程去做其他工作,待响应到达时再处理结果。这种特性在I/O密集型场景(如文件读写、网络通信)和高并发场景(如Web服务器)中尤为重要。

1.2 C#中的asyncawait

C#通过asyncawait关键字简化了异步编程的编写:

  • **async**:标记一个方法为异步方法,表示它可能包含异步操作。通常与TaskTask<T>返回类型一起使用。
  • **await**:暂停异步方法的执行,等待某个异步操作(通常是Task)完成,同时释放当前线程。

以下是一个简单的异步方法示例:

public async Task<int> GetNumberAsync()
{
    await Task.Delay(1000); // 模拟1秒延迟
    return 42;
}

调用此方法时,await Task.Delay(1000)会暂停方法执行,但不会阻塞线程。线程会被释放,待延迟完成后,方法继续执行并返回结果。


2. 编译器的魔力:状态机

2.1 异步方法的转换

尽管asyncawait让异步代码看起来像同步代码,但这背后是C#编译器的复杂工作。当您编写一个async方法时,编译器会将其转换为一个状态机(State Machine),负责管理异步操作的执行流程。

状态机是一个自动机,它将方法的执行分解为多个状态,每个状态对应代码中的一个执行阶段(通常是await点)。状态机通过暂停和恢复机制,确保方法能在异步操作完成时正确继续执行。

2.2 状态机的结构

编译器生成的的状态机通常是一个结构体(在发布模式下以减少分配开销)或类(在调试模式下以便调试),实现了IAsyncStateMachine接口。该接口定义了两个方法:

  • **MoveNext**:驱动状态机执行,是状态机的核心逻辑。
  • **SetStateMachine**:用于跨AppDomain场景,通常不直接使用。

状态机包含以下关键字段:

  • **state**:一个整数,表示当前状态(如-1表示初始,0、1等表示等待点,-2表示完成)。
  • **builder**:AsyncTaskMethodBuilderAsyncTaskMethodBuilder<T>,用于构建和完成返回的Task
  • **awaiter**:表示当前等待的异步操作(如TaskAwaiter)。

2.3 状态机的执行流程

GetNumberAsync为例,其状态机的执行流程如下:

  1. 初始状态(state = -1):方法开始执行。
  2. **遇到await**:检查Task.Delay(1000)是否已完成。

    • 如果未完成,状态机将:

      • 更新state为0(表示等待第一个await)。
      • 注册一个延续(continuation),等待任务完成时回调。
      • 返回,释放线程。
    • 如果已完成,直接继续执行。
  3. 任务完成:任务完成时触发延续,状态机恢复:

    • 检查state值为0,跳转到await后的代码。
    • 获取结果,继续执行。
  4. 方法完成(state = -2):设置返回值并完成Task

以下是简化的状态机伪代码:

private struct GetNumberAsyncStateMachine : IAsyncStateMachine
{
    public int state; // 状态字段
    public AsyncTaskMethodBuilder<int> builder; // Task构建器
    private TaskAwaiter awaiter; // 等待器     public void MoveNext()
    {
        int result;
        try
        {
            if (state == -1) // 初始状态
            {
                awaiter = Task.Delay(1000).GetAwaiter();
                if (!awaiter.IsCompleted) // 任务未完成
                {
                    state = 0; // 等待状态
                    builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); // 注册延续
                    return;
                }
                goto resume0; // 已完成,直接继续
            }
            if (state == 0) // 从await恢复
            {
resume0:
                awaiter.GetResult(); // 获取结果
                result = 42;
                builder.SetResult(result); // 设置返回值
                state = -2; // 完成
            }
        }
        catch (Exception ex)
        {
            builder.SetException(ex); // 设置异常
            state = -2;
        }
    }
}

2.4 状态机图示

为了更直观地理解,我们将从宏观角度理解状态机(State Machine)的组件及其交互逻辑,以下是一个状态机流程图:

https://vkontech.com/exploring-the-async-await-state-machine-series-overview/

3. 任务(Task)的奥秘

3.1 Task的定义

Task是C#异步编程的核心类,位于System.Threading.Tasks命名空间。它表示一个异步操作,可以是计算任务、I/O操作或任何异步工作。Task<T>是带返回值的版本。

3.2 Task的生命周期

Task有以下状态(通过Task.Status属性查看):

  • Created:已创建但未调度。
  • WaitingToRun:已调度但等待执行。
  • Running:正在执行。
  • RanToCompletion:成功完成。
  • Faulted:发生异常。
  • Canceled:被取消。

3.3 Task的调度

Task的执行由任务调度器(TaskScheduler)管理。默认调度器使用线程池(ThreadPool)来执行任务。线程池是一个预分配的线程集合,可以重用线程,避免频繁创建和销毁线程的开销。

创建Task的方式包括:

  • **Task.Run**:将任务调度到线程池执行。
  • **Task.Factory.StartNew**:更灵活的创建方式。
  • 异步方法返回的Task:由AsyncTaskMethodBuilder管理。

3.4 I/O-bound vs CPU-bound任务

  • I/O-bound任务:如网络请求(HttpClient.GetAsync)、文件操作(File.ReadAllTextAsync),使用异步I/O机制,通常不占用线程,而是通过操作系统提供的回调完成。
  • CPU-bound任务:如复杂计算(Task.Run(() => Compute())),在线程池线程上执行。

例如:

public async Task<string> FetchDataAsync()
{
    using var client = new HttpClient();
    return await client.GetStringAsync("https://example.com"); // I/O-bound
} public Task<int> ComputeAsync()
{
    return Task.Run(() => { /* CPU密集型计算 */ return 42; }); // CPU-bound
}

4. 线程管理和上下文

异步编程的核心目标是避免线程阻塞,而不是频繁切换线程。想象一个应用程序,比如一个带有用户界面的程序,主线程(通常是UI线程)负责处理用户交互、绘制界面等任务。如果某个操作(比如网络请求或文件读写)需要很长时间,主线程如果傻等,就会导致程序卡顿。异步编程通过将耗时任务“卸载”出去,让主线程继续执行其他工作,从而保持程序的响应性。

在C#中,asyncawait关键字极大简化了异步编程,但其底层依赖于状态机任务调度

异步并不总是意味着线程切换,而是通过合理的任务分配和通知机制实现非阻塞。

4.1 线程切换是如何发生的?

异步操作中是否涉及线程切换,取决于任务的类型和执行环境。我们可以把任务分为两类:

  1. I/O密集型任务(I/O-bound)

    • 比如网络请求、文件读写等,这些任务通常由系统内核或线程池线程在后台处理。
    • 主线程发起请求后,立即返回,不会被阻塞。当任务完成时,系统通过回调或延续(continuation)通知主线程。
    • 例子:你调用HttpClient.GetAsync(),主线程发起请求后继续执行,网络操作由底层线程池或系统完成,结果回来时触发延续。
  2. CPU密集型任务(CPU-bound)

    • 比如复杂的数学计算,这种任务可以交给线程池线程执行,避免阻塞主线程。
    • 例子:用Task.Run()将计算任务交给线程池,主线程继续处理其他逻辑。

需要注意的是,在某些情况下,异步操作可能根本不涉及线程切换。例如,一个同步完成的I/O操作(比如从缓存读取数据)或使用Task.Yield(),都可能在同一线程上完成。

4.2 C#中async/await的工作原理

在C#中,当你使用asyncawait时,编译器会将方法转化为一个状态机。这个状态机负责:

  • await处暂停方法的执行。
  • 设置一个延续(continuation),表示任务完成后要继续执行的代码。
  • 当任务完成时,触发状态机恢复执行,从await后的代码继续。

关键机制

  • 同步上下文(SynchronizationContext):在UI应用中,await会捕获当前的同步上下文(通常是UI线程上下文),确保任务完成后的延续回到UI线程执行,以便更新界面。
  • ConfigureAwait(false):如果不需要回到原线程(比如在服务器端代码中),可以用这个选项让延续在线程池线程上执行,减少线程切换开销。

4.3 线程切换的开销

线程切换涉及上下文切换(保存和恢复线程状态),开销不小。因此,异步编程的目标是减少不必要的切换。比如:

  • 在UI应用中,延续默认回到UI线程,确保界面更新安全。
  • 在服务器端,ConfigureAwait(false)可以避免切换回原上下文,提升性能。

异步编程通过将耗时任务委托给后台线程或系统内核,避免主线程阻塞,而不是依赖频繁的线程切换。你的比喻基本合理,尤其是“主线程交给另一辆车”的想法,但需要强调主线程不等待、结果通过信号通知的特点。改进后的比喻更准确地反映了异步的非阻塞特性和线程管理机制。

4.4 几个重要概念

4.4.1 同步上下文(SynchronizationContext)

同步上下文是一个抽象类,用于在特定线程或上下文中执行代码。在UI应用程序(如WPF、WinForms)中,UI线程有一个特定的SynchronizationContext,确保UI更新在UI线程上执行。

await默认会捕获当前的同步上下文,并在任务完成后恢复到该上下文执行后续代码。例如:

private async void Button_Click(object sender, EventArgs e)
{
    await Task.Delay(1000);
    label.Text = "Done"; // 自动恢复到UI线程
}

4.4.2 ConfigureAwait 的作用

ConfigureAwait(bool continueOnCapturedContext)允许控制是否恢复到原始上下文:

  • **true**(默认):恢复到捕获的上下文。
  • **false**:在任务完成后的任意线程上继续执行。

在服务器端代码中,使用ConfigureAwait(false)可以避免不必要的上下文切换:

public async Task<string> GetDataAsync()
{
    await Task.Delay(1000).ConfigureAwait(false);
    return "Data"; // 不恢复到原始上下文
}

即使有人对async/await的工作流程有了相当不错的理解,但对于嵌套异步调用链的行为仍有很多困惑。尤其是讨论到在库代码中何时以及如何使用ConfigureAwait(false)时,这种困惑更为明显。接下来我们通过下面的流程图,探索一个非常具体的示例,并深入理解每一个执行步骤:

https://vkontech.com/exploring-the-async-await-state-machine-series-overview/

4.4.3 执行上下文(ExecutionContext)

执行上下文维护线程的执行环境,包括安全上下文、调用上下文等。在异步操作中,ExecutionContext会被捕获并在延续时恢复,确保线程局部数据(如ThreadLocal<T>)的正确性。


5. 异常处理机制

5.1 异常的捕获和传播

在异步方法中,抛出的异常会被捕获并存储在返回的Task中。当awaitTask时,异常会被重新抛出。例如:

public async Task ThrowAsync()
{
    await Task.Delay(1000);
    throw new Exception("Error");
} public async Task CallAsync()
{
    try
    {
        await ThrowAsync();
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message); // 输出 "Error"
    }
}

5.2 状态机中的异常处理

状态机的MoveNext方法包含try-catch块,捕获异常并通过builder.SetException设置到Task中,如前述伪代码所示。

5.3 聚合异常

如果一个Task等待多个子任务(如Task.WhenAll),可能会抛出AggregateException,包含所有子任务的异常。await会自动解包,抛出第一个异常。


6. 自定义Awaiter和扩展性

6.1 Awaiter模式

C#支持await任何实现了awaiter模式的类型,要求:

  • 提供GetAwaiter方法,返回一个awaiter对象。
  • awaiter实现INotifyCompletion(或ICriticalNotifyCompletion),并提供:

    • bool IsCompleted:指示任务是否完成。
    • GetResult:获取结果或抛出异常。

6.2 自定义Awaiter的用途

例如,ValueTask<T>是一个轻量级替代Task<T>的结构,用于高频调用场景减少内存分配:

public ValueTask<int> ComputeValueAsync()
{
    return new ValueTask<int>(42); // 同步完成,无需分配Task
}

7. 实际应用与示例分析

7.1 异步方法的编写

编写异步方法的最佳实践:

  • 使用async Taskasync Task<T>作为返回类型。
  • 避免async void,除非是事件处理程序。
  • 在非UI代码中使用ConfigureAwait(false)

7.2 异步流(C# 8.0+)

异步流(IAsyncEnumerable<T>)允许异步生成和消费数据序列:

public async IAsyncEnumerable<int> GenerateNumbersAsync()
{
    for (int i = 0; i < 5; i++)
    {
        await Task.Delay(100);
        yield return i;
    }
} await foreach (var number in GenerateNumbersAsync())
{
    Console.WriteLine(number);
}

8. 总结与实践建议

C#的异步编程通过asyncawait,结合状态机、任务调度和线程管理,实现了高效的非阻塞代码。其底层原理包括:

  • 状态机:编译器将异步方法转换为状态机,管理暂停和恢复。
  • Task:表示异步操作,由任务调度器和线程池执行。
  • 上下文:同步上下文和执行上下文确保线程安全性。
  • 异常处理:异常在Task中传播,await时重新抛出。

实践建议

  • 使用ConfigureAwait(false)优化服务器端性能。
  • 确保异常在合适的地方被捕获和处理。
  • 将CPU-bound任务调度到线程池,避免阻塞UI线程。
  • 利用异步流处理大数据或实时数据。

通过理解这些底层机制,有助于我们更高效地编写异步代码,从而构建高性能、可伸缩的应用程序。

9. 参考链接

  • How Async/Await Really Works in C#:https://devblogs.microsoft.com/dotnet/how-async-await-really-works/
  • Dissecting the async methods in C# :https://devblogs.microsoft.com/premier-developer/dissecting-the-async-methods-in-c/
  • https://vkontech.com/exploring-the-async-await-state-machine-series-overview/

揭秘C#异步编程核心机制:从状态机到线程池的全面拆解的更多相关文章

  1. 《Windows核心编程系列》十一谈谈Windows线程池

    Windows线程池 上一篇博文我们介绍了IO完成端口.得知IO完成端口可以非常智能的分派线程.但是IO完成端口仅对等待它的线程进行分派,创建和销毁线程的工作仍然需要我们自己来做. 我们自己也可以创建 ...

  2. Java并发编程:Java的四种线程池的使用,以及自定义线程工厂

    目录 引言 四种线程池 newCachedThreadPool:可缓存的线程池 newFixedThreadPool:定长线程池 newSingleThreadExecutor:单线程线程池 newS ...

  3. C#并行编程(2):.NET线程池

    线程 Thread 在总结线程池之前,先来看一下.NET线程. .NET线程与操作系统(Windows)线程有什么区别? .NET利用Windows的线程处理功能.在C#程序编写中,我们首先会新建一个 ...

  4. 并发编程学习笔记(14)----ThreadPoolExecutor(线程池)的使用及原理

    1. 概述 1.1 什么是线程池 与jdbc连接池类似,在创建线程池或销毁线程时,会消耗大量的系统资源,因此在java中提出了线程池的概念,预先创建好固定数量的线程,当有任务需要线程去执行时,不用再去 ...

  5. C#异步编程(二)用户模式线程同步

    基元线程同步构造 多个线程同时访问共享数据时,线程同步能防止数据损坏.不需要线程同步是最理想的情况,因为线程同步存在许多问题. 第一个问题就是它比较繁琐,而且很容易写错. 第二个问题是,他们会损害性能 ...

  6. Windows核心编程:第11章 Windows线程池

    Github https://github.com/gongluck/Windows-Core-Program.git //第11章 Windows线程池.cpp: 定义应用程序的入口点. // #i ...

  7. 009-ThreadPoolExecutor运转机制详解,线程池使用1-newFixedThreadPool、newCachedThreadPool、newSingleThreadExecutor、newScheduledThreadPool

    一.ThreadPoolExecutor理解 为什么要用线程池: 1.减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务. 2.可以根据系统的承受能力,调整线程池中工作线线程的数 ...

  8. C#多线程编程系列(四)- 使用线程池

    目录 1.1 简介 1.2 在线程池中调用委托 1.3 向线程池中放入异步操作 1.4 线程池与并行度 1.5 实现一个取消选项 1.6 在线程池中使用等待事件处理器及超时 1.7 使用计时器 1.8 ...

  9. Java 并发编程中的 Executor 框架与线程池

    Java 5 开始引入 Conccurent 软件包,提供完备的并发能力,对线程池有了更好的支持.其中,Executor 框架是最值得称道的. Executor框架是指java 5中引入的一系列并发库 ...

  10. Netty核心概念(7)之Java线程池

    1.前言 本章本来要讲解Netty的线程模型的,但是由于其是基于Java线程池设计而封装的,所以我们先详细学习一下Java中的线程池的设计.之前也说过Netty5被放弃的原因之一就是forkjoin结 ...

随机推荐

  1. Xshell连接VirtualBox虚拟机中的CentOS

    前提: 安装好VirtualBox虚拟机,并且在虚拟机上安装好CentOS系统. 具体步骤: 1.进入CentOS虚拟机设置--网络--高级--端口转发 2.新增端口规则,按照下面图片填写. 3.打开 ...

  2. [WPF] 在RichTextBox中输出Microsoft.Extension.Logging库的日志消息

    背景 微软的日志库一般是输出到控制台的,但是在WPF中并不能直接使用控制台,需要AllocConsole. 但是这种做法个人觉得不太安全(一关闭控制台整个程序就退出了?).这时候就需要一个更加友好的方 ...

  3. SLAM导航全栈书的正确打开姿势

    SLAM导航全栈书的正确打开姿势 随着人工智能.机器人.无人驾驶等技术的蓬勃发展,作为底层技术基石的SLAM也逐渐被大家所熟知.人工智能技术如果仅仅停留在虚拟的网络和数据之中的话,那么它挖掘并利用知识 ...

  4. sql server2008r2其中一张表不能任何操作

    用户的数据库一张高频表,使用select count(*) from t1 竟然一直在转圈,显示开始,而没有end. 找尽原因不得果.把数据库备份后在恢复,可以使用几小时,之后又是老毛病抽风. 用户生 ...

  5. Cursor预测程序员行业倒计时:CTO应做好50%裁员计划

    提供AI咨询+AI项目陪跑服务,有需要回复1 前两天跟几个业内同学做了一次比较深入的探讨,时间从15.00到21.00,足足6个小时! 其中有个问题特别有意思:从ChatGPT诞生到DeepSeek爆 ...

  6. 0x02 数据结构

    目录 数据结构 链表与邻接表 单链表 双链表 栈与队列 单调栈与队列 KMP KMP算法 Trie字典树 并查集 朴素并查集 维护Size的并查集 维护到祖宗节点距离的并查集 堆 哈希表 拉链法 开放 ...

  7. Chrome 135 版本开发者工具(DevTools)更新内容

    Chrome 135 版本开发者工具(DevTools)更新内容 一.性能(Performance)面板改进 1. 性能面板中的配置文件和函数调用现已显示来源和脚本链接 Performance > ...

  8. Rocketmq 如何处理消息积压 ?

    一.消息积压发现 1.Console入口 A.延迟数量(Delay) 消息积压数量,即当前Topic还剩下多少消息未处理,该值越大,表示积压的消息越多 B.最后消费时间(LastConsumeTime ...

  9. Java 的 CMS 垃圾回收器和 G1 垃圾回收器在记忆集的维护上有什么不同?

    Java 的 CMS 垃圾回收器和 G1 垃圾回收器在记忆集的维护上的不同 记忆集(Remembered Set, RSet)是垃圾回收器用来跟踪跨代引用的重要结构,它记录老年代对象对新生代对象的引用 ...

  10. PC端网页/web通过自定义协议唤起启动windows桌面应用

    PC端网页/web通过自定义协议唤起启动windows桌面应用 步骤: 写注册表 调用 Windows Registry Editor Version 5.00 [HKEY_CURRENT_USER\ ...