学无先后达者为师!
不忘初心,砥砺前行。

为什么 curl 毫秒级,HttpClient 却卡 60 秒?——IPv6 黑洞与 Happy Eyeballs

一句话结论:目标域名同时有 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. 原理小结

四个事实叠加成这个故障:

  1. 域名是双栈的,AAAA 排在 A 前面;
  2. 所在网络的 IPv6 出口丢包而不是拒绝(拒绝会立刻失败,丢包只能等超时);
  3. 操作系统的单次 TCP 连接超时约 21 秒;
  4. .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;
    }
}

工程上要注意四点:

  1. 只有一族地址时不要竞速:直接顺序连接即可,竞速会带来 2 倍的 socket / 句柄开销。
  2. 先观察异常、再取消:落败任务里抛出的 SocketException 若无人观察,会变成 UnobservedTaskException。
  3. IPv6 socket 建议 DualMode = false,避免把 IPv4 映射地址也算进 IPv6 候选。
  4. 该成本是按新建连接计费的:连接池复用后的请求不受影响;域名多、并发高、连接频繁过期时收益最明显。

实测效果(同一台机器、同一条链接):

场景默认加上 ConnectCallback
完整 HTTPS 请求(建连+首包)169203 ms1054 / 1072 / 1604 ms
直接建连 :44321.2s × N435–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 方案用不了。

可选做法:

  1. 网络层解决(同 4.3);
  2. 多目标:库改为 netstandard2.0;net8.0,只在 .NET 5+ 的 TFM 里装配 ConnectCallback,
    netstandard2.0 分支保持原样;
  3. 自建连接:需要自己控制 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.ConnectSocketConnectAlgorithm
.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 / SocketsHttpHandlerConnectCallbackConnectCallback(不变)等 #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 双栈机器上的实测值,会随网络环境变化,量级与成因不变。

赞(0) 打赏
未经允许不得转载:码农很忙 » 为什么 curl 毫秒级,HttpClient 却卡 60 秒?——IPv6 黑洞与 Happy Eyeballs

评论 抢沙发

给作者买杯咖啡

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

支付宝扫一扫

微信扫一扫

登录

找回密码

注册