QUIC 协议

QUIC 是 RFC 9000 中标准化的网络传输层协议。 它使用 UDP 作为基础协议,并且由于它强制使用 TLS 1.3,因此具有内在的安全性。 有关详细信息,请参阅 RFC 9001。 与 TCP 和 UDP 等已知传输协议的另一个值得关注的区别在于,它在传输层上内置了流多路复用。 这允许使用多个独立的并发数据流,这些数据流不会相互影响。

QUIC 本身不会为交换的数据定义任何语义,因为它是传输协议。 它用于应用程序层协议,例如 HTTP/3SMB over QUIC。 它还可用于任何自定义协议。

与具有 TLS 的 TCP 的相比,此协议具有许多优势,下面列出了其中几点:

  • 更快地建立连接,因为它需要进行的往返不像顶层具有 TLS 的 TCP 那样多。
  • 避免出现队头阻塞问题,即一个丢失的数据包不会阻塞所有其他流的数据。

另一方面,在使用 QUIC 时,需要考虑潜在的缺点。 作为一项较新的协议,其采用范围仍在增加,并且是有限的。 除此之外,QUIC 流量甚至可能受到某些网络组件的阻止。

.NET 中的 QUIC

QUIC 实现作为 System.Net.Quic 库在 .NET 5 中引入。 但是,在 .NET 7 之前,此库严格地在内部使用,并且仅用作 HTTP/3 的实现。 在 .NET 7 中,此库设置为公开库,并公开了其 API。

注意

在 .NET 7.0 和 8.0 中,API 作为预览功能发布。 从 .NET 9 开始,这些 API 不再被视为预览功能,现在被视为稳定。

从实现的角度来看,System.Net.Quic 依赖于 MsQuic(QUIC 协议的本机实现)。 因此,System.Net.Quic 平台支持和依赖项继承自 MsQuic 并记录在平台依赖项部分中。 简言之,MsQuic 库作为 Windows 版 .NET 的一部分提供。 但对于 Linux,必须通过相应的包管理器手动安装 libmsquic。 对于其他平台,仍可以手动生成 MsQuic(无论是针对 SChannel 还是 OpenSSL),并将其与 System.Net.Quic 配合使用。 但是,这些方案不是测试矩阵的一部分,并且可能会出现不可预见的问题。

平台依赖项

以下部分介绍 .NET 中 QUIC 的平台依赖项。

Windows

  • Windows 11、Windows Server 2022 或更高版本。 (早期 Windows 版本缺少支持 QUIC 所需的加密 API。)

在 Windows 上,msquic.dll 作为 .NET 运行时的一部分分发,无需执行其他步骤即可安装它。

Linux

注意

.NET 7 仅与 2.2+ 版本的 libmsquic 兼容。

libmsquic 包在 Linux 上是必需的。 此包在Microsoft的官方 Linux 包存储库中发布, https://packages.microsoft.com 在某些官方存储库(如 Alpine Packages - libmsquic)中也可用。

libmsquic从Microsoft的官方 Linux 包存储库安装

在安装包之前,必须将此存储库添加到包管理器。 有关详细信息,请参阅 Microsoft 产品的 Linux 软件存储库

注意

当分发的存储库提供 .NET 和其他Microsoft包时,添加Microsoft包存储库可能与分发存储库冲突。 要避免或排查包混合问题,请查看排查与 Linux 上缺少的文件相关的 .NET 错误

示例

下面是使用包管理器安装 libmsquic 的一些示例:

  • APT

    sudo apt-get install libmsquic 
    
  • APK

    sudo apk add libmsquic
    
  • DNF

    sudo dnf install libmsquic
    
  • zypper

    sudo zypper install libmsquic
    
  • YUM

    sudo yum install libmsquic
    

libmsquic从分发包存储库安装

libmsquic也可以从分发包存储库进行安装,但目前仅可用于Alpine此存储库。

示例

下面是使用包管理器安装 libmsquic 的一些示例:

  • Alpine 3.21 及更高版本
apk add libmsquic
  • Alpine 3.20 及更早版本
# Get libmsquic from community repository edge branch.
apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ libmsquic
libmsquic 的依赖项

以下所有依赖项都显示在 libmsquic 包清单中,由包管理器自动安装:

  • OpenSSL 3+ 或 1.1 - 取决于发行版本的默认 OpenSSL 版本,例如,Ubuntu 22 对应 OpenSSL 3,Ubuntu 20 对应 OpenSSL 1.1。

  • libnuma1

macOS

QUIC 现在通过非标准 Homebrew 包管理器在 macOS 上受部分支持,但存在一些限制。 可以使用 libmsquic Homebrew 通过以下命令在 macOS 上安装:

brew install libmsquic

若要运行使用的 libmsquic.NET 应用程序,需要在运行环境变量之前设置环境变量。 这可确保应用程序可以在运行时动态加载期间找到 libmsquic 库。 可以通过在主命令之前添加以下命令来执行此操作:

DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib dotnet run

DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib ./binaryname

或者,可以使用以下命令设置环境变量:

export DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib

然后运行主命令:

./binaryname

API 概述

System.Net.Quic 提供三个主要类,这些类支持使用 QUIC 协议:

但在使用这些类之前,代码应检查 QUIC 当前是否受支持,因为 libmsquic 可能缺失或者 TLS 1.3 可能不受支持。 为此,QuicListenerQuicConnection 均公开静态属性 IsSupported

if (QuicListener.IsSupported)
{
    // Use QuicListener
}
else
{
    // Fallback/Error
}

if (QuicConnection.IsSupported)
{
    // Use QuicConnection
}
else
{
    // Fallback/Error
}

这些属性将报告相同的值,但将来可能会发生变化。 建议检查适用于服务器方案的 IsSupported 和适用于客户端方案的 IsSupported

QuicListener

QuicListener 表示接受来自客户端的传入连接的服务器端类。 侦听器是使用静态方法 ListenAsync(QuicListenerOptions, CancellationToken) 构造和启动的。 此方法接受 QuicListenerOptions 类的实例,其中包含启动侦听器并接受传入连接所需的所有设置。 在此之后,侦听器已准备好通过 AcceptConnectionAsync(CancellationToken) 分发连接。 此方法返回的连接始终完全连接,这意味着 TLS 握手已完成,并且连接已准备好可供使用。 最后,若要停止侦听和释放所有资源,必须调用 DisposeAsync()

请参考以下 QuicListener 示例代码:

using System.Net.Quic;

// First, check if QUIC is supported.
if (!QuicListener.IsSupported)
{
    Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
    return;
}

// Share configuration for each incoming connection.
// This represents the minimal configuration necessary.
var serverConnectionOptions = new QuicServerConnectionOptions
{
    // Used to abort stream if it's not properly closed by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.

    // Used to close the connection if it's not done by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.

    // Same options as for server side SslStream.
    ServerAuthenticationOptions = new SslServerAuthenticationOptions
    {
        // Specify the application protocols that the server supports. This list must be a subset of the protocols specified in QuicListenerOptions.ApplicationProtocols.
        ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
        // Server certificate, it can also be provided via ServerCertificateContext or ServerCertificateSelectionCallback.
        ServerCertificate = serverCertificate
    }
};

// Initialize, configure the listener and start listening.
var listener = await QuicListener.ListenAsync(new QuicListenerOptions
{
    // Define the endpoint on which the server will listen for incoming connections. The port number 0 can be replaced with any valid port number as needed.
    ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
    // List of all supported application protocols by this listener.
    ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
    // Callback to provide options for the incoming connections, it gets called once per each connection.
    ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(serverConnectionOptions)
});

// Accept and process the connections.
while (isRunning)
{
    // Accept will propagate any exceptions that occurred during the connection establishment,
    // including exceptions thrown from ConnectionOptionsCallback, caused by invalid QuicServerConnectionOptions or TLS handshake failures.
    var connection = await listener.AcceptConnectionAsync();

    // Process the connection...
}

// When finished, dispose the listener.
await listener.DisposeAsync();

有关 QuicListener 的设计方式的详细信息,请参阅 API 建议

QuicConnection

QuicConnection 是用于服务器端和客户端 QUIC 连接的类。 服务器端连接由侦听器在内部创建,并通过 AcceptConnectionAsync(CancellationToken) 分发。 客户端连接必须处于打开状态并连接到服务器。 与侦听器一样,有一种用于实例化和连接该连接的静态方法 ConnectAsync(QuicClientConnectionOptions, CancellationToken)。 它接受 QuicClientConnectionOptions(类似于 QuicServerConnectionOptions 的类)的实例。 在此之后,在客户端中使用该连接与在服务器中使用没有什么不同。 它可以打开传出流并接受传入流。 它还提供包含连接相关信息的属性,例如 LocalEndPointRemoteEndPointRemoteCertificate

完成连接工作后,需要将其关闭并释放。 QUIC 协议要求使用应用层代码执行立即关闭,请参阅 RFC 9000 第 10.2 节。 为此,可以调用具有应用层代码的 CloseAsync(Int64, CancellationToken),否则,DisposeAsync() 将使用 DefaultCloseErrorCode 中提供的代码。 无论采用哪种方式,都必须在连接工作结束时调用 DisposeAsync(),以完全释放所有关联的资源。

请参考以下 QuicConnection 示例代码:

using System.Net.Quic;

// First, check if QUIC is supported.
if (!QuicConnection.IsSupported)
{
    Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
    return;
}

// This represents the minimal configuration necessary to open a connection.
var clientConnectionOptions = new QuicClientConnectionOptions
{
    // End point of the server to connect to.
    RemoteEndPoint = listener.LocalEndPoint,

    // Used to abort stream if it's not properly closed by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.

    // Used to close the connection if it's not done by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.

    // Optionally set limits for inbound streams.
    MaxInboundUnidirectionalStreams = 10,
    MaxInboundBidirectionalStreams = 100,

    // Same options as for client side SslStream.
    ClientAuthenticationOptions = new SslClientAuthenticationOptions
    {
        // List of supported application protocols.
        ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
        // The name of the server the client is trying to connect to. Used for server certificate validation.
        TargetHost = ""
    }
};

// Initialize, configure and connect to the server.
var connection = await QuicConnection.ConnectAsync(clientConnectionOptions);

Console.WriteLine($"Connected {connection.LocalEndPoint} --> {connection.RemoteEndPoint}");

// Open a bidirectional (can both read and write) outbound stream.
// Opening a stream reserves it but does not notify the peer or send any data. If you don't send data, the peer
// won't be informed about the stream, which can cause AcceptInboundStreamAsync() to hang. To avoid this, ensure
// you send data on the stream to properly initiate communication.
var outgoingStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);

// Work with the outgoing stream ...

// To accept any stream on a client connection, at least one of MaxInboundBidirectionalStreams or MaxInboundUnidirectionalStreams of QuicConnectionOptions must be set.
while (isRunning)
{
    // Accept an inbound stream.
    var incomingStream = await connection.AcceptInboundStreamAsync();

    // Work with the incoming stream ...
}

// Close the connection with the custom code.
await connection.CloseAsync(0x0C);

// Dispose the connection.
await connection.DisposeAsync();

有关 QuicConnection 的设计方式的详细信息,请参阅 API 建议

QuicStream

QuicStream 是 QUIC 协议中用于发送和接收数据的实际类型。 它派生自普通 Stream,具有上述功能,但它还提供几个特定于 QUIC 协议的功能。 首先,QUIC 流可以是单向流或双向流,请参阅 RFC 9000 第 2.1 节。 双向流能够在两端发送和接收数据,而单向流只能从发起端写入并在接受端读取。 每个对等方都可以限制愿意接受的每种类型的并发流的数量,请参阅 MaxInboundBidirectionalStreamsMaxInboundUnidirectionalStreams

QUIC 流的另一个特殊功能是能够在使用流的过程中显式关闭写入端,请参阅 CompleteWrites() 或带有 completeWrites 参数的 WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken) 重载。 关闭写入端可以让对等方知道不会再收到任何数据,但对等方仍可继续发送(在使用双向流的情况下)。 在客户端发送请求并关闭写入端以告知服务器请求内容发送完成时的 HTTP 请求/响应更改等方案中,这非常有用。 在此之后,服务器仍能够发送响应,但已了解不会再收到来自客户端的任何数据。 如果出现错误,可以中止流的写入或读取端,请参阅 Abort(QuicAbortDirection, Int64)

注意

打开流仅保留它而不发送任何数据。 这种方法旨在通过避免传输几乎为空的帧来优化网络使用。 由于在发送实际数据之前不会通知对等方,因此从对等方的角度来看,流仍然处于非活动状态。 如果不发送数据,对等方将无法识别流,这可能会导致 AcceptInboundStreamAsync() 在等待有意义的流时挂起。 为了确保正确的通信,你需要在打开流后发送数据。

下表汇总了适用于每种流类型的各种方法的行为(请注意,客户端和服务器都可以打开和接受流):

方法 对等方打开的流 对等方接受的流
CanRead 双向:true
单向:false
true
CanWrite true 双向:true
单向:false
ReadAsync 双向:读取数据
单向:InvalidOperationException
读取数据
WriteAsync 发送数据 => 对等方读取返回数据 双向:发送数据 => 对等方读取返回数据
单向:InvalidOperationException
CompleteWrites 关闭写入端 => 对等方读取返回 0 双向:关闭写入端 => 对等方读取返回 0
单向:无操作
Abort(QuicAbortDirection.Read) 双向:STOP_SENDING => 对等方写入引发 QuicException(QuicError.OperationAborted)
单向:无操作
STOP_SENDING => 对等方写入引发 QuicException(QuicError.OperationAborted)
Abort(QuicAbortDirection.Write) RESET_STREAM => 对等方读取引发 QuicException(QuicError.OperationAborted) 双向:RESET_STREAM => 对等方读取引发 QuicException(QuicError.OperationAborted)
单向:无操作

在这些方法的基础上,QuicStream 提供两个专用属性,用于在流的读取或写入端已关闭时收到通知:ReadsClosedWritesClosed。 无论是成功还是中止,这两个属性都会返回在其相应端关闭时完成的 Task,在中止情况下,Task 将包含相应的异常。 当用户代码需要在不发出对 ReadAsyncWriteAsync 的调用的情况下收到关于流端的关闭通知时,这些属性非常有用。

最后,完成流处理后,需要使用 DisposeAsync() 将其释放。 释放操作将确保读取端和/或写入端(具体取决于流类型)已关闭。 如果直到结束还未正确读取流,则释放操作将发出 Abort(QuicAbortDirection.Read) 的等效项。 但是,如果流写入端尚未关闭,则会像使用 CompleteWrites一样将其正常关闭。 形成这种差异的原因是确保使用普通 Stream 的方案按预期运行,并最终取得成功。 请看下面的示例:

// Work done with all different types of streams.
async Task WorkWithStreamAsync(Stream stream)
{
    // This will dispose the stream at the end of the scope.
    await using (stream)
    {
        // Simple echo, read data and send them back.
        byte[] buffer = new byte[1024];
        int count = 0;
        // The loop stops when read returns 0 bytes as is common for all streams.
        while ((count = await stream.ReadAsync(buffer)) > 0)
        {
            await stream.WriteAsync(buffer.AsMemory(0, count));
        }
    }
}

// Open a QuicStream and pass to the common method.
var quicStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);
await WorkWithStreamAsync(quicStream);

客户端方案中 QuicStream 的示例用法:

// Consider connection from the connection example, open a bidirectional stream.
await using var stream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional, cancellationToken);

// Send some data.
await stream.WriteAsync(data, cancellationToken);
await stream.WriteAsync(data, cancellationToken);

// End the writing-side together with the last data.
await stream.WriteAsync(data, completeWrites: true, cancellationToken);
// Or separately.
stream.CompleteWrites();

// Read data until the end of stream.
while (await stream.ReadAsync(buffer, cancellationToken) > 0)
{
    // Handle buffer data...
}

// DisposeAsync called by await using at the top.

以及服务器方案中 QuicStream 的示例用法:

// Consider connection from the connection example, accept a stream.
await using var stream = await connection.AcceptInboundStreamAsync(cancellationToken);

if (stream.Type != QuicStreamType.Bidirectional)
{
    Console.WriteLine($"Expected bidirectional stream, got {stream.Type}");
    return;
}

// Read the data.
while (stream.ReadAsync(buffer, cancellationToken) > 0)
{
    // Handle buffer data...

    // Client completed the writes, the loop might be exited now without another ReadAsync.
    if (stream.ReadsCompleted.IsCompleted)
    {
        break;
    }
}

// Listen for Abort(QuicAbortDirection.Read) from the client.
var writesClosedTask = WritesClosedAsync(stream);
async ValueTask WritesClosedAsync(QuicStream stream)
{
    try
    {
        await stream.WritesClosed;
    }
    catch (Exception ex)
    {
        // Handle peer aborting our writing side ...
    }
}

// DisposeAsync called by await using at the top.

有关 QuicStream 的设计方式的详细信息,请参阅 API 建议

另请参阅