Compartilhar via


Protocolo QUIC

O QUIC é um protocolo de camada de transporte de rede padronizado em RFC 9000. Ele usa UDP como um protocolo subjacente e é inerentemente seguro, pois exige o uso de TLS 1.3. Para saber mais, confira RFC 9001. Outra diferença interessante entre ele e os protocolos de transporte conhecidos, como TCP e UDP, é que ele conta com multiplexação de fluxo integrada na camada de transporte. Isso permite ter diversos fluxos de dados simultâneos e independentes que não afetam uns aos outros.

O próprio QUIC não define nenhuma semântica para os dados trocados, pois é um protocolo de transporte. Ele é bastante usado em protocolos de camada de aplicativo, por exemplo, em HTTP/3 ou SMB sobre QUIC. Também pode ser usado para qualquer protocolo definido de maneira personalizada.

O protocolo oferece muitas vantagens com relação ao TCP com TLS, como as seguintes:

  • Estabelecimento de conexão mais rápido, pois não requer tantas viagens de ida e volta quanto TCP com TLS.
  • Evita o problema de bloqueio HOL (bloqueio da fila de recebimento), em que um pacote perdido não bloqueia os dados de todos os outros fluxos.

Por outro lado, há possíveis desvantagens a serem consideradas ao usar QUIC. Por ser um protocolo mais recente, sua adoção ainda não é abrangente e está em ampliação. Além disso, o tráfego QUIC pode até mesmo ser bloqueado por alguns componentes de rede.

QUIC no .NET

A implementação do QUIC foi introduzida no .NET 5 como a biblioteca System.Net.Quic. No entanto, até o .NET 7, a biblioteca era estritamente interna e servia somente como uma implementação de HTTP/3. Com o .NET 7, ela se tornou pública, expondo as APIs.

Observação

No .NET 7.0 e 8.0, as APIs foram publicadas como versão prévia do recurso. A partir do .NET 9, essas APIs não são mais consideradas versão prévia do recurso, sendo agora tidas como estáveis.

Do ponto de vista da implementação, o System.Net.Quic depende do MsQuic, a implementação nativa do protocolo QUIC. Como resultado, o suporte e as dependências da plataforma System.Net.Quic são herdados da MsQuic e documentados na seção Dependências da plataforma. Resumindo, a biblioteca MsQuic é fornecida como parte do .NET para Windows. Porém, no caso do Linux, é preciso instalar libmsquic manualmente por meio de um gerenciador de pacotes apropriado. Para as outras plataformas, ainda é possível compilar a MsQuic manualmente, seja no SChannel ou no OpenSSL, e usá-la com System.Net.Quic. Porém, esses cenários não fazem parte da matriz de testes e imprevistos podem ocorrer.

Dependências de plataforma

As seções a seguir descrevem as dependências da plataforma para QUIC no .NET.

Windows

  • Windows 11, Windows Server 2022 ou posterior. (Versões anteriores do Windows não têm as APIs criptográficas necessárias para dar suporte ao QUIC.)

No Windows, msquic.dll é distribuído como parte do runtime do .NET e nenhuma outra etapa é necessária para instalá-lo.

Linux

Observação

O .NET 7+ só é compatível com versões 2.2+ do libmsquic.

O pacote libmsquic é necessário no Linux. Este pacote é publicado no repositório https://packages.microsoft.com oficial de pacotes Linux da Microsoft e também está disponível em alguns repositórios oficiais, como o Alpine Packages - libmsquic.

Instalando libmsquic a partir do repositório oficial de pacotes Linux da Microsoft

Você deve adicionar esse repositório ao gerenciador de pacotes antes de instalar o pacote. Para obter mais informações, consulte Repositório de Software do Linux para Produtos da Microsoft.

Cuidado

Adicionar o repositório de pacotes da Microsoft pode entrar em conflito com o repositório da sua distribuição quando o repositório da distribuição fornece .NET e outros pacotes da Microsoft. Para evitar ou solucionar problemas de combinações de pacotes, examine Solucionar problemas de erros do .NET relacionados a arquivos ausentes no Linux.

Exemplos

Aqui estão alguns exemplos de como usar um gerenciador de pacotes para instalar 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
    

Instalando libmsquic a partir do Repositório de Pacotes de Distribuição

A instalação libmsquic a partir do repositório de pacotes de distribuição também é possível, mas atualmente isso só está disponível para Alpine.

Exemplos

Aqui estão alguns exemplos de como usar um gerenciador de pacotes para instalar libmsquic:

  • Alpine 3.21 e posterior
apk add libmsquic
  • Alpine 3.20 e versões anteriores
# Get libmsquic from community repository edge branch.
apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ libmsquic
Dependências do libmsquic

Todas as seguintes dependências são declaradas no manifesto do pacote libmsquic e instaladas automaticamente pelo gerenciador de pacotes:

  • OpenSSL 3 e posterior ou 1.1: depende da versão padrão do OpenSSL para a versão de distribuição, por exemplo, OpenSSL 3 para Ubuntu 22 e OpenSSL 1.1 para Ubuntu 20.

  • libnuma 1

macOS

No momento, não há suporte para o QUIC no macOS, mas ele pode estar disponível em uma versão futura.

Visão geral da API

O System.Net.Quic oferece três grandes classes que possibilitam o uso do protocolo QUIC:

No entanto, antes de usar essas classes, o código deve verificar se o QUIC é compatível no momento, pois libmsquic pode estar ausente ou o TLS 1.3 pode não ser compatível. Para isso, tanto QuicListener quanto QuicConnection expõem uma propriedade estática IsSupported:

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

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

Essas propriedades relatarão o mesmo valor, mas isso pode mudar no futuro. Recomenda-se verificar IsSupported para os cenários de servidor e IsSupported para os de cliente.

QuicListener

QuicListener representa uma classe do servidor que aceita conexões de entrada dos clientes. O ouvinte é criado e iniciado com um método estático ListenAsync(QuicListenerOptions, CancellationToken). O método aceita uma instância da classe QuicListenerOptions com todas as configurações necessárias para iniciar o ouvinte e aceitar conexões de entrada. Depois disso, o ouvinte está pronto para realizar conexões via AcceptConnectionAsync(CancellationToken). As conexões retornadas por este método são sempre totalmente estabelecidas, o que significa que o handshake TLS está finalizado e a conexão está pronta para ser utilizada. Finalmente, para interromper a escuta e liberar todos os recursos, chame DisposeAsync().

Considere o seguinte código de exemplo 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();

Para saber como o QuicListener foi projetado, confira a proposta da API.

QuicConnection

QuicConnection é uma classe usada para conexões QUIC do servidor e do cliente. As conexões do servidor são criadas internamente pelo ouvinte e realizadas via AcceptConnectionAsync(CancellationToken). As conexões do cliente devem ser abertas e estabelecidas com o servidor. Assim como o ouvinte, há um método estático ConnectAsync(QuicClientConnectionOptions, CancellationToken) que instancia e estabelece a conexão. Ele aceita uma instância de QuicClientConnectionOptions, uma classe análoga a QuicServerConnectionOptions. Depois disso, o trabalho com a conexão não difere entre cliente e servidor. Ele pode abrir fluxos de saída e aceitar os de entrada. Também fornece propriedades com informações sobre a conexão, como LocalEndPoint, RemoteEndPoint ou RemoteCertificate.

Quando o trabalho com a conexão é concluído, ele deve ser encerrado e descartado. O protocolo QUIC exige o uso de um código de camada de aplicativo para o encerramento imediato. Para saber mais, confira a seção 10.2 do RFC 9000. Para isso, é possível chamar CloseAsync(Int64, CancellationToken) com o código da camada de aplicativo. Caso contrário, o DisposeAsync() usará o código fornecido em DefaultCloseErrorCode. De qualquer forma, o DisposeAsync() deve ser chamado ao final do trabalho com a conexão para liberar totalmente todos os recursos associados.

Considere o seguinte código de exemplo 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();

para saber como o QuicConnection foi projetado, confira a proposta de API.

QuicStream

QuicStream é o tipo real usado para enviar e receber dados no protocolo QUIC. Ele deriva do Stream comum e pode ser usado como tal, mas também oferece diversos recursos específicos do protocolo QUIC. Em primeiro lugar, um fluxo QUIC pode ser unidirecional ou bidirecional. Para saber mais, confira a seção 2.1 do RFC 9000. Um fluxo bidirecional é capaz de enviar e receber dados em ambos os lados, enquanto o fluxo unidirecional só pode gravar no lado inicial e ler no lado receptor. Cada par pode limitar quantos fluxos simultâneos de cada tipo está disposto a aceitar. Para saber mais, confira MaxInboundBidirectionalStreams e MaxInboundUnidirectionalStreams.

Outra particularidade do fluxo QUIC é a capacidade de encerrar explicitamente o lado da gravação no meio do trabalho com o fluxo. Para saber mais, confira a sobrecarga de CompleteWrites() ou WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken) com o argumento completeWrites. O encerramento do lado da gravação permite que o par saiba que não chegarão mais dados, mas ele ainda pode continuar realizando envios (no caso de um fluxo bidirecional). Isso é útil em cenários como trocas de solicitação/resposta HTTP quando o cliente envia a solicitação e encerra o lado de gravação para informar ao servidor que este é o fim do conteúdo da solicitação. O servidor ainda pode enviar a resposta depois disso, mas sabe que nenhum outro dado chegará do cliente. Nos casos errôneos, o lado de gravação ou de leitura do fluxo pode ser anulado. Para saber mais, confira Abort(QuicAbortDirection, Int64).

Observação

Abrir uma transmissão, somente a reserva, sem enviar nenhum dado. Essa abordagem foi projetada para otimizar o uso da rede, evitando a transmissão de quadros quase vazios. Como o par não é notificado até que os dados reais sejam enviados, a transmissão permanece inativa da perspectiva do par. Se você não enviar dados, o par não reconhecerá a transmissão, o que pode fazer com que o AcceptInboundStreamAsync() fique travado enquanto aguarda uma transmissão significante. Para garantir uma comunicação adequada, é necessário enviar dados após abrir a transmissão.

O comportamento dos métodos individuais para cada tipo de fluxo é resumido na tabela a seguir (observe que tanto o cliente quanto o servidor podem abrir e aceitar fluxos):

Método Fluxo de abertura de par Fluxo de aceitação de par
CanRead bidirecional: true
unidirecional: false
true
CanWrite true bidirecional: true
unidirecional: false
ReadAsync bidirecional: lê os dados
unidirecional: InvalidOperationException
lê os dados
WriteAsync envia os dados = a leitura do par > retorna os dados bidirecional: envia os dados = a leitura do par > retorna os dados
unidirecional: InvalidOperationException
CompleteWrites encerra o lado da gravação = a leitura do par > retorna 0 bidirecional: encerra o lado da gravação = a leitura do par > retorna 0
unidirecional: sem operação
Abort(QuicAbortDirection.Read) bidirecional: STOP_SENDING = a gravação do par > gera QuicException(QuicError.OperationAborted)
unidirecional: sem operação
STOP_SENDING = a gravação do par > gera QuicException(QuicError.OperationAborted)
Abort(QuicAbortDirection.Write) RESET_STREAM = a leitura do par > gera QuicException(QuicError.OperationAborted) bidirecional: RESET_STREAM = a leitura do par > gera QuicException(QuicError.OperationAborted)
unidirecional: sem operação

Além desses métodos, o QuicStream oferece duas propriedades especializadas para que você seja notificado sempre que o lado de leitura ou de gravação do fluxo for encerrado: ReadsClosed e WritesClosed. Ambas retornam um Task que é concluído com o encerramento do lado correspondente, seja a operação bem-sucedida ou uma anulação, caso em que o Task conterá a exceção apropriada. Essas propriedades são úteis quando o código do usuário precisa saber sobre o encerramento do fluxo sem a emissão de uma chamada para ReadAsync ou WriteAsync.

Finalmente, quando o trabalho com o fluxo é encerrado, ele precisa ser descartado com DisposeAsync(). O descarte garante que o lado de leitura e/ou de gravação, dependendo do tipo de fluxo, seja encerrado. Se o fluxo não tiver sido lido corretamente até o final, o descarte emitirá um equivalente de Abort(QuicAbortDirection.Read). No entanto, se o lado de gravação do fluxo não tiver sido encerrado, isso acontecerá normalmente, como no caso de CompleteWrites. O motivo dessa diferença é garantir que os cenários que trabalham com um Stream comum se comportem conforme o esperado e levem a um caminho de sucesso. Considere o exemplo a seguir:

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

O exemplo de uso de QuicStream no cenário do cliente:

// 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.

E o exemplo de uso de QuicStream no cenário do servidor:

// 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.

Para saber como o QuicStream foi projetado, confira a proposta de API.

Confira também