.NET 6网络的改进点展示

106 阅读6分钟

在每一个新的.NET版本中,我们都喜欢发表一篇博文,强调网络的一些变化和改进。在这篇文章中,我很高兴地谈一谈 .NET 6的变化。

这篇文章的前一个版本是.NET 5的网络改进

HTTP

HTTP/2窗口扩展

随着HTTP/2和gRPC的流行,我们的客户发现,SocketsHttpHandler ,当连接到有明显网络延迟的地理上遥远的服务器时,HTTP/2的下载速度与其他实现方式不相上下。在具有高带宽-延迟产品的链接上,一些用户报告说,与能够利用链接的物理带宽的其他实现相比,有5-10倍的差异。举个例子:在我们的一个基准测试中,curl能够达到一个特定的跨大西洋链路的最大10 Mbit/s速率,而SocketsHttpHandler 速度最高为2.5 Mbit/s。除其他外,这严重影响了gRPC流媒体方案。

这个问题的根本原因是固定大小的HTTP/2接收窗口,它的64KB大小太小了,当WINDOW_UPDATE 帧时,它的延迟很高,无法保持网络繁忙,这意味着HTTP/2自己的流控机制正在拖延网络链接。

我们考虑了一些 "便宜 "的方案来解决这个问题,比如定义一个大的固定大小的窗口--这可能会导致不必要的高内存占用--或者要求用户根据经验观察来手动配置接收窗口。这些似乎都不能令人满意,所以我们决定实现一种类似于TCP或QUIC(dotnet/runtime#54755)中的自动窗口大小算法。

这证明效果很好,使下载速度接近其理论最大值。然而,由于HTTP/2 PING帧被用来确定HTTP/2连接的往返时间,我们必须非常小心,以避免触发服务器的PING泛滥保护机制。我们实现了一种算法,它应该能与gRPC和现有的HTTP服务器很好地配合,但我们想确保万一出了问题,我们有一条逃生通道。动态窗口大小--以及随之而来的PING帧--可以通过将System.Net.SocketsHttpHandler.Http2FlowControl.DisableDynamicWindowSizing AppContext 开关设置为true 来关闭。如果有必要这样做,还有一种方法可以解决吞吐量的问题,那就是将更高的值分配给 SocketsHttpHandler.InitialHttp2StreamWindowSize.

HTTP/3和QUIC

在.NET 5中,我们发布了一个QUIC和HTTP/3的实验性实现。它只限于Windows的Insider版本,并且有相当多的仪式来使它工作。

在.NET 6中,我们已经大大简化了设置:

  • 在Windows上,我们将MsQuic库作为运行时的一部分,所以不需要下载或引用任何外部东西。唯一的限制是,需要Windows 11或Windows Server 2022。这是由于Schannel中的TLS 1.3对QUIC的支持,在早期的Windows版本中是没有的。
  • 在Linux上,我们将MsQuic作为标准的Linux软件包libmsquic (deb和rpm)发布在Microsoft Package Repository。在Linux上不将MsQuic与运行时捆绑在一起的原因是我们将libmsquicQuicTLS是OpenSSL的一个分叉,提供必要的TLS APIs。由于我们将QuicTLS与MsQuic捆绑在一起,我们需要在正常的.NET发布计划之外进行安全补丁。

我们还大大改善了稳定性,并实现了很多缺失的功能,在.NET 6中关闭了大约90个问题

HTTP/3使用QUIC而不是TCP作为其传输层。我们的QUIC协议的.NET实现是一个建立在MsQuic之上的托管层,在System.Net.Quic 库。QUIC是一个通用的协议,可以用于多种情况,而不仅仅是HTTP/3,但它是新的,最近才在RFC 9000中批准。 我们没有足够的信心,目前的API形状将经得起时间的考验,并适合于其他协议的使用,所以我们决定在这个版本中保持它的隐私。因此,.NET 6包含了QUIC协议的实现,但并没有将其公开。它只在HttpClient 和Kestrel服务器中的HTTP/3内部使用。

尽管在这个版本中投入了大量的精力来消除错误,我们仍然不认为HTTP/3的质量是完全可以生产的。由于任何HTTP请求都可能在无意中通过Alt-Svc 头部升级到HTTP/3并开始失败,我们选择在这个版本中保持HTTP/3功能默认为禁用。在HttpClient ,它被隐藏在System.Net.SocketsHttpHandler.Http3Support AppContext 开关后面。

所有关于如何设置的细节已经在我们以前的文章中描述过了。 HttpClientKestrel。在Linux上,获取libmsquic 包,在Windows上,确保操作系统版本至少为10.0.20145.1000。然后,你只需要启用HTTP/3支持,并设置HttpClient ,以使用HTTP/3。

using System.Net;

// Set this switch programmatically or in csproj:
// <RuntimeHostConfigurationOption Include="System.Net.SocketsHttpHandler.Http3Support" Value="true" />
AppContext.SetSwitch("System.Net.SocketsHttpHandler.Http3Support", true);

// Set up the client to request HTTP/3.
var client = new HttpClient()
{
    DefaultRequestVersion = HttpVersion.Version30,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
};
var resp = await client.GetAsync("https://<http3 endpoint>");

// Print the response version.
Console.WriteLine($"status: {resp.StatusCode}, version: {resp.Version}");

我们鼓励你尝试HTTP/3!如果你遇到任何问题,请在dotnet/runtime中提交一个问题。

HTTP重试逻辑

.NET 6改变了HTTP请求重试逻辑,使其基于一个固定的重试次数限制(见dotnet/runtime#48758)。

以前,.NET 5不允许在连接失败时重试,如果失败发生在一个 "新 "连接上(一个没有被用于以前请求的连接)。我们这样做主要是为了确保重试逻辑不会陷入无限循环。这是不理想的,特别是对HTTP/2连接来说是有问题的(见dotnet/runtime#44669)。另一方面,.NET 5在许多情况下对允许重试过于宽松,这并不完全符合RFC 2616的规定。例如,我们在任意的异常情况下重试,例如在IO超时的情况下,尽管用户明确地设置了这个超时,并假定希望在超过超时后失败(而不是重试)请求。

.NET 6的重试逻辑将发挥作用,无论该请求是否是连接上的第一个请求。它引入了重试限制,目前设置为5。在未来,如果需要,我们可能会考虑调整它或使其可配置。

为了更好地遵守RFC,现在只有当我们认为服务器试图优雅地关闭连接时,请求才是可重试的--也就是说,当我们在HTTP/1.1的任何其他响应数据之前收到EOF,或者在HTTP/2收到GOAWAY。

.NET 6更保守的重试行为的缺点是,以前被宽松的重试策略所掩盖的失败将开始对用户可见。例如,如果一个服务器以非优雅的方式关闭空闲连接(通过发送TCP RST数据包),那些因为RST而失败的请求将不会被自动重试。这一点在AAD关于迁移到.NET 6的文章中已经简要地提到了。解决的办法是将客户端的空闲超时 (SocketsHttpHandler.PooledConnectionIdleTimeout)设置为服务器空闲超时的50-75%(如果它是已知的)。这样一来,一个请求就不会陷入与服务器关闭连接的空闲竞赛中--HttpClient 会更快地清除它。另一种方法是在HttpClient 外实现一个自定义的重试策略。这也将允许调整重试策略和启发式方法,例如,如果一些一般的非空闲请求可以被重试,这取决于特定服务器的逻辑和实现。

SOCKS代理支持

SOCKS代理支持是一个长期存在的问题(dotnet/runtime#17740),最终由社区贡献者@huoyaoyuan实现。我们已经在.NET 6预览版5的博文中写到了这个新增功能。这一变化增加了对SOCKS4、SOCKS4a和SOCKS5代理的支持。

SOCKS代理是一个非常通用的工具。例如,它可以提供与VPN类似的功能。最值得一提的是,SOCKS代理被用来访问Tor网络。

要配置HttpClient ,以使用SOCKS代理,你只需要在定义proxy1时使用socks 方案:

var client = new HttpClient(new SocketsHttpHandler()
{
    // Specify the whole Uri (schema, host and port) as one string or use Uri directly.
    Proxy = new WebProxy("socks5://127.0.0.1:9050")
});

var content = await client.GetStringAsync("https://check.torproject.org/");
Console.WriteLine(content);

这个例子假设你在你的电脑上运行tor 实例。如果请求成功,你应该能找到 "恭喜你。这个浏览器被配置为使用Tor。"在响应内容中。

1.在原来的博文中,我们犯了一个错误,使用了一个错误的WebProxy 构造函数的重载。它在第一个参数中只期望有主机名,不能用于HTTP以外的任何其他代理类型。我们也修正了.NET 7中这个特殊的构造函数行为的不一致性(dotnet/runtime#62338)。

WinHTTP

WinHttpHandler 是WinHTTP的一个封装器,因此其功能集是以WinHTTP的功能为基础的。在这个版本中,有几个新增的功能是为HTTP/2暴露或启用WinHttp功能。它们是一个更大的努力(dotnet/core#5713)的一部分,使用户能够在.NET框架上使用gRPC.NET。其目的是使WCF更顺利地过渡到.NET框架上的gRPC,然后再过渡到.NET核心/.NET 5+上的gRPC。

  • 追踪头信息(dotnet/runtime#44778)。

    • 对于.NET Core 3.1/.NET 5及更高版本,尾部头信息暴露在 HttpResponseMessage.TrailingHeaders.
    • 对于.NET框架,它们被暴露在HttpRequestMessage.Properties["__ResponseTrailers"] ,因为在.NET框架上没有这样的属性TrailingHeaders
  • 双向流(dotnet/runtime#44784)。这一变化是完全无缝的,WinHttpHandler ,在适当的时候会自动允许双向流,即当请求内容没有一个已知的长度,并且底层的WinHTTP支持它。

  • TCP keep-alive配置。TCP keep-alive用于保持空闲连接的开放,并防止中间的节点,如代理和防火墙,比客户预期的更快地放弃连接。在.NET 6中,我们为WinHttpHandler 增加了3个新的属性来配置它:

    public class WinHttpHandler
    {
    // Controls whether TCP keep-alive is getting send or not.
    public bool TcpKeepAliveEnabled { get; set; }
    // Delay to the first keep-alive packet during inactivity.
    public TimeSpan TcpKeepAliveTime { get; set; }
    // Interval for subsequent keep-alive packets during inactivity.
    public TimeSpan TcpKeepAliveInterval { get; set; }
    }
    

    这些属性对应于WinHTTP tcp_keepalive结构。

  • 使用TLS 1.3与WinHttpHandler (dotnet/runtime#58590)。这个功能对用户是透明的,唯一需要的是Windows的支持。

其他HTTP变化

.NET 6中的许多HTTP变化已经在Stephen Toub的关于性能的大量文章中谈到了,但其中有几个值得重复一下:

  • 重构了SocketsHttpHandler 中的连接池(runtime/dotnet#44818)。新的方法允许我们总是在首先可用的连接上处理请求,无论那是一个新建立的连接还是一个在此期间准备好处理请求的连接。而以前,在请求到来时所有的连接都很忙的情况下,我们会开始打开一个新的连接,让请求等待它的到来。这一变化适用于HTTP/1.1以及HTTP/2,并在 EnableMultipleHttp2Connections的情况下也是如此。
  • 增加了HTTP头的非验证枚举(runtime/dotnet#35126)。这一变化为头信息收集增加了一个新的API HttpHeaders.NonValidated到头信息收集中。它允许检查接收到的头信息(没有经过消毒),并且跳过所有的解析和验证逻辑,不仅节省了CPU周期,也节省了分配。
  • 优化的HPack Huffman解码(dotnet/runtime#43603)。HPack是HTTP/2RFC 7541的一个头(去)压缩格式。从我们的微观基准来看,这一优化将解码的时间减少到原始时间的0.35左右(dotnet/runtime#1506)。
  • 引入 ZLibStream.最初,我们不希望在deflate压缩的内容数据中出现zlib信封(dotnet/runtime#38022),RFC 2616将其定义为带deflate压缩的zlib格式。一旦我们解决了这个问题,就会出现另一个问题,因为不是所有的服务器都把zlib信封放在那里。所以我们引入了一种机制来检测格式并使用适当的流类型(dotnet/runtime#57862)。
  • 增加了cookies枚举。在.NET 6之前,没有一种方法可以列举出.NET中的所有cookies。 CookieContainer.你需要知道它们的域名才能得到它们。此外,也没有办法获得有任何cookie的域名列表。人们使用丑陋的黑客手段来访问cookies(dotnet/runtime#44094)。所以我们引入了一个新的API CookieContainer.GetAllCookies列出容器中的所有cookies(dotnet/runtime#44094)。

套接字

通过利用Windows上的自动重用端口范围来处理端口耗尽的问题

当大规模地打开HTTP/1.1连接时,你可能会注意到,新的连接尝试在一段时间后开始失败。在Windows上,这种情况通常发生在~16K并发连接的时候,套接字错误10055(WSAENOBUFS )作为内部SocketException 。通常,网络堆栈会选择一个尚未与另一个套接字绑定的端口,这意味着同时打开的最大连接数受到动态端口范围的限制。这是一个可配置的范围,通常默认为49152-65535 ,理论限制为216=65536端口,因为一个端口是16位数字。

为了解决远程端点的IP地址和/或端口不同的情况下的这个问题,Windows早在Windows 8.1时代就引入了一个叫做自动重用端口范围的功能。.NET框架通过一个opt-in属性暴露了相关的socket选项SO_REUSE_UNICASTPORTServicePointManager.ReusePort但这个属性在.NET Core/.NET 5+上变成了一个无用的API。相反,在dotnet/runtime#48219中,我们在.NET 6+上为所有出站的异步Socket 连接启用了SO_REUSE_UNICASTPORT ,允许端口在连接之间重复使用,只要是:

  • 连接的完整4元组(本地端口、本地地址、远程端口、远程地址)是唯一的。
  • 自动重复使用的端口范围是在机器上配置的。

你可以用下面的PowerShell cmdlet来设置自动重复使用的端口范围:

Set-NetTCPSetting -SettingName InternetCustom `
                  -AutoReusePortRangeStartPort <start-port> `
                  -AutoReusePortRangeNumberOfPorts <number-of-ports>

设置需要重新启动才能生效。

由于粘性的向后兼容问题,自动重用端口范围必须专门用于使用这种特殊逻辑的出站连接。这意味着如果自动重用端口范围被配置为与一个众所周知的监听端口(例如80端口)重叠,那么将一个监听套接字绑定到该端口的尝试将失败。另外,如果自动重复使用的端口范围完全覆盖了常规的短暂端口范围,那么正常的通配符绑定将失败。通常情况下,选择一个自动重用范围是默认短暂端口范围的一个严格子集,将避免问题。但是,管理员仍然必须小心,因为有些应用程序将临时端口范围内的大端口号作为 "知名 "端口号。

一个全局禁用IPv6的选项

自.NET 5以来,我们在SocketsHttpHandler 中使用了双模式套接字。这使我们能够从IPv6套接字中处理IPv4流量,并且被RFC 1933认为是一种有利的做法。另一方面,我们有几个用户报告说,通过VPN隧道连接有问题,这些隧道不正确地支持IPv6和/或双栈套接字。 为了缓解这些问题和其他IPv6的潜在问题,dotnet/runtime#55012实现了一个开关,在整个.NET 6进程中全局禁用IPv6。

如果你遇到类似的问题并决定通过禁用IPv6来解决,你现在可以将环境变量DOTNET_SYSTEM_NET_DISABLEIPV6 设置为1 ,或将System.Net.DisableIPv6 运行时配置设置true

新的基于跨度和任务的重载在System.Net.Sockets

在社区的帮助下,我们成功地使Socket 和相关类型在SpanTask 和取消支持方面接近API完整。完整的API-diff太长了,不能包含在这篇博文中,你可以在这个dotnet/core文档中找到它。 我们要感谢@gfoidl@ovebastiansen@PJB3005的贡献!

安全性

在.NET 6中,我们在网络安全领域做了两个值得一提的小改动。

延迟的客户端协议

这是一个服务器端的SslStream 功能。当服务器决定需要为已经建立的连接重新协商加密时,它就会被使用。例如,当客户访问一个需要客户证书的资源时,而这个证书最初并没有被提供。

新的SslStream 方法看起来像这样:

public virtual Task NegotiateClientCertificateAsync(CancellationToken cancellationToken = default);

该实现根据TLS版本使用两种不同的TLS功能。对于包括1.2在内的TLS,使用TLS重新协商(RFC 5746)。对于TLS 1.3,握手后认证扩展被使用(RFC 8446)。这两个特性在SChannel AcceptSecurityContext函数中抽象出来。因此,在Windows上完全支持延迟的客户端协商。不幸的是,OpenSSL的情况是不同的,因此在Linux上的支持仅限于TLS重新协商,即TLS到1.2的情况。此外,MacOS根本不被支持,因为它的安全层不提供这两点。我们在.NET 7中全力以赴缩小这一平台差距。

请注意,HTTP/2(RFC 8740)不允许TLS重新协商和握手后认证扩展,因为它在一个连接上复用多个请求。

模拟改进

这是Windows唯一的功能,在这个功能中,单个进程可以在不同的用户下运行线程,通过 WindowsIdentity.RunImpersonatedAsync.在两种情况下,我们的表现并不理想,我们在.NET 6中对此进行了修正。第一个是在做异步名称解析时(dotnet/runtime#47435)。另一个是在发送HTTP请求时,我们不尊重冒充的用户(dotnet/runtime#58033)。

诊断

我们收到了许多问题、投诉和错误报告,关于 HttpClient的默认行为 Activity创建(dotnet/runtime#41072)和自动跟踪头的注入(dotnet/runtime#35337)。这些问题在ASP.NET Core项目中更加明显,在这些项目中,自动创建了一个Activity ,无意中开启了DiagnosticsHandler ,它是HttpClient 处理程序链的一部分。此外,DiagnosticsHandler 是一个内部类,没有通过HttpClient 公开的任何配置,因此迫使用户想出黑客式的变通方法来控制行为(dotnet/runtime#31862),或者只是完全关闭它(dotnet/runtime#35337-comment)。

所有这些问题都在.NET 6中得到解决(dotnet/runtime#55392)。头部注入现在可以用 DistributedContextPropagator.它可以通过以下方式进行全局控制 DistributedContextPropagator.Current或每个HttpClient/SocketsHttpHandler ,用 SocketsHttpHandler.ActivityHeadersPropagator.我们还准备了一些被要求最多的实现方式:

为了更精细地控制头的注入,可以提供一个自定义的DistributedContextPropagator 。例如,一个用于跳过由DiagnosticsHandler (归功于@MihaZupan)发出的一个层。

public sealed class SkipHttpClientActivityPropagator : DistributedContextPropagator
{
    private readonly DistributedContextPropagator _originalPropagator = Current;

    public override IReadOnlyCollection<string> Fields => _originalPropagator.Fields;

    public override void Inject(Activity? activity, object? carrier, PropagatorSetterCallback? setter)
    {
        if (activity?.OperationName == "System.Net.Http.HttpRequestOut")
        {
            activity = activity.Parent;
        }

        _originalPropagator.Inject(activity, carrier, setter);
    }

    public override void ExtractTraceIdAndState(object? carrier, PropagatorGetterCallback? getter, out string? traceId, out string? traceState) =>
        _originalPropagator.ExtractTraceIdAndState(carrier, getter, out traceId, out traceState);

    public override IEnumerable<KeyValuePair<string, string?>>? ExtractBaggage(object? carrier, PropagatorGetterCallback? getter) =>
        _originalPropagator.ExtractBaggage(carrier, getter);
}

最后,为了把这一切结合起来,设置ActivityHeadersPropagator

// Set up headers propagator for this client.
var client = new HttpClient(new SocketsHttpHandler() {
    // -> Turns off activity creation as well as header injection
    // ActivityHeadersPropagator = null

    // -> Activity gets created but no trace header is injected
    // ActivityHeadersPropagator = DistributedContextPropagator.CreateNoOutputPropagator()

    // -> Activity gets created, trace header gets injected and contains "root" activity id
    // ActivityHeadersPropagator = DistributedContextPropagator.CreatePassThroughPropagator()

    // -> Activity gets created, trace header gets injected and contains "parent" activity id
    // ActivityHeadersPropagator = new SkipHttpClientActivityPropagator()

    // -> Activity gets created, trace header gets injected and contains "System.Net.Http.HttpRequestOut" activity id
    // Same as not setting ActivityHeadersPropagator at all.
    // ActivityHeadersPropagator = DistributedContextPropagator.CreateDefaultPropagator()
});

// If you want the see the order of activities created, add ActivityListener.
ActivitySource.AddActivityListener(new ActivityListener()
{
    ShouldListenTo = (activitySource) => true,
    ActivityStarted = activity => Console.WriteLine($"Start {activity.DisplayName}{activity.Id}"),
    ActivityStopped = activity => Console.WriteLine($"Stop {activity.DisplayName}{activity.Id}")
});

// Set up activities, at least two layers to show all the differences.
using Activity root = new Activity("root");
// Header format can be overridden, default is W3C, see https://www.w3.org/TR/trace-context/).
// root.SetIdFormat(ActivityIdFormat.Hierarchical);
root.Start();
using Activity parent = new Activity("parent");
// parent.SetIdFormat(ActivityIdFormat.Hierarchical);
parent.Start();

var request = new HttpRequestMessage(HttpMethod.Get, "https://www.microsoft.com");

using var response = await client.SendAsync(request);
Console.WriteLine($"Request: {request}"); // Print the request to see the injected header.

URI

HttpClient 使用 ,它根据System.Uri RFC 3986进行验证和规范化,并以可能破坏其终端客户的方式修改一些URI。例如,较大的服务或SDK可能需要将URI从其源头(如Kestrel)透明地传递到 ,这在.NET 5中是不可能的(见HttpClientdotnet/runtime#52628,dotnet/runtime#58057)。

.NET 6将引入一个新的API标志 UriCreationOptions.DangerousDisablePathAndQueryCanonicalization(见dotnet/runtime#59274),这将允许用户禁用URI上的任何规范化,并 "原封不动 "地接受它。

设置DangerousDisablePathAndQueryCanonicalization 意味着没有验证,也没有对输入的转换将发生在权限之上。作为一个副作用,用这个选项创建的Uri 实例不支持Uri.Fragments - 它将永远是空的。此外,Uri.GetComponents(UriComponents, UriFormat) 不能用于UriComponents.PathUriComponents.Query ,并且会抛出InvalidOperationException

请注意,禁用规范化也意味着保留字符不会被转义(例如,空格字符不会被改为%20 ),这可能会破坏HTTP请求,使应用程序受到请求偷渡的影响。只有在你确保URI字符串已经被净化的情况下才设置这个选项:

var uriString = "http://localhost/path%4A?query%4A#/foo";

var options = new UriCreationOptions { DangerousDisablePathAndQueryCanonicalization = true };
var uri = new Uri(uriString, options);
Console.WriteLine(uri); // outputs "http://localhost/path%4A?query%4A#/foo"
Console.WriteLine(uri.AbsolutePath); // outputs "/path%4A"
Console.WriteLine(uri.Query); // outputs "?query%4A#/foo"
Console.WriteLine(uri.PathAndQuery); // outputs "/path%4A?query%4A#/foo"
Console.WriteLine(uri.Fragment); // outputs an empty string

var canonicalUri = new Uri(uriString);
Console.WriteLine(canonicalUri.PathAndQuery); // outputs "/pathJ?queryJ"
Console.WriteLine(canonicalUri.Fragment); // outputs "#/foo"

请注意,该API是我们为.NET 7设计的一个更大的API表面的一部分(见dotnet/runtime#59099)。

最后说明

这并不是一份关于.NET 6中发生的所有网络变化的详尽清单。我们试图挑选最有趣的或影响最大的。如果你在网络堆栈中发现任何错误,不要犹豫,请与我们联系。你可以在GitHub上找到我们。