QUIC-Protokoll

QUIC ist ein in RFC 9000 standardisiertes Netzwerktransportschichtprotokoll. Es verwendet UDP als zugrunde liegendes Protokoll und ist grundsätzlich sicher, da es die Verwendung von TLS 1.3 vorschreibt. Weitere Informationen finden Sie unter RFC 9001. Ein weiterer interessanter Unterschied zu bekannten Transportprotokollen wie TCP und UDP besteht darin, dass hier in der Transportschicht Stream-Multiplexing integriert ist. Dadurch können mehrere unabhängige Datenströme gleichzeitig vorhanden sein, die sich nicht gegenseitig beeinflussen.

Da es sich bei QUIC um ein Transportprotokoll handelt, definiert es selbst keine Semantik für die ausgetauschten Daten. Es wird eher in Anwendungsschichtprotokollen verwendet, z. B. in HTTP/3 oder in SMB over QUIC. Es kann auch für jedes benutzerdefinierte Protokoll verwendet werden.

Das Protokoll bietet viele Vorteile gegenüber TCP mit TLS, darunter folgende:

  • Schnelleren Verbindungsaufbau, da nicht so viele Roundtrips erforderlich sind wie bei TCP und zusätzlich TLS.
  • Vermeidung des Problems „Head-of-Line Blocking“ (Blockierung durch den Anfang der Schlange), sodass ein verloren gegangenes Paket nicht die Daten aller anderen Datenströme blockiert.

Andererseits gibt es potenzielle Nachteile, die bei der Verwendung von QUIC berücksichtigt werden müssen. Da es sich hierbei um ein neueres Protokoll handelt, ist die Akzeptanz noch begrenzt und nimmt erst allmählich zu. Abgesehen davon kann QUIC-Datenverkehr sogar von einigen Netzwerkkomponenten blockiert werden.

QUIC in .NET

Die QUIC-Implementierung wurde in .NET 5 als System.Net.Quic-Bibliothek eingeführt. Bis .NET 7.0 war die Bibliothek jedoch ausschließlich intern und diente lediglich der Implementierung von HTTP/3. Mit .NET 7 wurde die Bibliothek damit die APIs veröffentlicht.

Hinweis

In .NET 7.0 wurden die APIs in Form von Previewfunktionen veröffentlicht.

Aus Implementierungsperspektive hängt System.Net.Quic von MsQuic ab. Hierbei handelt es sich um die native Implementierung des QUIC-Protokolls. Daher werden System.Net.Quic-Plattformunterstützung und -Abhängigkeiten von MsQuic geerbt und im Abschnitt Plattformabhängigkeiten dokumentiert. Kurz gesagt, die MsQuic-Bibliothek wird als Teil von .NET für Windows bereitgestellt. Für Linux müssen Sie libmsquic jedoch manuell über einen entsprechenden Paket-Manager installieren. Für die anderen Plattformen ist es weiterhin möglich, MsQuic manuell zu erstellen, ob für SChannel oder OpenSSL, und mit System.Net.Quic zu verwenden. Diese Szenarios sind jedoch nicht Teil unserer Testmatrix und möglicherweise können unvorhergesehene Probleme auftreten.

Plattformabhängigkeiten

In den folgenden Abschnitten werden die Plattformabhängigkeiten für QUIC in .NET beschrieben.

Windows

  • Windows 11, Windows Server 2022 oder höher. (In früheren Windows-Versionen fehlen die kryptografischen APIs, die zur Unterstützung von QUIC erforderlich sind.)

Unter Windows wird „msquic.dll“ als Teil der .NET-Runtime verteilt, und es sind keine weiteren Schritte erforderlich, um sie zu installieren.

Linux

Hinweis

.NET 7 ist nur mit den Versionen 2.2 und höher von libmsquic kompatibel.

Das libmsquic-Paket ist unter Linux erforderlich. Dieses Paket wird im offiziellen Linux-Paketrepository https://packages.microsoft.com von Microsoft veröffentlicht. Sie müssen dieses Repository dem Paket-Manager hinzufügen, bevor Sie das Paket installieren. Weitere Informationen finden Sie unter Linux-Softwarerepository für Microsoft-Produkte.

Achtung

Das Hinzufügen des Microsoft-Package-Repositorys kann zu Konflikten mit dem Repository Ihrer Distribution führen, wenn das Repository Ihrer Distribution .NET und andere Microsoft-Pakete bereitstellt. Um Paketverwechslungen zu vermeiden oder zu beheben, lesen Sie Problembehandlung von .NET-Fehlern im Zusammenhang mit fehlenden Dateien unter Linux.

Beispiele

Hier sind einige Beispiele für die Verwendung eines Paket-Managers zur Installation von libmsquic:

  • APT

    sudo apt-get libmsquic 
    
  • APK

    sudo apk add libmsquic
    
  • DNF

    sudo dnf install libmsquic
    
  • zypper

    sudo zypper install libmsquic
    
  • YUM

    sudo yum install libmsquic
    
Abhängigkeiten von libmsquic

Alle folgenden Abhängigkeiten werden im libmsquic-Paketmanifest angegeben und vom Paket-Manager automatisch installiert:

  • OpenSSL 3+ oder 1.1: hängt von der Standardversion von OpenSSL für die Verteilungsversion ab, z. B. OpenSSL 3 für Ubuntu 22 und OpenSSL 1.1 für Ubuntu 20.

  • libnuma 1

macOS

QUIC wird derzeit unter macOS nicht unterstützt, aber möglicherweise ist es in einer zukünftigen Version verfügbar.

API-Übersicht

System.Net.Quic enthält drei Hauptklassen, mit deren Hilfe das QUIC-Protokoll verwendet werden kann:

Bevor Sie diese Klassen verwenden, sollte Ihr Code jedoch überprüfen, ob QUIC derzeit unterstützt wird, da libmsquic möglicherweise fehlt oder TLS 1.3 unter Umständen nicht unterstützt wird. Hierfür enthält sowohl QuicListener als auch QuicConnection die statische Eigenschaft IsSupported:

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

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

Diese Eigenschaften melden denselben Wert. Dies kann sich jedoch in Zukunft ändern. Es wird empfohlen, IsSupported für Serverszenarios und IsSupported für Clientszenarios zu überprüfen.

QuicListener

QuicListener stellt eine serverseitige Klasse dar, die eingehende Verbindungen von den Clients akzeptiert. Der Listener wird erstellt und mit der statischen Methode ListenAsync(QuicListenerOptions, CancellationToken) gestartet. Die Methode akzeptiert eine Instanz der QuicListenerOptions-Klasse mit allen Einstellungen, die erforderlich sind, um den Listener zu starten und eingehende Verbindungen zu akzeptieren. Danach ist der Listener bereit, Verbindungen über AcceptConnectionAsync(CancellationToken) zu übergeben. Verbindungen, die von dieser Methode zurückgegeben werden, sind immer vollständig verbunden, was bedeutet, dass der TLS-Handshake abgeschlossen ist und die Verbindung verwendet werden kann. Schließlich muss DisposeAsync() aufgerufen werden, um die Überwachung zu beenden und alle Ressourcen freizugeben.

Betrachten Sie den folgenden QuicListener-Beispielcode:

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
    {
        // List of supported application protocols, must be the same or subset of QuicListenerOptions.ApplicationProtocols.
        ApplicationProtocols = new List<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
{
    // Listening endpoint, port 0 means any port.
    ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
    // List of all supported application protocols by this listener.
    ApplicationProtocols = new List<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();

Weitere Informationen zur Gestaltung des QuicListener finden Sie unter API Proposal.

QuicConnection

QuicConnection ist eine Klasse, die sowohl für server- als auch für clientseitige QUIC-Verbindungen verwendet wird. Serverseitige Verbindungen werden intern vom Listener erstellt und über AcceptConnectionAsync(CancellationToken) übergeben. Clientseitige Verbindungen müssen geöffnet und mit dem Server verbunden werden. Wie beim Listener gibt es die statische Methode ConnectAsync(QuicClientConnectionOptions, CancellationToken), die die Verbindung instanziiert und verbindet. Sie akzeptiert eine Instanz von QuicClientConnectionOptions, eine analoge Klasse zu QuicServerConnectionOptions. Danach unterscheidet sich die Arbeit mit der Verbindung nicht zwischen Client und Server. Sie kann ausgehende Datenströme öffnen und eingehende Datenströme akzeptieren. Außerdem werden Eigenschaften mit Informationen über die Verbindung bereitgestellt, z. B. LocalEndPoint, RemoteEndPoint oder RemoteCertificate.

Wenn die Arbeit mit der Verbindung abgeschlossen ist, muss sie geschlossen und gelöscht werden. QUIC-Protokollmandanten mit einem Anwendungsschichtcode zum sofortigen Schließen finden Sie unter RFC 9000 Section 10.2. Dazu kann CloseAsync(Int64, CancellationToken) mit Anwendungsschichtcode aufgerufen werden. Wenn nicht, verwendet DisposeAsync() den in DefaultCloseErrorCode bereitgestellten Code. In jedem Fall muss DisposeAsync() am Ende der Arbeit mit der Verbindung aufgerufen werden, um alle zugeordneten Ressourcen vollständig freizugeben.

Betrachten Sie den folgenden QuicConnection-Beispielcode:

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 List<SslApplicationProtocol>() { "protocol-name" }
    }
};

// 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.
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();

Weitere Informationen zur Gestaltung des QuicConnection finden Sie unter API Proposal.

QuicStream

QuicStream ist der tatsächliche Typ, der zum Senden und Empfangen von Daten im QUIC-Protokoll verwendet wird. Er wird vom gewöhnlichen Stream abgeleitet und kann als solcher verwendet werden, bietet aber auch mehrere QUIC-spezifische Features. Erstens kann ein QUIC-Datenstrom entweder unidirektional oder bidirektional sein. Informationen hierzu finden Sie unter RFC 9000 Section 2.1. Ein bidirektionaler Datenstrom kann Daten auf beiden Seiten senden und empfangen, während unidirektionaler Datenstrom nur von der initiierenden Seite aus schreiben und in dem empfangenden Datenstrom lesen kann. Jeder Peer kann die Anzahl der gleichzeitigen Datenströme einschränken, die jeder Typ akzeptieren können soll. Informationen hierzu finden Sie unter MaxInboundBidirectionalStreams und MaxInboundUnidirectionalStreams.

Eine weitere Besonderheit des QUIC-Datenstroms ist die Möglichkeit, die Schreibseite mitten in der Arbeit mit dem Datenstrom explizit zu schließen. Informationen hierzu finden Sie unter CompleteWrites() oder WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken)-Überladung mit dem Argument completeWrites. Das Schließen der Schreibseite teilt dem Peer mit, dass keine weiteren Daten eingehen, wobei der Peer jedoch weiterhin senden kann (im Falle eines bidirektionalen Datenstroms). Dies ist in Szenarios wie dem Austausch von HTTP-Anforderungen/Antworten hilfreich, wenn der Client die Anforderung sendet und die Schreibseite geschlossen wird, um dem Server mitzuteilen, dass dies das Ende des Anforderungsinhalts ist. Der Server kann die Antwort trotzdem senden, weiß jedoch, dass keine weiteren Daten vom Client empfangen werden. Und im Falle eines Fehlers kann die Schreib- oder die Leseseite des Datenstroms geschlossen werden. Informationen hierzu finden Sie unter Abort(QuicAbortDirection, Int64). Das Verhalten der einzelnen Methoden für jeden Datenstromtyp wird in der folgenden Tabelle zusammengefasst (beachten Sie, dass sowohl Client als auch Server Datenströme öffnen und akzeptieren können):

Methode Peer öffnet den Datenstrom Peer akzeptiert den Datenstrom
CanRead bidirektional: true
unidirektional: false
true
CanWrite true bidirektional: true
unidirektional: false
ReadAsync bidirektional: liest Daten
unidirektional: InvalidOperationException
liest Daten
WriteAsync sendet Daten => Peerlesevorgang gibt die Daten zurück bidirektional: sendet Daten => Peerlesevorgang gibt die Daten zurück
unidirektional: InvalidOperationException
CompleteWrites schließt die Schreibseite => Peerlesevorgang gibt 0 zurück bidirektional: schließt Schreibseite => Peerlesevorgang gibt 0 zurück
unidirektional: kein Vorgang
Abort(QuicAbortDirection.Read) bidirektional: STOP_SENDING => Peerschreibvorgang gibt QuicException(QuicError.OperationAborted) aus
unidirektional: kein Vorgang
STOP_SENDING => Peerschreibvorgang gibt QuicException(QuicError.OperationAborted) aus
Abort(QuicAbortDirection.Write) RESET_STREAM => Peerlesevorgang gibt QuicException(QuicError.OperationAborted) aus bidirektional: RESET_STREAM => Peerlesevorgang gibt QuicException(QuicError.OperationAborted) aus
unidirektional: kein Vorgang

Zusätzlich zu diesen Methoden bietet QuicStream zwei spezielle Eigenschaften, um benachrichtigt zu werden, wenn entweder Lese- oder Schreibseiten des Datenstroms geschlossen wurden: ReadsClosed und WritesClosed. Beide geben einen Task zurück, der unabhängig vom Erfolg mit der entsprechenden Seite, die geschlossen wird, ausgeführt wird. Im Fall eines Abbruchs enthält der Task eine entsprechende Ausnahme. Diese Eigenschaften sind nützlich, wenn der Benutzercode über ein datenstromseitiges Schließen ohne Aufruf von ReadAsync oder WriteAsync informiert werden muss.

Wenn die Arbeit mit dem Datenstrom abgeschlossen ist, muss sie schließlich mit DisposeAsync() gelöscht werden. Mit dem Löschen wird sichergestellt, dass je nach Datenstromtyp die Lese- und/oder Schreibseite geschlossen wird. Wenn der Datenstrom bis zum Ende nicht ordnungsgemäß gelesen wurde, wird beim Löschen eine Entsprechung von Abort(QuicAbortDirection.Read) ausgegeben. Wenn die Datenstromschreibseite jedoch nicht geschlossen wurde, wird sie wie bei CompleteWrites einfach geschlossen. Der Grund für diesen Unterschied besteht darin, dass sichergestellt werden soll, dass sich Szenarios mit einem normalen Stream erwartungsgemäß verhalten und zu einem erfolgreichen Pfad führen. Betrachten Sie das folgende Beispiel:

// 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);

Die beispielhafte Verwendung von QuicStream in einem Clientszenario:

// Consider connection from the connection example, open a bidirectional stream.
await using var stream = await connection.OpenStreamAsync(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, endStream: 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.

Und die beispielhafte Verwendung von QuicStream in einem Serverszenario:

// Consider connection from the connection example, accept a stream.
await using var stream = await connection.AcceptStreamAsync(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.

Weitere Informationen zur Gestaltung des QuicStream finden Sie unter API Proposal.

Siehe auch