原文 | Máňa Píchová
翻译 | 郑子铭
对于 .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 接收窗口,当以高延迟接收 WINDOW_UPDATE 帧时,它的 64KB 大小太小而无法保持网络繁忙,这意味着 HTTP/2 自己的流量控制机制正在停止网络链接。
我们考虑了“廉价”选项来解决这个问题,例如定义一个固定大小的大窗口——这可能会导致不必要的高内存占用——或者要求用户根据经验观察手动配置接收窗口。这些似乎都不令人满意,因此我们决定实现一种类似于 TCP 或 QUIC 中的自动窗口大小调整算法 (dotnet/runtime#54755)。
结果证明效果很好,将下载速度提升到接近其理论最大值。但是,由于 HTTP/2 PING 帧用于确定 HTTP/2 连接的往返时间,因此我们必须非常小心,以免触发服务器的 PING 泛洪保护机制。我们实现了一个算法,该算法应该可以很好地与 gRPC 和现有的 HTTP 服务器一起工作,但我们想确保我们有一个逃生路径,以防出现问题。可以通过将 System.Net.SocketsHttpHandler.Http2FlowControl.DisableDynamicWindowSizing AppContext 开关设置为 true 来关闭动态窗口大小以及相应的 PING 帧。如果这变得有必要,仍然可以通过为 SocketsHttpHandler.InitialHttp2StreamWindowSize 分配更高的值来解决吞吐量问题。
HTTP/3 和 QUIC在 .NET 5 中,我们发布了 QUIC 和 HTTP/3 的实验性实现。它仅限于 Windows 的 Insider 版本,并且有相当多的仪式让它工作。
在 .NET 6 中,我们大大简化了设置。
- 在 Windows 上,我们将 MsQuic 库作为运行时的一部分提供,因此无需下载或引用任何外部内容。唯一的限制是需要 Windows 11 或 Windows Server 2022。这是因为 TLS 1.3 对 SChannel 中的 QUIC 的支持在早期的 Windows 版本中不可用。
- 在 Linux 上,我们将 MsQuic 作为标准 Linux 包 libmsquic(deb 和 rpm)发布在 Microsoft Package Repository 中。在 Linux 上不将 MsQuic 与 runtime 捆绑在一起的原因是,我们将 libmsquic 与 QuicTLS 一起发布,QuicTLS 是 OpenSSL 的一个分支,提供了必要的 TLS API。由于我们将 QuicTLS 与 MsQuic 捆绑在一起,我们需要能够在正常的 .NET 发布计划之外进行安全补丁。
我们还大大提高了稳定性并实现了许多缺失的功能,在 .NET 6 里程碑中解决了大约 90 个问题。
HTTP/3 使用 QUIC 而不是 TCP 作为其传输层。我们的 QUIC 协议的 .NET 实现是在 System.Net.Quic 库中的 MsQuic 之上构建的托管层。 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 开关后面。
我们之前的文章中已经描述了如何设置所有内容的所有细节:HttpClient 和 Kestrel。在 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 失败的请求将不会自动重试。这在关于迁移到 .NET 6 的 AAD 文章中简要提及。解决方法是将客户端的空闲超时 (SocketsHttpHandler.PooledConnectionIdleTimeout) 设置为服务器空闲超时的 50-75%(如果已知)。这样一来,请求永远不会在服务器以空闲状态关闭连接的竞争中被捕获——HttpClient 会更快地清除它。另一种方法是在 HttpClient 之外实现自定义重试策略。这也将允许调整重试策略和启发式方法,例如,如果可以根据特定服务器的逻辑和实现重试一些通常非幂等的请求。
SOCKS 代理支持SOCKS 代理支持是一个长期存在的问题 (dotnet/runtime#17740),最终由社区贡献者 @huoyaoyuan 实现。我们已经在 .NET 6 Preview 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) 修复了这种特殊的构造函数行为不一致问题。
WinHTTPWinHttpHandler 是 WinHTTP 的包装器,因此功能集取决于 WinHTTP 中的功能。在此版本中,有一些新增功能可以公开或启用 HTTP/2 的 WinHttp 功能。它们是使用户能够在 .NET Framework 上使用 gRPC .NET 的更大努力 (dotnet/core#5713) 的一部分。目标是实现从 WCF 到 .NET Framework 上的 gRPC 以及再到 .NET Core / .NET 5+ 上的 gRPC 的更平滑过渡。
- 尾随标头 (dotnet/runtime#44778)。
- 对于 .NET Core 3.1 / .NET 5 及更高版本,尾随标头在 HttpResponseMessage.TrailingHeaders 中公开。
- 对于 .NET Framework,它们在 HttpRequestMessage.Properties["__ResponseTrailers"] 中公开,因为 .NET Framework 上没有 TrailingHeaders 这样的属性。
- 双向流 (dotnet/runtime#44784)。此更改是完全无缝的,WinHttpHandler 将在适当时自动允许双向流式传输,即当请求内容没有已知长度并且底层 WinHTTP 支持它时。
- TCP 保持活动配置。 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 以及启用了 EnableMultipleHttp2Connections 的 HTTP/2。
- 添加了未经验证的 HTTP 标头枚举 (runtime/dotnet#35126)。更改将新的 API HttpHeaders.NonValidated
- 添加到标头集合中。它允许在收到标头时检查标头(无需进行清理),它还跳过所有解析和验证逻辑,不仅节省了 CPU 周期,还节省了分配。
- 优化 HPack Huffman 解码 (dotnet/runtime#43603)。 HPack 是 HTTP/2 RFC 7541 的标头(解)压缩格式。从我们的微基准测试来看,这种优化将解码所需的时间减少到原始解码时间的 0.35 左右(dotnet/runtime#1506)。
- 引入 ZLibStream。最初,我们没想到 zlib 信封在 deflate 压缩内容数据 (dotnet/runtime#38022) 中,RFC 2616 将其定义为带 deflate 压缩的 zlib 格式。一旦我们解决了这个问题,就会出现另一个问题,因为并非所有服务器都将 zlib 信封放置到位。所以我们引入了一种机制来检测格式并使用适当类型的流(dotnet/runtime#57862)。
- 添加了 cookie 枚举。在 .NET 6 之前,无法枚举 CookieContainer 中的所有 cookie。您需要知道他们的域名才能获得它们。此外,没有办法获取有任何 cookie 的域列表。人们使用丑陋的伎俩来访问 cookie (dotnet/runtime#44094)。因此我们引入了一个新的 API CookieContainer.GetAllCookies 来列出容器中的所有 cookie (dotnet/runtime#44094)。
在大规模打开并发 HTTP/1.1 连接时,您可能会注意到新连接尝试在一段时间后开始失败。在 Windows 上,这通常发生在大约 16K 并发连接左右,其中套接字错误 10055 (WSAENOBUFS) 作为内部 SocketException 消息。通常,网络堆栈会选择一个尚未绑定到另一个套接字的端口,这意味着同时打开的最大连接数受动态端口范围的限制。这是一个可配置的范围,通常默认为 49152-65535,理论上限制为 216=65536 个端口,因为端口是 16 位数字。
为了解决远程端点 IP 地址和/或端口不同的情况下的这个问题,Windows 早在 Windows 8.1 时代就引入了一种称为自动重用端口范围的功能。 .NET 框架通过可选属性 ServicePointManager.ReusePort 公开了相关的套接字选项 SO_REUSE_UNICASTPORT,但此属性在 .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>
设置需要重启才能生效。
来自 Windows 功能的