← 博客
技术2026-09-1147 次阅读

C# async/await 在 MES 高并发采集中的并发陷阱与调优

# C## .NET# async/await# MES# 高并发# HttpClient# 线程池# 性能调优

背景:采集服务的并发膨胀

我们的 MES 采集服务需要同时对接产线上 30 多台设备——PLC 走 TCP 长连接、扫码枪走串口、部分设备走 HTTP REST 接口。最初版本是同步写的,一轮采集循环跑下来要 40 秒,产线节拍是 15 秒一个工位,数据延迟直接导致看板数据对不上。于是全面改成 async/await,一口气把所有 I/O 调用都异步化了。

改完确实快了——单轮采集从 40 秒降到 3 秒。但上线第二天凌晨,服务挂了。

问题:线程池耗尽与超时雪崩

看 Windows 事件日志,异常信息是 InvalidOperationException: A task was canceled 和 SocketException: 由于系统缓冲区空间不足或队列已满,不能对套接字执行操作。Thread Pool 的队列长度飙升到几千,CPU 占用反而不高(20%),典型的"线程全在等 I/O、没有真正在干活"。

根因有三个:

  • 无限制并发:对 30 台设备同时 Task.WhenAll 发起 HTTP 请求,每台设备又要查 5-8 个点位,瞬时并发量 200+。HTTP 连接池根本扛不住。
  • HttpClient 用错:每次采集都 new HttpClient(),套接字来不及释放,TIME_WAIT 堆积。.NET Core 之前这是经典坑,.NET 8 里依然有人踩。
  • async void 隐患:采集回调用了 async void 方法处理异常重试,异常直接逃逸到 SynchronizationContext,进程崩溃。

解决思路:限流 + 复用 + 异常收敛

核心思路三步走:用 SemaphoreSlim 做并发限流,HttpClient 单例化,所有异步方法返回 Task 而非 void。

实现步骤

1. HttpClient 单例化

用 IHttpClientFactory 或静态单例,绝不每次 new:

// ✅ 推荐:DI 注入 IHttpClientFactory(.NET Core+)
public class DeviceClient
{
    private readonly HttpClient _client;
    
    public DeviceClient(IHttpClientFactory factory)
    {
        _client = factory.CreateClient("Device");
    }
    
    public async Task<string> ReadPointAsync(string ip, string point)
    {
        var resp = await _client.GetAsync($"http://{ip}/api/points/{point}");
        resp.EnsureSuccessStatusCode();
        return await resp.Content.ReadAsStringAsync();
    }
}

// DI 注册
builder.Services.AddHttpClient("Device", client =>
{
    client.Timeout = TimeSpan.FromSeconds(5);
    client.DefaultRequestHeaders.ConnectionClose = false; // 启用 Keep-Alive
});

IHttpClientFactory 内部管理 HttpMessageHandler 池,默认每 2 分钟回收底层套接字,从根本上解决 SocketExhausted 问题。

2. SemaphoreSlim 并发限流

对设备批次做限流,避免 200 个请求同时打出去:

public class DeviceCollector
{
    private readonly DeviceClient _client;
    // 限制最多 10 个并发采集
    private static readonly SemaphoreSlim _throttle = new(10);
    
    public DeviceCollector(DeviceClient client) => _client = client;
    
    public async Task<Dictionary<string, string>> CollectBatchAsync(
        List<(string ip, string point)> targets)
    {
        var tasks = targets.Select(async target =>
        {
            await _throttle.WaitAsync();
            try
            {
                var raw = await _client.ReadPointAsync(target.ip, target.point);
                return (target.point, value: ParseValue(raw));
            }
            catch (Exception ex)
            {
                // ✅ 返回错误标记,不抛异常,不让单个点位失败拖垮整批
                return (target.point, value: $"ERR:{ex.Message}");
            }
            finally
            {
                _throttle.Release();
            }
        });
        
        var results = await Task.WhenAll(tasks);
        return results.ToDictionary(r => r.point, r => r.value);
    }
}

关键点:SemaphoreSlim.WaitAsync 是异步等待,不会阻塞线程池线程。限流值 10 是实测出来的——HTTP 连接池上限(默认 ServicePointManager.DefaultConnectionLimit = 2,需要调到更大)。

3. 设置连接池上限

// Program.cs 或启动时配置
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
builder.Services.AddHttpClient("Device", client =>
{
    client.Timeout = TimeSpan.FromSeconds(5);
}).ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
    MaxConnectionsPerServer = 20  // 默认是 int.MaxValue,反而不好
});

踩坑记录

  • async void 是炸弹:最初的事件处理回调写成 private async void OnTimerElapsed(...),里面 await 的异常直接导致进程崩溃。改成 async Task,在 try-catch 里包住。如果框架必须用 async void(如 WinForms 事件),必须方法内最顶层 try-catch 全包。
  • .Result 和 .Wait() 制造假死锁:同步调异步方法时用 task.Result,导致线程池线程被阻塞,等 I/O 完成,而 I/O 完成的回调又需要线程池线程来执行——死锁。全链路异步是唯一解,从入口到底层不留同步桥。
  • Task.WhenAll 不等于无限并发:Task.WhenAll(tasks) 会同时启动所有 Task,没有内置限流。1000 个 Task 同时 await HttpClient,瞬间打满连接池。必须在外面套 SemaphoreSlim 或者用 Parallel.ForEachAsync(.NET 6+)。
  • ConfigureAwait(false) 的意义:在 ASP.NET Core 中没有 SynchronizationContext,ConfigureAwait(false) 基本无意义。但如果是类库代码、或跑在 WinForms/WPF 上,不加 ConfigureAwait(false) 会导致 await 之后试图回到 UI 线程执行后续代码,线程被占满时就是死锁。养成习惯,库代码一律加。
  • 超时取消要传 CancellationToken:HttpClient 的 Timeout 是全局的,但采集场景需要对不同设备设置不同超时。手动用 CancellationTokenSource:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var resp = await _client.GetAsync(url, cts.Token);
// 超时后会抛 TaskCanceledException,catch 里记录设备名和超时

调优效果

改造前后对比:

  • 单轮采集耗时:40s(同步)→ 3s(无限制异步)→ 6s(限流后,稳定)
  • 峰值线程数:无限制时线程池队列 2000+,限流后稳定在 15-20
  • 套接字状态:TIME_WAIT 连接从堆积 800+ 降到个位数
  • 运行稳定性:改造后连续运行 7 天无崩溃,之前平均 6 小时挂一次

限流后单轮采集从 3 秒变 6 秒,看着慢了,但换来的是稳定。MES 场景下,6 秒采集一轮远低于 15 秒工位节拍,完全够用。稳定比极限速度重要得多。

总结

async/await 是 .NET 并发编程的利器,但它不会自动帮你管理并发度。"无脑 WhenAll" 等于把同步代码的串行问题换成了异步代码的并发爆炸。三个原则记住了能避掉 90% 的坑:HttpClient 绝不 new(用 IHttpClientFactory),并发一定限流(SemaphoreSlim 或 Parallel.ForEachAsync),异步链路不出现 void 和 .Result。剩下的 10% 是 ConfigureContext 和 CancellationToken 的细节,写多了自然成肌肉记忆。

改造完那天,我把采集服务的日志级别从 Debug 调回 Info,终于不用盯日志了——不是因为问题没了,是因为知道它不会再因为并发问题在凌晨把我叫醒。

评论 (0)