引言:这个“最佳实践”到底过时了吗?
每隔一段时间,就会看到类似问题反复出现:
“都 .NET 10 了,HttpClient 还不能 using 吗?我每次请求 new HttpClient(),用完 Dispose(),不是很合理?”
这类问题之所以经久不衰,是因为它在低并发下几乎永远跑得通;但一旦进入“高并发 + 短连接密集创建”的场景,就会突然变成玄学:有的人能跑,有的人会炸,有人说这是一个这是一个“bug”,在某某版本中会修复(其实并没有),有人说这是一个feature,设计就是如此……
所以我决定做一个实验,来重现一下10年前就有的现象,看这些现象是否有任何不同。
本文用一组可复现的压测(同机 server/client,Windows,requests=20000,parallel=200)对比:
- 每请求 new HttpClient + Dispose()(也就是大家常说的“using 写法”)
- 复用一个 HttpClient(静态/单例)
- 使用 IHttpClientFactory
并观察关键指标:TIME_WAIT 数量,以及是否出现经典的端口耗尽错误:
通常每个套接字地址(协议/网络地址/端口)只允许使用一次。
实验目标
验证“每请求 new HttpClient 并 using 释放”在高并发下会导致 TIME_WAIT 激增,并对比复用 HttpClient / IHttpClientFactory 的表现。
实验环境与参数
<ul>OS: Windows
SDK: .NET SDK 10.0.102
服务器: HttpLeakServer(target net6,通过 roll-forward 运行)
客户端: net48 / net6 / net8 / net10
压测参数: requests=20000, parallel=200, timeoutSeconds=5
TIME_WAIT 统计: netstat -an 过滤端口 5055
隔离策略: 每轮结束后等待 TIME_WAIT { options.SingleLine = true; options.TimestampFormat = "HH:mm:ss ";});var app = builder.Build();app.MapGet("/", () => Results.Text("ok"));app.MapGet("/ping", () => Results.Text("ok"));app.MapGet("/slow", async () =>{ await Task.Delay(50); return Results.Text("ok");});var url = Environment.GetEnvironmentVariable("HTTPLEAK_URL") ?? "http://localhost:5055";app.Urls.Add(url);app.Lifetime.ApplicationStarted.Register(() =>{ Console.WriteLine($"Listening on {url}");});await app.RunAsync();[/code]客户端:HttpLeakClient(net48/net6/net8/net10 共用一份)
Clients/HttpLeakClient/HttpLeakClient.csproj(示意:多目标 + 条件依赖)
- dotnet run --project Server/HttpLeakServer/HttpLeakServer.csproj
复制代码 Clients/HttpLeakClient/Program.cs(用 #if 表示差异)
[code]using System.Diagnostics;using System.Net.Http;#if NET48using System.Net;#endif#if NET10_0_OR_GREATERusing Microsoft.Extensions.DependencyInjection;#endifstatic string? GetArg(string[] args, string name){ for (var i = 0; i < args.Length - 1; i++) { if (string.Equals(args, name, StringComparison.OrdinalIgnoreCase)) { return args[i + 1]; } } return null;}static int GetArgInt(string[] args, string name, int defaultValue){ var value = GetArg(args, name); return int.TryParse(value, out var parsed) ? parsed : defaultValue;}var url = GetArg(args, "--url") ?? "http://localhost:5055/ping";var requests = GetArgInt(args, "--requests", 20000);var parallel = GetArgInt(args, "--parallel", 200);var logEvery = GetArgInt(args, "--logEvery", 1000);var timeoutSeconds = GetArgInt(args, "--timeoutSeconds", 5);#if NET10_0_OR_GREATERvar mode = GetArg(args, "--mode") ?? "new"; // new | static | factory#endifConsole.WriteLine($"url={url}");#if NET10_0_OR_GREATERConsole.WriteLine($"requests={requests}, parallel={parallel}, timeoutSeconds={timeoutSeconds}, mode={mode}");#elseConsole.WriteLine($"requests={requests}, parallel={parallel}, timeoutSeconds={timeoutSeconds}");#endif#if NET48ServicePointManager.DefaultConnectionLimit = 1000;ServicePointManager.Expect100Continue = false;#endifvar throttler = new SemaphoreSlim(parallel);var tasks = new List(requests);var sw = Stopwatch.StartNew();var success = 0;var failed = 0;#if NET10_0_OR_GREATERHttpClient? staticClient = null;if (string.Equals(mode, "static", StringComparison.OrdinalIgnoreCase)){ staticClient = new HttpClient { Timeout = TimeSpan.FromSeconds(timeoutSeconds) };}IHttpClientFactory? httpClientFactory = null;ServiceProvider? serviceProvider = null;if (string.Equals(mode, "factory", StringComparison.OrdinalIgnoreCase)){ var services = new ServiceCollection(); services.AddHttpClient(); serviceProvider = services.BuildServiceProvider(); httpClientFactory = serviceProvider.GetRequiredService();}#endiffor (var i = 0; i < requests; i++){ await throttler.WaitAsync(); var index = i + 1; tasks.Add(Task.Run(async () => { try {#if NET10_0_OR_GREATER HttpClient client; if (staticClient != null) { client = staticClient; } else if (httpClientFactory != null) { client = httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(timeoutSeconds); } else { client = new HttpClient(); client.Timeout = TimeSpan.FromSeconds(timeoutSeconds); } using var response = await client.GetAsync(url); response.EnsureSuccessStatusCode(); Interlocked.Increment(ref success); if (staticClient == null && httpClientFactory == null) { client.Dispose(); }#else using var client = new HttpClient(); client.Timeout = TimeSpan.FromSeconds(timeoutSeconds); using var response = await client.GetAsync(url); response.EnsureSuccessStatusCode(); Interlocked.Increment(ref success);#endif } catch (Exception ex) { var fail = Interlocked.Increment(ref failed); if (fail |