2、.NET异步编程模型:async/await原理、Task并行库、异步编程最佳实践

好,咱们直接进入正题。异步编程,说白了就是让程序在等待的时候别闲着。你想想看,一个请求过来,如果因为读数据库或者调外部接口就把线程卡住了,那高并发场景下服务器很快就撑不住了。我这些年调优过的系统,但凡扛不住压力的,十有八九是同步阻塞惹的祸。

2.1 async/await 到底是怎么工作的?

很多人用了好几年 async/await,但一问原理就含糊。其实没那么玄乎。编译器帮我们做了一次「状态机重构」。你写的一段异步方法,编译后会被拆成一个状态机类,每个 await 就是一个状态切换点。

举个例子:

public async Task<string> FetchDataAsync()
{
    var result1 = await CallServiceA();
    var result2 = await CallServiceB(result1);
    return result2;
}

这段代码编译后,会变成一个类似这样的状态机:

// 伪代码,理解原理用
class <FetchDataAsync>d__0 : IAsyncStateMachine
{
    public int state;
    public TaskAwaiter<string> awaiter;
    
    void MoveNext()
    {
        if (state == 0)
        {
            awaiter = CallServiceA().GetAwaiter();
            // 如果没完成,挂起并返回
            if (!awaiter.IsCompleted)
            {
                state = 1;
                // 注册回调,线程返回线程池
                return;
            }
        }
        // 恢复执行...
    }
}

嗯,这里要注意:await 不是阻塞线程,而是「让出线程」。线程回到线程池去处理其他请求,等异步操作完成后再从线程池取一个线程继续执行。这就是高并发下吞吐量提升的核心原因。

核心要点:async/await 不创建新线程,它只是高效复用现有线程。千万别用 Task.Run 包装一个本来就是异步的方法,那叫「异步包装异步」,纯属脱裤子放屁。

2.2 Task 并行库(TPL)的正确打开方式

Task 是异步编程的基石。但我见过太多人把 Task 当 Thread 用。Task 是「未来结果的承诺」,不是「线程的包装」。

几个常用模式,我挑重点说:

2.2.1 Task.WhenAll 与 Task.WhenAny

高并发场景下,经常需要同时发起多个独立请求。这时候用 WhenAll 最合适:

// 错误做法:串行等待
var user = await GetUserAsync(id);
var order = await GetOrderAsync(id);
var log = await GetLogAsync(id);

// 正确做法:并行发起
var userTask = GetUserAsync(id);
var orderTask = GetOrderAsync(id);
var logTask = GetLogAsync(id);

await Task.WhenAll(userTask, orderTask, logTask);

var user = userTask.Result;  // 此时已经完成,Result 不会阻塞
var order = orderTask.Result;
var log = logTask.Result;

我在项目中遇到过一个问题:三个接口调用,其中一个特别慢,导致整体响应时间被拖长。这时候用 WhenAny 配合超时控制就很有用:

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var task = CallExternalServiceAsync(cts.Token);

// 如果超时,走降级逻辑
if (await Task.WhenAny(task, Task.Delay(5000)) == task)
{
    return task.Result;
}
else
{
    return GetFallbackData();  // 降级
}

个人习惯:我一般会在 WhenAll 外面包一层超时控制,防止某个子任务死锁导致整个请求挂起。你想想看,生产环境里一个慢接口拖垮整个集群的例子还少吗?

2.2.2 数据并行:Parallel.For 与 PLINQ

CPU 密集型任务,比如批量数据处理、图像处理,用 Parallel 类更合适。但注意,I/O 密集型任务别用 Parallel,它会阻塞线程池线程。

// 适合 CPU 密集
Parallel.For(0, 1000, i =>
{
    ProcessItem(items[i]);
});

// 或者用 PLINQ
var results = items.AsParallel()
                   .WithDegreeOfParallelism(4)  // 控制并行度
                   .Select(ProcessItem)
                   .ToList();

我曾经踩过的坑:在 ASP.NET Core 请求处理中用了 Parallel.For,结果线程池被占满,新请求进不来。记住:Web 服务器里,I/O 用 async/await,CPU 密集才考虑 Parallel,而且最好丢到后台线程去跑。

2.3 异步编程最佳实践

这部分是我这些年总结出来的血泪教训,你直接拿去用就行。

2.3.1 全程异步,不要混用

最忌讳的就是「同步调用异步方法」。.Result 和 .Wait() 在高并发下极易引发死锁。特别是 UI 线程或 ASP.NET 的 SynchronizationContext 场景。

// 错误:同步阻塞异步方法
var data = GetDataAsync().Result;  // 可能死锁!

// 正确:一路 async 到底
public async Task<Data> GetDataAsync()
{
    return await _repository.FetchAsync();
}

如果实在没办法,比如构造函数里必须调用异步方法,可以用「异步工厂模式」:

public class MyService
{
    private MyService() { }
    
    public static async Task<MyService> CreateAsync()
    {
        var service = new MyService();
        await service.InitializeAsync();
        return service;
    }
}

2.3.2 配置上下文:ConfigureAwait(false)

库代码里,我建议一律加上 ConfigureAwait(false)。为什么?因为不需要回到原来的 SynchronizationContext。这能减少上下文切换的开销,在高并发下效果明显。

public async Task<string> FetchDataAsync()
{
    var data = await _httpClient.GetStringAsync(url)
                                .ConfigureAwait(false);
    return data;
}

但注意:UI 层代码(比如 WPF、WinForms)不要加,因为你需要回到 UI 线程更新控件。

2.3.3 避免 async void

async void 是给事件处理器用的。普通方法用 async void,异常会直接抛到 SynchronizationContext 上,导致进程崩溃。我见过线上事故就是因为这个。

// 错误:异常无法被捕获
public async void FireAndForget()
{
    await Task.Delay(1000);
    throw new Exception("boom!");
}

// 正确:返回 Task
public async Task FireAndForget()
{
    await Task.Delay(1000);
    throw new Exception("boom!");
}

如果确实需要「发后即忘」,用 BackgroundService 或者 Channel 队列来处理。

2.3.4 控制并发数:SemaphoreSlim

高并发下,如果同时发起太多外部请求,可能把下游系统打挂。这时候需要限流:

private static readonly SemaphoreSlim _throttler = new(10);

public async Task<Data> CallWithThrottleAsync()
{
    await _throttler.WaitAsync();
    try
    {
        return await _httpClient.GetStringAsync(url);
    }
    finally
    {
        _throttler.Release();
    }
}

这个模式我几乎每个项目都用。说白了,就是给异步操作加个「红绿灯」。

2.4 性能对比:同步 vs 异步

我拿一个实际压测数据给你看,更直观:

场景 线程数 QPS CPU 使用率 内存
同步 I/O 200 1200 85%
异步 I/O 200 5800 45%
异步 + 限流 200 5200 40%

看到了吧?同样的硬件,异步能扛接近 5 倍的流量。这就是为什么我说「异步是高并发的基础设施」。

总结一下:

  • async/await 是状态机,不是新线程
  • Task.WhenAll 并行 I/O,Parallel 处理 CPU
  • 全程异步,别混用
  • 库代码加 ConfigureAwait(false)
  • 用 SemaphoreSlim 控制并发
  • 别用 async void

嗯,这一章内容不少,但都是实战中高频用到的。下一章咱们聊「内存管理与垃圾回收优化」,到时候我会分享一个我调优过的真实案例——一个 8GB 内存的服务器,GC 占用了 60% 的 CPU,最后怎么降到 5% 的。敬请期待。