为什么要用async、await ?

为什么要用async、await?

聊到c#基础,async/await是绕不开的话题,本文只是结合自己后端开发的经验,按照自己的思路重新整理了一下,介绍的场景也是针对webapi接口请求。

一、为什么要用async、await?

异步编程可以提高系统的吞吐量,async/await语法简化了异步编程的实现,降低使用门槛。

二、为什么异步编程可以极大地提高系统的吞吐量?

当请求执行到异步任务方法时,当前请求线程会释放回收到线程池,不会一直等待异步任务执行完成,可以提高线程利用率。

csharp 复制代码
[HttpGet("async")]
public async Task<IActionResult> GetUserAsync(int id)
{
    // 1. 执行一些同步逻辑...
    // 2. 调用【异步】数据库查询方法 - 这会释放当前线程
    var user = await _userRepository.GetAsync(id); // 假设此操作耗时9s
    // 3. 对结果进行加工处理...
    return Ok(user);
}

以上是一个简单的异步查询数据的方法,执行过程中线程的使用:

  1. 接收到请求后,线程池分配空闲线程线程【1】执行
  2. 线程【1】执行一些同步逻辑
  3. 线程【1】调用GetAsync方法
  4. 发送请求到数据库,线程【1】线程释放到线程池,可以继续处理其他请求
  5. 数据库相关操作由操作系统和硬件处理
  6. 数据库处理完成后,由I/O完成端口触发回调
  7. 从线程池中重新分配线程【2】继续处理后续逻辑

上述第5步耗时比较长的主要是I/O操作,用异步方法会将线程释放,等到数据库返回结果后,再从线程池中重新分配线程继续执行后续代码。如果是同步方法,那么在第4步时,线程不会被释放,一直会被占用。

那么这种差异对并发量有什么影响?假设线程池有100个线程:

【同步执行】 100线程数 / 10s 约等于每秒处理10个请求,当请求量高于这个数值时,会发生请求等待的情况。

模拟同步并发,100个请求,每个耗时10s,同时最多只有10个任务在执行,最后执行完101551ms。

csharp 复制代码
static void Main(string[] args)
{
    int maxWorkerThreads, maxIOThreads;
    ThreadPool.GetMaxThreads(out maxWorkerThreads, out maxIOThreads);

    // 设置最大工作线程数为 10
    ThreadPool.SetMaxThreads(10, maxIOThreads);

    var stopwatch = Stopwatch.StartNew();
    Console.WriteLine($"主线程ID: {Environment.CurrentManagedThreadId}");
    Console.WriteLine($"开始模拟100个耗时同步任务...\n");

    // 创建100个任务
    var tasks = new Task[100];
    for (int i = 0; i < 100; i++)
    {
        int taskId = i; // 捕获循环变量
        tasks[i] = Task.Run(() =>
        {
            ExecuteSynchronousTask(taskId);
        });
    }

    // 同步等待所有任务完成(会阻塞主线程)
    Task.WaitAll(tasks);

    stopwatch.Stop();
    Console.WriteLine($"\n所有100个同步任务完成!");
    Console.WriteLine($"总耗时: {stopwatch.ElapsedMilliseconds} ms");
    Console.WriteLine($"最后线程ID: {Environment.CurrentManagedThreadId}");
}

// 模拟耗时同步任务
static void ExecuteSynchronousTask(int taskId)
{
    Console.WriteLine($"任务 {taskId} 开始执行,线程ID: {Environment.CurrentManagedThreadId}");
    Thread.Sleep(10000); // 同步延迟10秒
    Console.WriteLine($"任务 {taskId} 完成,线程ID: {Environment.CurrentManagedThreadId}");
}

【异步执行】 当请求执行到数据库处理时,线程会释放到线程池,这样线程就可以用来处理其他请求。

模拟异步并发,100个请求,每个耗时10s,最后执行完10865ms。

csharp 复制代码
static async Task Main(string[] args)
{
    int maxWorkerThreads, maxIOThreads;
    ThreadPool.GetMaxThreads(out maxWorkerThreads, out maxIOThreads);

    // 设置最大工作线程数为 10
    ThreadPool.SetMaxThreads(10, maxIOThreads);

    var stopwatch = Stopwatch.StartNew();
    Console.WriteLine($"主线程ID: {Environment.CurrentManagedThreadId}");
    Console.WriteLine($"开始模拟100个耗时异步任务...");

    // 创建100个任务
    var tasks = new Task[100];
    for (int i = 0; i < 100; i++)
    {
        int taskId = i; // 捕获循环变量
        tasks[i] = ExecuteAsynchronousTaskAsync(taskId);
    }

    // 同步等待所有任务完成(会阻塞主线程)
    Task.WaitAll(tasks);

    stopwatch.Stop();
    Console.WriteLine($"所有100个异步任务完成!");
    Console.WriteLine($"总耗时: {stopwatch.ElapsedMilliseconds} ms");
    Console.WriteLine($"最后线程ID: {Environment.CurrentManagedThreadId}");
}

/// <summary>
/// 模拟异步耗时操作
/// </summary>
/// <param name="taskId"></param>
static async Task ExecuteAsynchronousTaskAsync(int taskId)
{
    Console.WriteLine($"任务 {taskId} 开始执行,线程ID: {Environment.CurrentManagedThreadId}");
    await Task.Delay(10000);
    Console.WriteLine($"任务 {taskId} 完成,线程ID: {Environment.CurrentManagedThreadId}");
}

三、async/await是不是一定可以提高系统的吞吐量?

不一定。上文的例子中,提高吞吐量的关键是在执行查询数据库时会释放线程。因为查询数据是I/O操作,由操作系统处理,如果是CPU类型操作时,请求线程释放后会立即分配一个新的线程处理,这样并不会提高线程利用率,反而因为线程切换增加额外的开销

所以,如果接口请求中有CPU密集型任务,我们用Task.Run限制并发量的方式,或者交给其他的服务(如消息队列)去处理。

I/O类型:数据库操作、网络请求、文件读写

CPU计算类型:图片处理、函数计算

四、async/await是不是可以提高单次接口的响应速度?

不会。异步编程是通过提高线程的利用率来增加应用并发量,针对单次请求,异步操作反而会因为线程切换增加额外的开销。如果想提高单次请求速度,是通过并发编程实现。

异步编程示例:总耗时15s

csharp 复制代码
static async Task Main(string[] args)
{
    var stopwatch = Stopwatch.StartNew();

    await Task.Delay(5000);
    await Task.Delay(5000);
    await Task.Delay(5000);

    stopwatch.Stop();
    Console.WriteLine($"总耗时: {stopwatch.ElapsedMilliseconds} ms");
}

并发编程示例:总耗时 5s

csharp 复制代码
static async Task Main(string[] args)
{
    var stopwatch = Stopwatch.StartNew();

    var task1 = Task.Delay(5000);
    var task2 = Task.Delay(5000);
    var task3 = Task.Delay(5000);

    Task.WaitAll(task1, task2, task3);

    stopwatch.Stop();
    Console.WriteLine($"总耗时: {stopwatch.ElapsedMilliseconds} ms");
}

五、async/await是如何切换线程提高吞吐量的?

通过状态机模式。

下面是一个简单的异步方法,生成后用dnSpy看下IL代码发现编译器会针对RunTasksAsync生成一个实现IAsyncStateMachine的状态机类,里面有状态记录执行进度,当异步任务执行完成时,通过回调通知继续执行方法内后续逻辑,看通过deepseek生成IL的伪代码,可以清晰了了解内部执行过程。

csharp 复制代码
C#方法
static async Task RunTasksAsync(int i)
{
    int j = i * 10;
    int result = await Task.Run(() => { return j + 1; } );
    int k = result * 10;
    Console.WriteLine(k);
}
csharp 复制代码
IL代码通过deepseek简化
// RunTasksAsync方法的状态机  
[CompilerGenerated]
private sealed class <RunTasksAsync>d__1 : IAsyncStateMachine
{
    public int <>1__state;                    // 状态字段:控制执行流程。-1=初始/恢复,0=在await暂停,-2=已完成
    public AsyncTaskMethodBuilder <>t__builder; // 构建并管理最终返回的Task对象,用于设置结果或异常
    public int i;                             // 原始方法参数i,被"提升"为状态机字段,以便跨await恢复时仍可访问
    private <>c__DisplayClass1_0 <>8__1;      // 显示类实例,用于捕获lambda表达式中的变量(如j=i*10),实现闭包
    private int <result>5__2;                 // 对应原始代码中的局部变量'result',保存await的返回值
    private int <k>5__3;                      // 对应原始代码中的局部变量'k',用于后续计算
    private int <>s__4;                       // 临时字段,用于存储await表达式的结果(即awaiter.GetResult()的返回值)
    private TaskAwaiter<int> <>u__1;          // 保存Task<int>的等待器(awaiter),在await未完成时暂存,恢复时使用

    void MoveNext()
    {
        try
        {
            TaskAwaiter<int> awaiter;         // 局部变量:用于操作await的awaiter对象
            int state = this.<>1__state;      // 读取当前状态,决定从哪开始执行
            
            if (state != 0) // 首次执行(state为-1)或从异常后继续,进入同步执行路径
            {
                // 创建显示类来捕获lambda表达式的上下文
                this.<>8__1 = new <>c__DisplayClass1_0();  // 实例化闭包类,用于在异步lambda中访问外部变量
                this.<>8__1.j = this.i * 10;               // 在闭包类中计算 j = i * 10,供lambda使用
                
                // 开始异步操作:Task.Run(() => j + 1)
                awaiter = Task.Run<int>(this.<>8__1.<RunTasksAsync>b__0).GetAwaiter();
                // 获取Task<int>的awaiter,以便检查完成状态和获取结果
                
                if (!awaiter.IsCompleted)     // 如果任务尚未完成,则需要"暂停"当前方法
                {
                    this.<>1__state = 0;      // 设置状态为0,表示在第一个await处暂停
                    this.<>u__1 = awaiter;    // 保存awaiter到字段,供恢复时使用(避免栈变量丢失)
                    this.<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                    // 注册回调:当Task完成时,调用状态机的MoveNext()继续执行
                    // 此调用会"挂起"当前线程,将控制权返回给调用者,实现非阻塞
                    return;                   // 退出MoveNext,等待Task完成后再恢复
                }
                // 如果任务已同步完成(IsCompleted == true),则直接继续执行,不暂停
            }
            else // state == 0,表示从await暂停点恢复执行
            {
                awaiter = this.<>u__1;        // 从字段中取出之前保存的awaiter
                this.<>u__1 = default;        // 清空字段,避免内存泄漏和重复使用
                this.<>1__state = -1;         // 重置状态为-1,表示正在恢复执行
            }
            
            // 执行到此处,说明await已完成,获取结果
            // 对应原始代码:int result = await Task.Run(() => j + 1);
            this.<>s__4 = awaiter.GetResult();   // 调用GetResult()获取Task的返回值(int)
            this.<result>5__2 = this.<>s__4;     // 将结果赋值给局部变量result(提升为字段)
            
            // 继续执行await之后的同步代码
            this.<k>5__3 = this.<result>5__2 * 10; // 对应:k = result * 10
            Console.WriteLine(this.<k>5__3);       // 对应:Console.WriteLine(k)
            
            // 方法正常执行完成
            this.<>1__state = -2;              // 设置状态为-2,表示已完成
            this.<>8__1 = null;                // 清理闭包类实例,帮助GC回收
            this.<>t__builder.SetResult();     // 通知builder:Task已完成,设置为成功状态
        }
        catch (Exception ex)                   // 捕获await期间或后续代码中抛出的任何异常
        {
            this.<>1__state = -2;              // 标记为已完成(失败)
            this.<>8__1 = null;                // 清理资源
            this.<>t__builder.SetException(ex); // 通知builder:Task失败,设置异常
            // 异常会被封装到返回的Task中,调用者通过await或Task.Exception获取
        }
    }
}

结尾

以上是个人理解的整理,也参考了其他博主的文档,如果错误,欢迎指正,感谢

相关推荐
NullReference3 小时前
记一次WPF程序界面卡死的情况
c#
秋月的私语3 小时前
wpf程序启动居中并且最小化到托盘修复记录
c#
木心爱编程6 小时前
C++程序员速通C#:从Hello World到数据类型
c++·c#
※※冰馨※※7 小时前
【c#】 使用winform如何将一个船的图标(ship.png)添加到资源文件
开发语言·windows·c#
咕白m6257 小时前
C# 实现 Word 与 TXT 文本格式互转
c#·.net
土了个豆子的20 小时前
04.事件中心模块
开发语言·前端·visualstudio·单例模式·c#
@areok@21 小时前
C++mat传入C#OpencvCSharp的mat
开发语言·c++·opencv·c#
时光追逐者1 天前
C# 哈希查找算法实操
算法·c#·哈希算法