C# async/await 在 MES 高并发采集中的并发陷阱与调优
背景:采集服务的并发膨胀
我们的 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,终于不用盯日志了——不是因为问题没了,是因为知道它不会再因为并发问题在凌晨把我叫醒。