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 时,编译器实际上做了一件非常巧妙的事:把你的方法重写成一个状态机。
以之前的下载方法为例,编译器会生成类似这样的逻辑:
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来支持异步锁。