C#异步开发探微

C# 和 JavaScript 中的 async/await 在概念上非常相似,都旨在简化异步编程,但它们在实现细节上有所不同:

  1. 相似点
    • 都使用 async/await 关键字
    • 都使异步代码看起来像同步代码
    • 都使用相同的异常处理模式
  2. 主要差异
    • C# 需要显式指定返回类型,一般是Task,但函数体内无需直接创建Task对象,编译器会对我们返回的结果自动包装。
    • C# 有更丰富的并发控制选项
    • C# 有内置的取消机制 (CancellationToken)
    • JavaScript 在单线程环境中运行,C# 在多线程环境中运行(比如ASP.NET的系统线程池)

注意:async只能用于有函数体的函数,接口上不能使用。

Task.run和async方法的区别

我们可以把一个同步方法通过Task.run放到另一个线程中去执行,不阻塞当前工作线程,营造出一种异步的感觉。

但真正的异步方法(或者叫异步IO),其内部的工作原理大致如下:

  1. 发起操作:方法开始执行,它向操作系统发出一个指令:"嘿,操作系统,请帮我从网络上下载这个网页的数据。"
  2. 不占用线程 :一旦这个指令发出,整个下载过程就交给操作系统和硬件(网卡、网络驱动等)去处理了。这期间,没有任何一个 CPU 线程在"等待"数据返回。无论是你原来的线程,还是线程池里的其他线程,都没有被这个操作占用。它们在等待期间是自由的,可以去做其他工作(比如处理界面点击,或者处理另一个 HTTP 请求)。
  3. 通知与回调 :当操作系统和硬件完成工作,数据准备好之后,它会通知 .NET 运行时,整个机制叫IOCP(Input Output Completion Port,IO完成端口)。这时,运行时才会从线程池中找一个空闲的线程 来继续执行 await 后面的代码。

本质上await+async其实是协程切换机制,常用于IO密集型操作,而Task.run仍然是传统的多线程调度。

顺带说一下协程的两种常见语言级实现,C# 的 await/async 属于第二种:

特性 传统协程 (Lua, Python yield) C# await/async
语法 显式 yield 让出控制权 隐式的状态机,await 表示挂起点
调用者关系 通常需要手动驱动(next() 事件驱动,完成后自动恢复
实现机制 栈式协程 状态机
返回值 通常是自己定义 统一的 Task 模式

C# 的 await/async 是如何实现协程的?

当你写下 await 时,编译器实际上做了一件非常巧妙的事:把你的方法重写成一个状态机

以之前的下载方法为例,编译器会生成类似这样的逻辑:

c# 复制代码
// 这是编译器生成的简化版状态机
class DownloadAsync_StateMachine
{
    int _state = 0;           // 当前执行到哪一步了
    string _data;             // 需要保留的局部变量
    HttpClient _httpClient;
    
    public Task MoveNext()
    {
        if (_state == 0)
        {
            Console.WriteLine("开始下载");
            
            // 启动异步操作,并告诉它完成后回调 MoveNext
            Task<string> task = _httpClient.GetStringAsync(url);
            
            // 关键:如果操作还没完成,就"挂起"
            if (!task.IsCompleted)
            {
                // 注册一个 continuation:完成后继续执行
                task.ContinueWith(t => this.MoveNext());
                _state = 1;  // 记录下一个状态
                return;       // 立即返回,不阻塞线程!
            }
            
            // 如果奇迹般瞬间完成,直接继续
            _state = 1;
            // 继续执行...
        }
        
        if (_state == 1)
        {
            // 从 task 拿到结果
            Console.WriteLine($"下载完成: {task.Result}");
            _state = -1;  // 完成
        }
    }
}

这正是协程的核心机制

  • 一个函数被切分成多个片段(状态)
  • 遇到 await 时保存当前状态并返回
  • 异步操作完成后,通过回调重新进入这个状态机

await后续动作的执行者是谁?如何自定义执行线程?

await后续动作是由IOCP机制触发的,理论上,可以是任意线程,不必是原来的线程。

默认行为

  • UI 应用 -> 同一个 UI 线程

  • ASP.NET Core / 控制台 -> 系统线程池的任意线程

也可以通过自定义SynchronizationContext来强行指定await后续动作的线程归属。

系统线程池

无论是 ASP.NET Core(处理 Web 请求)、Task.Run、还是 await 完成后执行的后续代码,它们所使用的都是同一个 .NET 系统线程池,即 System.Threading.ThreadPool 类所管理的全局线程池 。这是 CLR(公共语言运行时)在一个进程中维护的唯一线程池,被该进程内的所有组件共享 。这个线程池有线程数量的上下限,并且它的扩容机制非常保守

下限(最小线程数)
  • 默认值ASP.NET Core 应用启动时,线程池的最小线程数等于 CPU 的核心数

  • 。例如,在一个 4 核的服务器上,刚启动时线程池里只会有 4 个线程。

  • 为什么这么少? 因为 .NET 的设计哲学是"保守"。大多数时候,应用并不需要几百个线程同时工作。保持较少的线程可以减少上下文切换的开销,提升性能。

上限(最大线程数)
  • 默认值 :在 .NET Core / .NET 5+ 中,工作线程(worker threads)的最大数量默认是一个非常大的值,通常是 32,767

。而异步 I/O 完成线程(completion port threads)的最大数量默认是 1000

32,767 意味着无限吗? 不,它只是一个技术上可行的上限。在 32,767 之内,线程池理论上可以创建这么多线程,但实际上几乎永远不会达到这个数字,因为服务器资源(CPU、内存)会先被耗尽。每个线程都会占用一定的内存(默认栈空间),创建数千个线程会迅速耗尽内存,导致程序崩溃。

因为默认的最小线程数很低(核心数),而创建新线程又很慢(每秒1-2个),这会导致一个典型问题:应用刚启动时遇到高并发,线程数不够用,大量请求在排队,而新线程正在"慢吞吞"地创建,造成"线程池饥饿"

开发人员如何干预?

你可以通过编程方式调整线程池的参数来优化这种行为。

  • 设置最小线程数:最常用的优化是提高最小线程数。这会让线程池在应用启动时就准备好一定数量的线程,而不是等到高并发来了才现场去创建。
c# 复制代码
// 在应用启动时(例如 Program.cs 或 Startup.cs 中)调用
// 假设你的服务器有 8 核,并且预期会有较高的并发,可以设置最小工作线程为 50
ThreadPool.SetMinThreads(workerThreads: 50, completionPortThreads: 50);

异步编程注意事项

  • for循环里的异步调用不是并行的,而是串行的!因此,如果某次循环里出现了长时间的同步IO,就会造成后续循环阻塞。一个典型的例子是同步connect失败。
  • ASP.NET的异步编程下,无主线程和工作线程之分,都是从系统线程池随机取的
  • 不使用await调用async方法,实际上会在async方法内的第一个await处就返回,返回一个未完成的Task;而使用await,会等async方法的所有逻辑处理完再返回,返回的是已完成Task的结果。看下面例子:
c# 复制代码
    private async Task DoSomethingAsync()
    {
        testOutputHelper.WriteLine("Step 1: 开始执行");
        await Task.Delay(1000); // 遇到第一个 await,返回未完成的 Task
        testOutputHelper.WriteLine("Step 2: 延迟后继续执行");
        // 方法结束,Task 标记为完成
    }

    [Fact]
    public async Task Test1()
    {
        // 调用方代码
        var task = DoSomethingAsync(); // 打印完Step1后返回
        testOutputHelper.WriteLine("调用方继续执行");
        await task; 
    }

    [Fact]
    public async Task Test2()
    {
        await DoSomethingAsync();
        testOutputHelper.WriteLine("调用方继续执行");
    }

Test1打印结果:

复制代码
Step 1: 开始执行
调用方继续执行
Step 2: 延迟后继续执行

Test2打印结果:

复制代码
Step 1: 开始执行
Step 2: 延迟后继续执行
调用方继续执行

印证了上述结论。

  • 标准锁没有异步操作,因为锁是线程级别的。可用内置的SemaphoreSlim或三方库Nito.AsyncEx来支持异步锁。
相关推荐
2301_793804693 小时前
C++中的访问者模式变体
开发语言·c++·算法
2501_945424803 小时前
模板代码版本兼容
开发语言·c++·算法
m0_518019483 小时前
C++中的委托构造函数
开发语言·c++·算法
m0_743470373 小时前
高性能计算框架实现
开发语言·c++·算法
weixin_307779133 小时前
2025年中国研究生数学建模竞赛A题:通用神经网络处理器下的核内调度问题——解决方案与实现
开发语言·人工智能·python·数学建模·性能优化
焦糖玛奇朵婷3 小时前
盲盒小程序开发|解锁开箱新体验[特殊字符]
大数据·开发语言·程序人生·小程序·软件需求
1104.北光c°3 小时前
基于Canal + Kafka的高可用关注系统:一主多从关系链
java·开发语言·笔记·分布式·程序人生·kafka·一主多从
Mem0rin3 小时前
[Java]异常及其处理
java·开发语言
2401_846341653 小时前
调试技巧与核心转储分析
开发语言·c++·算法