.NET 10了,HttpClient还是不能用using吗?我做了一个实验

引言:这个"最佳实践"到底过时了吗?

每隔一段时间,就会看到类似问题反复出现:

"都 .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 的表现。


实验环境与参数

  • 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 <= baseline + 200(每 10 秒检查,最长 300 秒)

如何运行(可复现)

完整项目我放在 GitHub:https://github.com/sdcb/http-client-exp

1)启动服务端(单独窗口)

powershell 复制代码
dotnet run --project Server/HttpLeakServer/HttpLeakServer.csproj

2)运行实验脚本(另一个窗口)

powershell 复制代码
scripts/run-experiment-external-server.ps1

本次日志目录:logs/run-20260119-095017


解读:为什么"using HttpClient"会把你推向端口耗尽?

很多人直觉会觉得:HttpClient 是托管对象,用完 Dispose(),不就释放资源了吗?

但这里有两个关键点经常被忽略:

  1. HttpClient 并不是"请求一次就关一次连接"的简单模型。HTTP Keep-Alive + 连接池的存在,意味着正确姿势应该是复用底层连接(或至少复用 handler 的连接池),让大量请求复用少量 TCP 连接。
  2. 你频繁 new HttpClient + Dispose(),等价于频繁建立 TCP 连接并快速关闭 。而 TCP 连接的关闭会进入 TIME_WAIT(具体哪一端进入 TIME_WAIT 与关闭时序有关),TIME_WAIT 存在的意义是保护网络不被"旧连接的残留包"污染。

在"200 并发 + 2 万次请求"这种参数下,如果你让每个请求都创建新连接,那么很容易短时间制造大量 TIME_WAIT;一旦本机可用的临时端口范围被 TIME_WAIT 占满(或接近占满),新连接就会开始失败,典型异常就是:

通常每个套接字地址(协议/网络地址/端口)只允许使用一次。

这也是为什么同样的代码:

  • 在低并发下"看起来完全没问题"
  • 在压力一上来就开始"玄学报错"

那到底怎么写才对?

本文不展开"所有场景的最佳实践",只给两条最能落地的结论:

  1. 业务代码不要每次请求 new HttpClient 。要么复用单例/静态 HttpClient,要么使用 IHttpClientFactory
  2. 可以 using 的是 HttpResponseMessage / HttpContent(它们确实应该及时释放),而不是"每个请求一个 HttpClient"。

下面我把这次实验的完整代码原始日志全部贴出来,方便你自己复跑/改参数/做二次验证。


完整代码(精简版:一份源码 + 条件编译)

完整项目地址:https://github.com/sdcb/http-client-exp

为了避免同样的代码贴四遍,这里把 net48/net6/net8/net10 的客户端合并成一份 ,用 #if / #elif 表示差异(实验输出与原始日志仍按"多份结果"原样保留在后文)。

简单服务端:HttpLeakServer

Server/HttpLeakServer/HttpLeakServer.csproj

xml 复制代码
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Server/HttpLeakServer/Program.cs

csharp 复制代码
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = WebApplication.CreateBuilder(args);

builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(options =>
{
    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();

客户端:HttpLeakClient(net48/net6/net8/net10 共用一份)

Clients/HttpLeakClient/HttpLeakClient.csproj(示意:多目标 + 条件依赖)

xml 复制代码
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>net48;net6.0;net8.0;net10.0</TargetFrameworks>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup Condition="'$(TargetFramework)' == 'net48'">
    <Reference Include="System.Net.Http" />
  </ItemGroup>

  <ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
    <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
  </ItemGroup>
</Project>

Clients/HttpLeakClient/Program.cs(用 #if 表示差异)

csharp 复制代码
using System.Diagnostics;
using System.Net.Http;

#if NET48
using System.Net;
#endif

#if NET10_0_OR_GREATER
using Microsoft.Extensions.DependencyInjection;
#endif

static string? GetArg(string[] args, string name)
{
    for (var i = 0; i < args.Length - 1; i++)
    {
        if (string.Equals(args[i], 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_GREATER
var mode = GetArg(args, "--mode") ?? "new"; // new | static | factory
#endif

Console.WriteLine($"url={url}");
#if NET10_0_OR_GREATER
Console.WriteLine($"requests={requests}, parallel={parallel}, timeoutSeconds={timeoutSeconds}, mode={mode}");
#else
Console.WriteLine($"requests={requests}, parallel={parallel}, timeoutSeconds={timeoutSeconds}");
#endif

#if NET48
ServicePointManager.DefaultConnectionLimit = 1000;
ServicePointManager.Expect100Continue = false;
#endif

var throttler = new SemaphoreSlim(parallel);
var tasks = new List<Task>(requests);
var sw = Stopwatch.StartNew();
var success = 0;
var failed = 0;

#if NET10_0_OR_GREATER
HttpClient? 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<IHttpClientFactory>();
}
#endif

for (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 <= 5)
            {
                Console.WriteLine($"ERR#{fail}: {ex.GetType().Name} {ex.Message}");
            }
        }
        finally
        {
            throttler.Release();
        }
    }));

    if (index % logEvery == 0)
    {
        Console.WriteLine($"queued: {index}/{requests}, success: {Volatile.Read(ref success)}, failed: {Volatile.Read(ref failed)}, elapsed: {sw.Elapsed}");
    }
}

await Task.WhenAll(tasks);
Console.WriteLine($"done: success={success}, failed={failed}, elapsed={sw.Elapsed}");

#if NET10_0_OR_GREATER
staticClient?.Dispose();
serviceProvider?.Dispose();
#endif

PowerShell 脚本

脚本我就不在文章里全文贴了:

  • 外部启动 Server 版本:https://github.com/sdcb/http-client-exp/blob/main/scripts/run-experiment-external-server.ps1
  • 脚本内启动 Server 版本:https://github.com/sdcb/http-client-exp/blob/main/scripts/run-experiment.ps1

原始实验结果(日志,完整贴出)

日志目录:logs/run-20260119-095017

experiment-summary.log

text 复制代码
[2026-01-19T09:51:13.7180857+08:00] net48 ExitCode=0 OutLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net48.out.log ErrLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net48.err.log
[2026-01-19T09:53:33.0362523+08:00] net6 ExitCode=0 OutLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net6.out.log ErrLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net6.err.log
[2026-01-19T09:56:00.4574527+08:00] net8 ExitCode=0 OutLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net8.out.log ErrLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net8.err.log
[2026-01-19T09:58:27.2618139+08:00] net10-new ExitCode=0 OutLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net10-new.out.log ErrLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net10-new.err.log
[2026-01-19T10:00:53.0490472+08:00] net10-static ExitCode=0 OutLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net10-static.out.log ErrLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net10-static.err.log
[2026-01-19T10:01:10.2096164+08:00] net10-factory ExitCode=0 OutLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net10-factory.out.log ErrLog=C:\Users\ZhouJie\source\repos\http-client-exp\logs\\run-20260119-095017\net10-factory.err.log

netstat.log

text 复制代码
[2026-01-19T09:50:55.6627455+08:00] before-net48 TIME_WAIT=2 ESTABLISHED=0 CLOSE_WAIT=0
[2026-01-19T09:51:15.4686603+08:00] after-net48 TIME_WAIT=20002 ESTABLISHED=0 CLOSE_WAIT=0
[2026-01-19T09:53:13.9647485+08:00] before-net6 TIME_WAIT=0 ESTABLISHED=0 CLOSE_WAIT=0
[2026-01-19T09:53:33.8112294+08:00] after-net6 TIME_WAIT=20000 ESTABLISHED=0 CLOSE_WAIT=0
[2026-01-19T09:55:42.6435015+08:00] before-net8 TIME_WAIT=0 ESTABLISHED=0 CLOSE_WAIT=0
[2026-01-19T09:56:01.1900000+08:00] after-net8 TIME_WAIT=18361 ESTABLISHED=0 CLOSE_WAIT=0
[2026-01-19T09:58:08.8603129+08:00] before-net10-new TIME_WAIT=0 ESTABLISHED=0 CLOSE_WAIT=0
[2026-01-19T09:58:28.1500737+08:00] after-net10-new TIME_WAIT=18860 ESTABLISHED=0 CLOSE_WAIT=0
[2026-01-19T10:00:36.1038757+08:00] before-net10-static TIME_WAIT=0 ESTABLISHED=0 CLOSE_WAIT=0
[2026-01-19T10:00:53.1007259+08:00] after-net10-static TIME_WAIT=200 ESTABLISHED=0 CLOSE_WAIT=0
[2026-01-19T10:00:53.2458798+08:00] before-net10-factory TIME_WAIT=200 ESTABLISHED=0 CLOSE_WAIT=0
[2026-01-19T10:01:10.2574678+08:00] after-net10-factory TIME_WAIT=200 ESTABLISHED=0 CLOSE_WAIT=0

cooldown.log

text 复制代码
[2026-01-19T09:51:16.2687666+08:00] cooldown-net48 TIME_WAIT=20002 baseline=2 limit=202
[2026-01-19T09:51:27.0427805+08:00] cooldown-net48 TIME_WAIT=20002 baseline=2 limit=202
[2026-01-19T09:51:37.8184382+08:00] cooldown-net48 TIME_WAIT=20002 baseline=2 limit=202
[2026-01-19T09:51:48.5404492+08:00] cooldown-net48 TIME_WAIT=20002 baseline=2 limit=202
[2026-01-19T09:51:59.2846968+08:00] cooldown-net48 TIME_WAIT=20002 baseline=2 limit=202
[2026-01-19T09:52:10.1562079+08:00] cooldown-net48 TIME_WAIT=20002 baseline=2 limit=202
[2026-01-19T09:52:21.0588622+08:00] cooldown-net48 TIME_WAIT=20002 baseline=2 limit=202
[2026-01-19T09:52:31.7777704+08:00] cooldown-net48 TIME_WAIT=20002 baseline=2 limit=202
[2026-01-19T09:52:42.5664350+08:00] cooldown-net48 TIME_WAIT=20002 baseline=2 limit=202
[2026-01-19T09:52:53.3299614+08:00] cooldown-net48 TIME_WAIT=20001 baseline=2 limit=202
[2026-01-19T09:53:03.8109734+08:00] cooldown-net48 TIME_WAIT=10752 baseline=2 limit=202
[2026-01-19T09:53:13.8560831+08:00] cooldown-net48 TIME_WAIT=0 baseline=2 limit=202
[2026-01-19T09:53:34.6370711+08:00] cooldown-net6 TIME_WAIT=20000 baseline=0 limit=200
[2026-01-19T09:53:45.5166974+08:00] cooldown-net6 TIME_WAIT=20000 baseline=0 limit=200
[2026-01-19T09:53:56.2899736+08:00] cooldown-net6 TIME_WAIT=20000 baseline=0 limit=200
[2026-01-19T09:54:07.0165428+08:00] cooldown-net6 TIME_WAIT=20000 baseline=0 limit=200
[2026-01-19T09:54:17.8872137+08:00] cooldown-net6 TIME_WAIT=20000 baseline=0 limit=200
[2026-01-19T09:54:28.7673529+08:00] cooldown-net6 TIME_WAIT=20000 baseline=0 limit=200
[2026-01-19T09:54:39.5200091+08:00] cooldown-net6 TIME_WAIT=20000 baseline=0 limit=200
[2026-01-19T09:54:50.3090659+08:00] cooldown-net6 TIME_WAIT=20000 baseline=0 limit=200
[2026-01-19T09:55:01.0638920+08:00] cooldown-net6 TIME_WAIT=20000 baseline=0 limit=200
[2026-01-19T09:55:11.7529528+08:00] cooldown-net6 TIME_WAIT=20000 baseline=0 limit=200
[2026-01-19T09:55:22.3886445+08:00] cooldown-net6 TIME_WAIT=12540 baseline=0 limit=200
[2026-01-19T09:55:32.4658786+08:00] cooldown-net6 TIME_WAIT=435 baseline=0 limit=200
[2026-01-19T09:55:42.5354878+08:00] cooldown-net6 TIME_WAIT=0 baseline=0 limit=200
[2026-01-19T09:56:01.8876493+08:00] cooldown-net8 TIME_WAIT=18361 baseline=0 limit=200
[2026-01-19T09:56:12.6341541+08:00] cooldown-net8 TIME_WAIT=18361 baseline=0 limit=200
[2026-01-19T09:56:23.2806604+08:00] cooldown-net8 TIME_WAIT=18361 baseline=0 limit=200
[2026-01-19T09:56:33.9425873+08:00] cooldown-net8 TIME_WAIT=18361 baseline=0 limit=200
[2026-01-19T09:56:44.6277561+08:00] cooldown-net8 TIME_WAIT=18361 baseline=0 limit=200
[2026-01-19T09:56:55.3013674+08:00] cooldown-net8 TIME_WAIT=18361 baseline=0 limit=200
[2026-01-19T09:57:06.0278128+08:00] cooldown-net8 TIME_WAIT=18361 baseline=0 limit=200
[2026-01-19T09:57:16.7315578+08:00] cooldown-net8 TIME_WAIT=18361 baseline=0 limit=200
[2026-01-19T09:57:27.3887925+08:00] cooldown-net8 TIME_WAIT=18361 baseline=0 limit=200
[2026-01-19T09:57:38.0442453+08:00] cooldown-net8 TIME_WAIT=18361 baseline=0 limit=200
[2026-01-19T09:57:48.6566839+08:00] cooldown-net8 TIME_WAIT=12989 baseline=0 limit=200
[2026-01-19T09:57:58.7347337+08:00] cooldown-net8 TIME_WAIT=1069 baseline=0 limit=200
[2026-01-19T09:58:08.7780660+08:00] cooldown-net8 TIME_WAIT=0 baseline=0 limit=200
[2026-01-19T09:58:29.0261291+08:00] cooldown-net10-new TIME_WAIT=18860 baseline=0 limit=200
[2026-01-19T09:58:39.7491043+08:00] cooldown-net10-new TIME_WAIT=18860 baseline=0 limit=200
[2026-01-19T09:58:50.4613796+08:00] cooldown-net10-new TIME_WAIT=18860 baseline=0 limit=200
[2026-01-19T09:59:01.2064545+08:00] cooldown-net10-new TIME_WAIT=18860 baseline=0 limit=200
[2026-01-19T09:59:11.8710153+08:00] cooldown-net10-new TIME_WAIT=18860 baseline=0 limit=200
[2026-01-19T09:59:22.5648486+08:00] cooldown-net10-new TIME_WAIT=18860 baseline=0 limit=200
[2026-01-19T09:59:33.2755958+08:00] cooldown-net10-new TIME_WAIT=18860 baseline=0 limit=200
[2026-01-19T09:59:43.9631547+08:00] cooldown-net10-new TIME_WAIT=18860 baseline=0 limit=200
[2026-01-19T09:59:54.6979243+08:00] cooldown-net10-new TIME_WAIT=18860 baseline=0 limit=200
[2026-01-19T10:00:05.3961397+08:00] cooldown-net10-new TIME_WAIT=18860 baseline=0 limit=200
[2026-01-19T10:00:15.8607378+08:00] cooldown-net10-new TIME_WAIT=11840 baseline=0 limit=200
[2026-01-19T10:00:25.9471407+08:00] cooldown-net10-new TIME_WAIT=614 baseline=0 limit=200
[2026-01-19T10:00:36.0111701+08:00] cooldown-net10-new TIME_WAIT=0 baseline=0 limit=200
[2026-01-19T10:00:53.1510649+08:00] cooldown-net10-static TIME_WAIT=200 baseline=0 limit=200
[2026-01-19T10:01:10.3057785+08:00] cooldown-net10-factory TIME_WAIT=200 baseline=200 limit=400

net48.out.log

text 复制代码
url=http://localhost:5055/ping
requests=20000, parallel=200, timeoutSeconds=5
queued: 1000/20000, success: 803, failed: 0, elapsed: 00:00:00.7170409
queued: 2000/20000, success: 1803, failed: 0, elapsed: 00:00:01.5455465
queued: 3000/20000, success: 2800, failed: 0, elapsed: 00:00:02.3559601
queued: 4000/20000, success: 3801, failed: 0, elapsed: 00:00:03.1925172
queued: 5000/20000, success: 4802, failed: 0, elapsed: 00:00:04.0184093
queued: 6000/20000, success: 5802, failed: 0, elapsed: 00:00:04.8348862
queued: 7000/20000, success: 6800, failed: 0, elapsed: 00:00:05.6775563
queued: 8000/20000, success: 7801, failed: 0, elapsed: 00:00:06.5458377
queued: 9000/20000, success: 8802, failed: 0, elapsed: 00:00:07.4397727
queued: 10000/20000, success: 9803, failed: 0, elapsed: 00:00:08.3348468
queued: 11000/20000, success: 10802, failed: 0, elapsed: 00:00:09.2035416
queued: 12000/20000, success: 11804, failed: 0, elapsed: 00:00:10.0393223
queued: 13000/20000, success: 12806, failed: 0, elapsed: 00:00:10.8262272
queued: 14000/20000, success: 13804, failed: 0, elapsed: 00:00:11.6636597
queued: 15000/20000, success: 14802, failed: 0, elapsed: 00:00:12.4799716
queued: 16000/20000, success: 15819, failed: 0, elapsed: 00:00:13.4697834
queued: 17000/20000, success: 16822, failed: 0, elapsed: 00:00:14.5332049
queued: 18000/20000, success: 17818, failed: 0, elapsed: 00:00:15.5317397
queued: 19000/20000, success: 18816, failed: 0, elapsed: 00:00:16.5405963
queued: 20000/20000, success: 19819, failed: 0, elapsed: 00:00:17.6652081
done: success=20000, failed=0, elapsed=00:00:17.7541839

net6.out.log

text 复制代码
url=http://localhost:5055/ping
requests=20000, parallel=200, timeoutSeconds=5
queued: 1000/20000, success: 803, failed: 0, elapsed: 00:00:00.7159800
queued: 2000/20000, success: 1803, failed: 0, elapsed: 00:00:01.6028433
queued: 3000/20000, success: 2802, failed: 0, elapsed: 00:00:02.4876237
queued: 4000/20000, success: 3802, failed: 0, elapsed: 00:00:03.3549543
queued: 5000/20000, success: 4802, failed: 0, elapsed: 00:00:04.2710795
queued: 6000/20000, success: 5801, failed: 0, elapsed: 00:00:05.1637653
queued: 7000/20000, success: 6802, failed: 0, elapsed: 00:00:06.0729777
queued: 8000/20000, success: 7802, failed: 0, elapsed: 00:00:07.0697555
queued: 9000/20000, success: 8801, failed: 0, elapsed: 00:00:08.0143162
queued: 10000/20000, success: 9800, failed: 0, elapsed: 00:00:08.9633860
queued: 11000/20000, success: 10802, failed: 0, elapsed: 00:00:09.9344239
queued: 12000/20000, success: 11802, failed: 0, elapsed: 00:00:10.8379783
queued: 13000/20000, success: 12801, failed: 0, elapsed: 00:00:11.6810601
queued: 14000/20000, success: 13801, failed: 0, elapsed: 00:00:12.5122642
queued: 15000/20000, success: 14800, failed: 0, elapsed: 00:00:13.3692282
queued: 16000/20000, success: 15803, failed: 0, elapsed: 00:00:14.2782829
queued: 17000/20000, success: 16801, failed: 0, elapsed: 00:00:15.2187642
queued: 18000/20000, success: 17802, failed: 0, elapsed: 00:00:16.0811154
queued: 19000/20000, success: 18810, failed: 0, elapsed: 00:00:16.9798536
queued: 20000/20000, success: 19817, failed: 0, elapsed: 00:00:17.8952478
done: success=20000, failed=0, elapsed=00:00:18.0175351

net8.out.log

text 复制代码
url=http://localhost:5055/ping
requests=20000, parallel=200, timeoutSeconds=5
queued: 1000/20000, success: 800, failed: 0, elapsed: 00:00:00.8405997
queued: 2000/20000, success: 1802, failed: 0, elapsed: 00:00:01.6913251
queued: 3000/20000, success: 2800, failed: 0, elapsed: 00:00:02.6176324
queued: 4000/20000, success: 3802, failed: 0, elapsed: 00:00:03.4744051
queued: 5000/20000, success: 4801, failed: 0, elapsed: 00:00:04.2873345
queued: 6000/20000, success: 5800, failed: 0, elapsed: 00:00:05.0960137
queued: 7000/20000, success: 6803, failed: 0, elapsed: 00:00:05.8940500
queued: 8000/20000, success: 7802, failed: 0, elapsed: 00:00:06.7207568
queued: 9000/20000, success: 8803, failed: 0, elapsed: 00:00:07.5346954
queued: 10000/20000, success: 9800, failed: 0, elapsed: 00:00:08.3507870
queued: 11000/20000, success: 10802, failed: 0, elapsed: 00:00:09.1985154
queued: 12000/20000, success: 11803, failed: 0, elapsed: 00:00:10.0035216
queued: 13000/20000, success: 12803, failed: 0, elapsed: 00:00:10.7763067
queued: 14000/20000, success: 13802, failed: 0, elapsed: 00:00:11.6251384
queued: 15000/20000, success: 14802, failed: 0, elapsed: 00:00:12.4436652
queued: 16000/20000, success: 15807, failed: 0, elapsed: 00:00:13.3164599
queued: 17000/20000, success: 16801, failed: 0, elapsed: 00:00:14.2231530
ERR#1: HttpRequestException 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。 (localhost:5055)
ERR#2: HttpRequestException 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。 (localhost:5055)
ERR#3: HttpRequestException 通常每个套接���地址(协议/网络地址/端口)只允许使用一次。 (localhost:5055)
ERR#5: HttpRequestException 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。 (localhost:5055)
ERR#4: HttpRequestException 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。 (localhost:5055)
queued: 18000/20000, success: 17605, failed: 201, elapsed: 00:00:15.0862152
queued: 19000/20000, success: 17711, failed: 1090, elapsed: 00:00:15.6168905
queued: 20000/20000, success: 18165, failed: 1639, elapsed: 00:00:16.3332350
done: success=18361, failed=1639, elapsed=00:00:16.4268168

net10-new.out.log

text 复制代码
url=http://localhost:5055/ping
requests=20000, parallel=200, timeoutSeconds=5, mode=new
queued: 1000/20000, success: 805, failed: 0, elapsed: 00:00:00.7099803
queued: 2000/20000, success: 1800, failed: 0, elapsed: 00:00:01.5324361
queued: 3000/20000, success: 2800, failed: 0, elapsed: 00:00:02.3573877
queued: 4000/20000, success: 3800, failed: 0, elapsed: 00:00:03.2069000
queued: 5000/20000, success: 4800, failed: 0, elapsed: 00:00:04.0313423
queued: 6000/20000, success: 5802, failed: 0, elapsed: 00:00:04.8687039
queued: 7000/20000, success: 6801, failed: 0, elapsed: 00:00:05.7252572
queued: 8000/20000, success: 7800, failed: 0, elapsed: 00:00:06.5624078
queued: 9000/20000, success: 8800, failed: 0, elapsed: 00:00:07.4244971
queued: 10000/20000, success: 9800, failed: 0, elapsed: 00:00:08.2911306
queued: 11000/20000, success: 10800, failed: 0, elapsed: 00:00:09.1755667
queued: 12000/20000, success: 11801, failed: 0, elapsed: 00:00:10.1160925
queued: 13000/20000, success: 12802, failed: 0, elapsed: 00:00:11.0165038
queued: 14000/20000, success: 13801, failed: 0, elapsed: 00:00:11.9382103
queued: 15000/20000, success: 14802, failed: 0, elapsed: 00:00:12.8002543
queued: 16000/20000, success: 15801, failed: 0, elapsed: 00:00:13.7185127
ERR#3: HttpRequestException 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。 (localhost:5055)
ERR#2: HttpRequestException 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。 (localhost:5055)
ERR#1: HttpRequestException 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。 (localhost:5055)
ERR#5: HttpRequestException 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。 (localhost:5055)
ERR#4: HttpRequestException 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。 (localhost:5055)
queued: 17000/20000, success: 16682, failed: 118, elapsed: 00:00:14.7578539
queued: 18000/20000, success: 16684, failed: 1118, elapsed: 00:00:15.4297844
queued: 19000/20000, success: 17661, failed: 1140, elapsed: 00:00:16.2796589
queued: 20000/20000, success: 18661, failed: 1140, elapsed: 00:00:17.3258406
done: success=18860, failed=1140, elapsed=00:00:17.3996862

net10-static.out.log

text 复制代码
url=http://localhost:5055/ping
requests=20000, parallel=200, timeoutSeconds=5, mode=static
queued: 1000/20000, success: 801, failed: 0, elapsed: 00:00:00.6551470
queued: 2000/20000, success: 1800, failed: 0, elapsed: 00:00:01.5355394
queued: 3000/20000, success: 2800, failed: 0, elapsed: 00:00:02.3480456
queued: 4000/20000, success: 3800, failed: 0, elapsed: 00:00:03.0999050
queued: 5000/20000, success: 4800, failed: 0, elapsed: 00:00:03.8470820
queued: 6000/20000, success: 5800, failed: 0, elapsed: 00:00:04.5759884
queued: 7000/20000, success: 6800, failed: 0, elapsed: 00:00:05.3461919
queued: 8000/20000, success: 7801, failed: 0, elapsed: 00:00:06.0916621
queued: 9000/20000, success: 8802, failed: 0, elapsed: 00:00:06.9085343
queued: 10000/20000, success: 9800, failed: 0, elapsed: 00:00:07.8274125
queued: 11000/20000, success: 10800, failed: 0, elapsed: 00:00:08.6757231
queued: 12000/20000, success: 11800, failed: 0, elapsed: 00:00:09.5154490
queued: 13000/20000, success: 12800, failed: 0, elapsed: 00:00:10.3306765
queued: 14000/20000, success: 13800, failed: 0, elapsed: 00:00:11.1493724
queued: 15000/20000, success: 14800, failed: 0, elapsed: 00:00:11.9658212
queued: 16000/20000, success: 15801, failed: 0, elapsed: 00:00:12.7510706
queued: 17000/20000, success: 16800, failed: 0, elapsed: 00:00:13.4735304
queued: 18000/20000, success: 17800, failed: 0, elapsed: 00:00:14.2777953
queued: 19000/20000, success: 18800, failed: 0, elapsed: 00:00:15.0219907
queued: 20000/20000, success: 19801, failed: 0, elapsed: 00:00:15.7699702
done: success=20000, failed=0, elapsed=00:00:15.8507052

net10-factory.out.log

text 复制代码
url=http://localhost:5055/ping
requests=20000, parallel=200, timeoutSeconds=5, mode=factory
queued: 1000/20000, success: 801, failed: 0, elapsed: 00:00:00.7094395
queued: 2000/20000, success: 1801, failed: 0, elapsed: 00:00:01.5072383
queued: 3000/20000, success: 2800, failed: 0, elapsed: 00:00:02.3047647
queued: 4000/20000, success: 3800, failed: 0, elapsed: 00:00:03.0607252
queued: 5000/20000, success: 4800, failed: 0, elapsed: 00:00:03.8370598
queued: 6000/20000, success: 5800, failed: 0, elapsed: 00:00:04.6621606
queued: 7000/20000, success: 6800, failed: 0, elapsed: 00:00:05.4589104
queued: 8000/20000, success: 7800, failed: 0, elapsed: 00:00:06.2913588
queued: 9000/20000, success: 8800, failed: 0, elapsed: 00:00:07.0629536
queued: 10000/20000, success: 9800, failed: 0, elapsed: 00:00:07.8438472
queued: 11000/20000, success: 10800, failed: 0, elapsed: 00:00:08.5796209
queued: 12000/20000, success: 11800, failed: 0, elapsed: 00:00:09.3663975
queued: 13000/20000, success: 12801, failed: 0, elapsed: 00:00:10.1984757
queued: 14000/20000, success: 13800, failed: 0, elapsed: 00:00:11.0474925
queued: 15000/20000, success: 14800, failed: 0, elapsed: 00:00:11.9175753
queued: 16000/20000, success: 15800, failed: 0, elapsed: 00:00:12.7356429
queued: 17000/20000, success: 16801, failed: 0, elapsed: 00:00:13.4991148
queued: 18000/20000, success: 17800, failed: 0, elapsed: 00:00:14.2912941
queued: 19000/20000, success: 18800, failed: 0, elapsed: 00:00:15.0844828
queued: 20000/20000, success: 19800, failed: 0, elapsed: 00:00:15.8439387
done: success=20000, failed=0, elapsed=00:00:15.9485251

局限与备注(别把结论用错地方)

  1. 客户端与服务器同机:TIME_WAIT 统计包含两端连接,不能完全归因于"客户端端口耗尽",但它足以说明"短时间制造大量短连接"这件事本身的风险。
  2. net48 设置了 ServicePointManager.DefaultConnectionLimit = 1000,与 net6+/net10 的连接管理策略存在差异。

关键结果汇总

TIME_WAIT 统计(端口 5055)

运行 before TIME_WAIT after TIME_WAIT 耗时(秒)
net48 2 20002 17.754
net6 0 20000 18.018
net8 0 18361 16.427
net10 0 18860 17.400
net10-static 0 200 15.851
net10-factory 200 200 15.949


来源:logs/run-20260119-095017/netstat.log

客户端执行结果(摘要)

  • net48: success=20000, failed=0
  • net6: success=20000, failed=0
  • net8: success=18361, failed=1639(报错:"通常每个套接字地址只允许使用一次")
  • net10-new: success=18860, failed=1140(报错:"通常每个套接字地址只允许使用一次")
  • net10-static: success=20000, failed=0
  • net10-factory: success=20000, failed=0

来源:logs/run-20260119-095017/*.out.log


结论

".NET 10 了,HttpClient 还能不能 using?"------答案依然是:别把 HttpClient 当成一次性对象

你可以 using 的是请求/响应相关的对象(例如 HttpResponseMessage),但 HttpClient 本身更像一个"连接池的门面":它越复用,越稳定,越不容易把你推向 TIME_WAIT 地狱。

另外,从本次压测耗时来看,不做"每次请求都 using/new HttpClient" 的写法,速度其实还会稍微快一丢丢(当然差距很小)。

感谢阅读!如果你觉得这些实验分析有意思,或者对 .NET 高性能编程感兴趣,欢迎在评论区留言交流,也欢迎加入我的 .NET骚操作 QQ群:495782587,我们一起探索更多技术硬核玩法。