C# 和 JavaScript 中的 async/await 在概念上非常相似,都旨在简化异步编程,但它们在实现细节上有所不同:
- 相似点:
- 都使用 async/await 关键字
- 都使异步代码看起来像同步代码
- 都使用相同的异常处理模式
- 主要差异:
- C# 需要显式指定返回类型,一般是Task,但函数体内无需直接创建Task对象,编译器会对我们返回的结果自动包装。
- C# 有更丰富的并发控制选项
- C# 有内置的取消机制 (CancellationToken)
- JavaScript 在单线程环境中运行,C# 在多线程环境中运行(比如ASP.NET的系统线程池)
注意:async只能用于有函数体的函数,接口上不能使用。
Task.run和async方法的区别
我们可以把一个同步方法通过Task.run放到另一个线程中去执行,不阻塞当前工作线程,营造出一种异步的感觉。
但真正的异步方法(或者叫异步IO),其内部的工作原理大致如下:
- 发起操作:方法开始执行,它向操作系统发出一个指令:“嘿,操作系统,请帮我从网络上下载这个网页的数据。”
- 不占用线程:一旦这个指令发出,整个下载过程就交给操作系统和硬件(网卡、网络驱动等)去处理了。这期间,没有任何一个 CPU 线程在“等待”数据返回。无论是你原来的线程,还是线程池里的其他线程,都没有被这个操作占用。它们在等待期间是自由的,可以去做其他工作(比如处理界面点击,或者处理另一个 HTTP 请求)。
- 通知与回调:当操作系统和硬件完成工作,数据准备好之后,它会通知 .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时,编译器实际上做了一件非常巧妙的事:把你的方法重写成一个状态机。
以之前的下载方法为例,编译器会生成类似这样的逻辑:
// 这是编译器生成的简化版状态机 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个),这会导致一个典型问题:应用刚启动时遇到高并发,线程数不够用,大量请求在排队,而新线程正在“慢吞吞”地创建,造成“线程池饥饿”。线程池饥饿的情况下,我们会看到进程的内存占用在快速升高,直到达到一个峰值,然后再慢慢回落。
开发人员如何干预?
你可以通过编程方式调整线程池的参数来优化这种行为。
- 设置最小线程数:最常用的优化是提高最小线程数。这会让线程池在应用启动时就准备好一定数量的线程,而不是等到高并发来了才现场去创建。
// 在应用启动时(例如 Program.cs 或 Startup.cs 中)调用 // 假设你的服务器有 8 核,并且预期会有较高的并发,可以设置最小工作线程为 50 ThreadPool.SetMinThreads(workerThreads: 50, completionPortThreads: 50);一般一个工作线程配一个IO完成线程,两者值设置为一样。
通过设置SetMinThreads,可以有效的“削峰”,在我的应用里,可将内存峰值从700M削到600M,而且进程内稳定线程总数也从90降到70多。
特别说明:SetMinThreads并非指定线程池最小应持有的线程数,它的意思是:在达到这个最小值之前,可以激进策略创建线程(来一个请求创建一个线程),而非传统的保守策略创建(每秒1-2个)。若你的系统真的空闲下来,线程池依然会释放空闲线程,最终的池内线程数可能远远小于SetMinThreads的值(比如,回到CPU核心数)。
异步编程注意事项
- for循环里的异步调用不是并行的,而是串行的!因此,如果某次循环里出现了长时间的同步IO,就会造成后续循环阻塞。一个典型的例子是同步connect失败。
- ASP.NET的异步编程下,无主线程和工作线程之分,都是从系统线程池随机取的
- 不使用await调用async方法,实际上会在async方法内的第一个await处就返回,返回一个未完成的Task;而使用await,会等async方法的所有逻辑处理完再返回,返回的是已完成Task的结果。看下面例子:
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("调用方继续执行"); } [Fact] public async Task Test3() { // 调用方代码 DoSomethingAsync(); // 立即返回,不等待,也不用变量承接,跟Test1效果一样 testOutputHelper.WriteLine("调用方继续执行"); await Task.Delay(1000); }Test1打印结果:
Step 1: 开始执行 调用方继续执行 Step 2: 延迟后继续执行Test2打印结果:
Step 1: 开始执行 Step 2: 延迟后继续执行 调用方继续执行Test3打印结果:
Step 1: 开始执行 调用方继续执行 Step 2: 延迟后继续执行印证了上述结论。该结论很有用,可做异步并行操作。
- 标准锁没有异步操作,因为锁是线程级别的。可用内置的SemaphoreSlim或三方库Nito.AsyncEx来支持异步锁。
异步串行和异步并行
如前所述,使用await调用async方法,就是“异步串行”方式;不用await调用async,改用变量承接async函数,就能达到“异步并行”的效果,后者是实现高吞吐、高并发的利器。
对日志定位的影响
像传统的搜索特定线程号的做法不可行了,因为一个async函数可能由多个线程接力完成,所以更加依赖日志关键字了。