为什么要用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】执行一些同步逻辑
- 线程【1】调用GetAsync方法
- 发送请求到数据库,线程【1】线程释放到线程池,可以继续处理其他请求
- 数据库相关操作由操作系统和硬件处理
- 数据库处理完成后,由I/O完成端口触发回调
- 从线程池中重新分配线程【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获取
}
}
}
结尾
以上是个人理解的整理,也参考了其他博主的文档,如果错误,欢迎指正,感谢