一句话结论:目标域名同时有 AAAA 与 A 记录、且 AAAA 排在前面,而机器所在网络的 IPv6 出口是静默丢包。
.NET 10 及更早版本的HttpClient会按 DNS 顺序逐个地址尝试,于是每个 IPv6 地址都要等满操作系统的 TCP 连接超时(Windows 约 21 秒)。curl与浏览器实现了 RFC 8305 Happy Eyeballs(两族地址并行竞速),所以同样一条链接它们毫秒级返回。本文包含:现象与实测数据、四步定位手法、三种解决方案(含可直接复制的
ConnectCallback实现),
以及 .NET 11(2026-09 为 RC1)相关 API 的现状调查。
实测环境:Windows,双栈网络(机器拿得到全局 IPv6 地址与 IPv6 默认路由,但上游 IPv6 不通),测试时间 2026-09。
1. 现象
一段最普通的取图代码:
var handler = new HttpClientHandler
{
UseProxy = false,
Proxy = null,
};
using var http = new HttpClient(handler)
{
Timeout = TimeSpan.FromMinutes(5)
};
var sw = Stopwatch.StartNew();
using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
Console.WriteLine($"建连+首包耗时: {sw.ElapsedMilliseconds} ms");
同一条 HTTPS 链接(公开 CDN 上的图片资源):
| 客户端 | 结果 |
|---|---|
浏览器 / curl | 约 0.4 秒建连,1.2 秒拿到首包 |
上面这段 HttpClient(.NET 8) | 64106 ms |
| 同一份代码复现(.NET 8) | 169203 ms |
| 同一份代码复现(.NET 10) | 168764 ms |
两个容易带偏方向的线索:
Timeout设成 5 分钟不起作用——这不是超时,而是每次都把操作系统的 TCP 连接超时等满。- 耗时与响应体大小无关,
ResponseHeadersRead也一样,因为时间全花在建立连接上。
2. 四步定位
2.1 先看 DNS 顺序
双栈域名会同时返回 AAAA(IPv6)与 A(IPv4),而且 AAAA 通常排在前面。同一个域名在不同时刻解析结果会轮换,
实测出现过下面几种组合(地址条数直接影响卡顿时长):
8 × AAAA + 1 × A
3 × AAAA + 1 × A
8 × AAAA + 8 × A
Resolve-DnsName <测试域名> | Format-Table Name, Type, IPAddress
[System.Net.Dns]::GetHostAddresses('<测试域名>') # 这就是 .NET 建连时看到的地址顺序
2.2 再看 IPv6 到底通不通
curl.exe -sS -o NUL -w "ok connect=%{time_connect} total=%{time_total}`n" "https://<测试域名>/"
curl.exe -6 -sS --connect-timeout 5 -o NUL -w "v6 connect=%{time_connect} total=%{time_total}`n" "https://<测试域名>/"
第二条(强制 IPv6)实测输出:
curl: (28) Connection timed out after 5010 milliseconds
关键点:这是静默丢包——不回 ICMP、不 RST,所以不会”快速失败”,只能等超时。
同一环境下另外测试的多个公共站点(不同运营商、不同 CDN)IPv6 也全部超时,说明这不是某个网站的问题,
而是整条 IPv6 出口不通。典型的”路由器通告了 IPv6 前缀,但上游没有真正打通 IPv6″。
2.3 量一下单次连接超时
对某个 AAAA 地址直接发起 TCP 连接,不给超时,等系统放弃:
var sw = Stopwatch.StartNew();
using var socket = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp);
try
{
await socket.ConnectAsync(new IPEndPoint(IPAddress.Parse("<某个 IPv6 地址>"), 443), CancellationToken.None);
}
catch (Exception ex)
{
Console.WriteLine($"{sw.ElapsedMilliseconds} ms 后失败: {ex.GetType().Name}");
}
实测:21232 ms。这个数字来自 SYN 重传节奏(3s / 6s / 12s),是 Windows 上单次 TCP 连接超时的默认量级。
每个不可达地址都要付一次这个代价,所以”AAAA 排前面”就变成了乘法:地址数 × 21 秒。
169203 ms 正好是 8 个 AAAA 地址各等 21.2 秒;最初观察到的 64106 ms 则是当时 DNS 只回了 3 个 AAAA。
2.4 对比 curl:它走了哪个地址、等了多久
curl.exe -sS -o NUL -w "remote_ip=%{remote_ip}`nconnect=%{time_connect}`nappconnect=%{time_appconnect}`ntotal=%{time_total}`n" "https://<测试域名>/"
实测输出:
remote_ip=<公网 IPv4 地址>
connect=0.420332
appconnect=0.852330
total=1.238131
IPv6 明明不通,connect 却只有 0.42 秒——因为 curl 同时在试 IPv4,谁先连上就用谁。
可用 curl --happy-eyeballs-timeout-ms 调整这个竞速间隔。浏览器、Go、Java 也是同样的做法。
3. 原理小结
四个事实叠加成这个故障:
- 域名是双栈的,AAAA 排在 A 前面;
- 所在网络的 IPv6 出口丢包而不是拒绝(拒绝会立刻失败,丢包只能等超时);
- 操作系统的单次 TCP 连接超时约 21 秒;
- .NET 10 及更早版本的
SocketsHttpHandler按 DNS 顺序逐个尝试,没有 Happy Eyeballs。
RFC 8305(Happy Eyeballs v2)的要点正是打破第 4 条:
- 两族地址交错排序(IPv6、IPv4、IPv6、IPv4……);
- 每次尝试之间留一个短间隔(Connection Attempt Delay,建议 250 ms),不要等前一个彻底失败;
- 第一个成功的连接胜出,其余立刻放弃。
4. 解决方案
4.1 进程级关闭 IPv6(最快止血)
AppContext.SetSwitch("System.Net.DisableIPv6", true); // 必须在首次使用网络栈之前设置
等价做法:
$env:DOTNET_SYSTEM_NET_DISABLEIPV6 = "1"
<ItemGroup>
<RuntimeHostConfigurationOption Include="System.Net.DisableIPv6" Value="true" Trim="true" />
</ItemGroup>
该开关是框架原生支持的(字符串存在于 System.Net.NameResolution.dll、System.Net.Sockets.dll),
开启后 Dns 会直接丢弃 AAAA 记录。实测同一条链接:873 ms。
代价是整个进程都不再使用 IPv6。在网络本身 IPv6 不通的环境下这是纯收益;但将来网络修好时,
进程会失去 IPv6 能力,所以它属于”立刻止血”而不是长期方案。
4.2 ConnectCallback 实现 RFC 8305(推荐)
SocketsHttpHandler.ConnectCallback(.NET 5+)允许接管 TCP 建连,正好用来实现竞速:
var handler = new SocketsHttpHandler
{
UseProxy = false,
Proxy = null,
ConnectCallback = HappyEyeballs.ConnectAsync
};
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(5) };
完整实现(可直接复制,C# 9 / .NET 5 及以上):
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
/// <summary>RFC 8305 Happy Eyeballs:两族地址交错竞速建连,用作 SocketsHttpHandler.ConnectCallback。</summary>
internal static class HappyEyeballs
{
/// <summary>相邻两次连接尝试的间隔,RFC 8305 建议 250 毫秒。</summary>
public static readonly TimeSpan AttemptDelay = TimeSpan.FromMilliseconds(250);
/// <summary>ConnectCallback 入口:解析域名后按 Happy Eyeballs 策略建连。</summary>
public static async ValueTask<Stream> ConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
{
if (context == null) throw new ArgumentNullException(nameof(context));
var endpoint = context.DnsEndPoint;
var addresses = await Dns.GetHostAddressesAsync(endpoint.Host, cancellationToken).ConfigureAwait(false);
return await ConnectAsync(endpoint, addresses, cancellationToken).ConfigureAwait(false);
}
/// <summary>按 Happy Eyeballs 策略连接已解析的地址集合。</summary>
public static async ValueTask<Stream> ConnectAsync(DnsEndPoint endpoint, IPAddress[] addresses, CancellationToken cancellationToken)
{
if (endpoint == null) throw new ArgumentNullException(nameof(endpoint));
if (addresses == null) throw new ArgumentNullException(nameof(addresses));
var v6 = Select(addresses, AddressFamily.InterNetworkV6);
var v4 = Select(addresses, AddressFamily.InterNetwork);
if (v6.Length == 0 && v4.Length == 0)
throw new HttpRequestException($"无法连接 {endpoint.Host}:{endpoint.Port},没有可用的 IP 地址");
// 只有一族地址时直接顺序连接,不付竞速带来的额外 socket 与句柄开销
if (v4.Length == 0) return await ConnectSequentiallyAsync(v6, endpoint, cancellationToken).ConfigureAwait(false);
if (v6.Length == 0) return await ConnectSequentiallyAsync(v4, endpoint, cancellationToken).ConfigureAwait(false);
return await ConnectRacingAsync(Interleave(v6, v4), endpoint, cancellationToken).ConfigureAwait(false);
}
/// <summary>交错后的地址按固定间隔依次发起,第一个成功者胜出,其余立即取消并释放。</summary>
private static async ValueTask<Stream> ConnectRacingAsync(IPAddress[] ladder, DnsEndPoint endpoint, CancellationToken cancellationToken)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var attempts = new List<Task<Socket>>(ladder.Length);
for (var i = 0; i < ladder.Length; i++)
{
var startIn = TimeSpan.FromMilliseconds(AttemptDelay.TotalMilliseconds * i);
attempts.Add(ConnectCoreAsync(ladder[i], endpoint.Port, startIn, linked.Token));
}
var errors = new List<Exception>();
while (attempts.Count > 0)
{
var finished = await Task.WhenAny(attempts).ConfigureAwait(false);
attempts.Remove(finished);
if (finished.Status == TaskStatus.RanToCompletion)
{
ObservePending(attempts); // 先观察落败任务,避免 UnobservedTaskException
linked.Cancel(); // 再取消,让仍在 SYN_SENT 的 socket 尽快归还句柄
return new NetworkStream(finished.Result, ownsSocket: true);
}
if (finished.Exception != null) errors.AddRange(finished.Exception.InnerExceptions);
}
cancellationToken.ThrowIfCancellationRequested();
throw new HttpRequestException($"无法连接 {endpoint.Host}:{endpoint.Port}", new AggregateException(errors));
}
/// <summary>顺序尝试同一族地址,用于只有一族可用的场景。</summary>
private static async ValueTask<Stream> ConnectSequentiallyAsync(IPAddress[] addresses, DnsEndPoint endpoint, CancellationToken cancellationToken)
{
var errors = new List<Exception>(addresses.Length);
for (var i = 0; i < addresses.Length; i++)
{
var socket = CreateSocket(addresses[i].AddressFamily);
try
{
await socket.ConnectAsync(new IPEndPoint(addresses[i], endpoint.Port), cancellationToken).ConfigureAwait(false);
return new NetworkStream(socket, ownsSocket: true);
}
catch (OperationCanceledException)
{
socket.Dispose();
throw;
}
catch (Exception ex)
{
socket.Dispose();
errors.Add(ex);
}
}
throw new HttpRequestException($"无法连接 {endpoint.Host}:{endpoint.Port}", new AggregateException(errors));
}
/// <summary>延迟指定时间后连接单个地址,失败时释放 socket。</summary>
private static async Task<Socket> ConnectCoreAsync(IPAddress address, int port, TimeSpan startIn, CancellationToken cancellationToken)
{
if (startIn > TimeSpan.Zero) await Task.Delay(startIn, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var socket = CreateSocket(address.AddressFamily);
try
{
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
return socket;
}
catch
{
socket.Dispose();
throw;
}
}
/// <summary>观察落败任务上的异常,避免触发 UnobservedTaskException。</summary>
private static void ObservePending(List<Task<Socket>> attempts)
{
for (var i = 0; i < attempts.Count; i++)
{
_ = attempts[i].ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
}
/// <summary>创建指定地址族的 TCP socket。</summary>
private static Socket CreateSocket(AddressFamily family)
{
var socket = new Socket(family, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
// 关闭双模,保证 IPv6 socket 只尝试真正的 IPv6 地址
if (family == AddressFamily.InterNetworkV6) socket.DualMode = false;
return socket;
}
/// <summary>按地址族筛选地址。</summary>
private static IPAddress[] Select(IPAddress[] addresses, AddressFamily family)
{
var count = 0;
for (var i = 0; i < addresses.Length; i++)
{
if (addresses[i].AddressFamily == family) count++;
}
if (count == 0) return Array.Empty<IPAddress>();
var result = new IPAddress[count];
var index = 0;
for (var i = 0; i < addresses.Length; i++)
{
if (addresses[i].AddressFamily == family) result[index++] = addresses[i];
}
return result;
}
/// <summary>交错两族地址:first[0]、second[0]、first[1]、second[1]……(RFC 8305 第 4 节)。</summary>
private static IPAddress[] Interleave(IPAddress[] first, IPAddress[] second)
{
var result = new IPAddress[first.Length + second.Length];
var index = 0;
for (var i = 0; i < first.Length || i < second.Length; i++)
{
if (i < first.Length) result[index++] = first[i];
if (i < second.Length) result[index++] = second[i];
}
return result;
}
}
工程上要注意四点:
- 只有一族地址时不要竞速:直接顺序连接即可,竞速会带来 2 倍的 socket / 句柄开销。
- 先观察异常、再取消:落败任务里抛出的
SocketException若无人观察,会变成UnobservedTaskException。 - IPv6 socket 建议
DualMode = false,避免把 IPv4 映射地址也算进 IPv6 候选。 - 该成本是按新建连接计费的:连接池复用后的请求不受影响;域名多、并发高、连接频繁过期时收益最明显。
实测效果(同一台机器、同一条链接):
| 场景 | 默认 | 加上 ConnectCallback |
|---|---|---|
| 完整 HTTPS 请求(建连+首包) | 169203 ms | 1054 / 1072 / 1604 ms |
直接建连 :443 | 21.2s × N | 435–473 ms |
| 另一双栈站点 | — | 286–446 ms |
4.3 网络层根治
最省事的是在系统层面解决:修好路由器/ISP 的 IPv6 出口,或在网卡属性里取消勾选 IPv6。
影响面是全局的(其他软件也受益),但需要下发到每一台机器。
4.4 netstandard2.0 / .NET Framework 怎么办
SocketsHttpHandler 与 ConnectCallback 都不在 netstandard2.0 契约里,.NET Framework 4.x 运行时同样没有
(编译期即为 CS0246: 未能找到类型或命名空间名"SocketsHttpHandler")。这类项目的 HttpClientHandler 走的是HttpWebRequest/ServicePoint 老栈,没有 socket 注入点,因此 ConnectCallback 方案用不了。
可选做法:
- 网络层解决(同 4.3);
- 多目标:库改为
netstandard2.0;net8.0,只在 .NET 5+ 的 TFM 里装配ConnectCallback,
netstandard2.0 分支保持原样; - 自建连接:需要自己控制 TCP/TLS 时,把竞速逻辑写成 netstandard2.0 兼容版本——
Task而非ValueTask,且因为没有可取消的ConnectAsync,要用”连接超时 + 释放 socket”来中断。
该版本实测在 netstandard2.0 下编译通过、在 .NET Framework 4.8 上 273 ms 完成回退。
4.5 方案对比
| 方案 | 改动量 | 保留 IPv6 | 适用面 |
|---|---|---|---|
System.Net.DisableIPv6 | 一行 / 配置项 | 否 | .NET 5+ 进程;网络 IPv6 确定不可用时最省事 |
ConnectCallback + RFC 8305 | 一个类 | 是 | SocketsHttpHandler(含 gRPC、IHttpClientFactory);长期方案 |
| 系统 / 网络层修复 | 无代码 | 是 | 所有软件;需要运维配合 |
5. .NET 11 现状调查
截至 2026-09,.NET 11 处于 RC1(11.0.0-rc.1,2026-09-08 发布,go-live),GA 计划在 2026-11。
5.1 新增 ConnectAlgorithm:Socket 层面支持并行连接
.NET 11 为 System.Net.Sockets 增加了 ConnectAlgorithm 枚举(release/11.0 分支源码可查):
public enum ConnectAlgorithm
{
Default = 0, // 顺序尝试
Parallel = 1, // Happy Eyeballs 式并行竞速
}
配套的静态重载:
public static bool ConnectAsync(SocketType socketType, ProtocolType protocolType,
SocketAsyncEventArgs e, ConnectAlgorithm connectAlgorithm);
实现里对 DnsEndPoint 直接走 e.DnsConnectAsync(...),也就是由运行时完成 DNS 解析与多地址竞速:
var e = new SocketAsyncEventArgs { RemoteEndPoint = new DnsEndPoint(host, 443) };
e.Completed += (_, args) =>
{
if (args.SocketError == SocketError.Success)
{
var socket = args.ConnectSocket!; // 已连接的 socket
}
};
if (!Socket.ConnectAsync(SocketType.Stream, ProtocolType.Tcp, e, ConnectAlgorithm.Parallel))
{
// 已同步完成,Completed 已经触发
}
版本对照(直接检索各版本分支源码得出):
| 目标框架 | SocketAsyncEventArgs.ConnectSocket | ConnectAlgorithm |
|---|---|---|
| .NET 8 / 9 / 10 | 有 | 无 |
| .NET 11 | 有 | 有 |
对应 issue:dotnet/runtime#87932(Happy Eyeballs support in Socket.ConnectAsync)已 completed,里程碑 11.0.0。
5.2 关键结论:.NET 11 的 HttpClient 还享受不到
SocketsHttpHandler 采用 Happy Eyeballs 的跟踪 issue dotnet/runtime#26177 目前仍是 open,里程碑 Future;
在 main 分支的 SocketsHttpHandler/ConnectHelper.cs 里检索不到 ConnectAlgorithm 的引用。
也就是说:.NET 11 的 HttpClient 仍然按顺序试地址,4.2 的 ConnectCallback 方案在 .NET 11 上依然必要。
5.3 后续:12.0 的 API 提案
dotnet/runtime#117548([API Proposal]: ValueTask-based Socket.ConnectAsync overloads for Happy Eyeballs)仍在 open,
里程碑为 12.0.0,提案形态是:
public ValueTask ConnectAsync(DnsEndPoint remoteEP, ConnectAlgorithm connectAlgorithm, CancellationToken cancellationToken = default);
public ValueTask ConnectAsync(IPAddress[] addresses, int port, ConnectAlgorithm connectAlgorithm, CancellationToken cancellationToken);
等它落地后,ConnectCallback 里也可以直接复用它,替代手写的竞速循环。
5.4 迁移建议
| 场景 | .NET 11 之前 | .NET 11 | 备注 |
|---|---|---|---|
HttpClient / SocketsHttpHandler | ConnectCallback | ConnectCallback(不变) | 等 #26177 落地后再删 |
自建 Socket 连接 | 手写竞速 | ConnectAlgorithm.Parallel | 见 5.1 |
| netstandard2.0 / .NET Framework | 网络层或自建连接器 | 无变化 | 见 4.4 |
6. 速查表
| 症状 | 判断依据 | 处置 |
|---|---|---|
浏览器/curl 快,HttpClient 慢几十秒 | curl -6 超时但 curl 正常;DNS 里 AAAA 在 A 之前 | 4.1 或 4.2 |
| 每个请求都慢,且域名各异 | 每个域名都要新建连接 | 复用 HttpClient/连接池,并按 4.2 修根因 |
| 只有首次慢,之后正常 | 连接池已复用 | 属正常现象;仍建议修根因以应对新连接 |
| netstandard2.0 / .NET Framework 项目 | SocketsHttpHandler 编译期不存在 | 4.3 或 4.4 |
| 想等官方方案 | .NET 11 已有 ConnectAlgorithm,但 HttpClient 未采用 | 继续用 4.2 |
全部耗时与超时数字均为 2026-09 在一台 Windows 双栈机器上的实测值,会随网络环境变化,量级与成因不变。









