Partilhar via


Protocolo QUIC

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 do TLS 1.3. Para obter mais informações, consulte RFC 9001. Outra diferença interessante em relação a protocolos de transporte bem conhecidos, como TCP e UDP, é que ele tem multiplexação de fluxo integrada na camada de transporte. Isso permite ter fluxos de dados múltiplos, 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. É bastante usado em protocolos de camada de aplicação, por exemplo, em HTTP/3 ou em SMB sobre QUIC. Ele também pode ser usado para qualquer protocolo personalizado.

O protocolo oferece muitas vantagens sobre o TCP com TLS, aqui estão algumas:

  • Estabelecimento de conexão mais rápido, pois não requer tantas viagens de ida e volta quanto TCP com TLS no topo.
  • Evitar o problema de bloqueio de cabeça de linha em que um pacote perdido não bloqueia dados de todos os outros fluxos.

Por outro lado, existem potenciais desvantagens a considerar ao usar o QUIC. Como um protocolo mais recente, sua adoção ainda está crescendo e limitada. Além disso, o tráfego QUIC pode até ser bloqueado por alguns componentes de rede.

QUIC em .NET

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

Nota

No .NET 7.0 e 8.0, as APIs foram publicadas como recursos de visualização. A partir do .NET 9, essas APIs não são mais consideradas recursos de visualização e agora são consideradas estáveis.

Do ponto de vista da implementação, System.Net.Quic depende do MsQuic, a implementação nativa do protocolo QUIC. Como resultado, System.Net.Quic o suporte e as dependências da plataforma são herdados do MsQuic e documentados na seção Dependências da plataforma. Em resumo, a biblioteca MsQuic é fornecida como parte do .NET para Windows. Mas para Linux, você deve instalar libmsquic manualmente através de um gerenciador de pacotes apropriado. Para as outras plataformas, ainda é possível construir o MsQuic manualmente, seja contra SChannel ou OpenSSL, e usá-lo com System.Net.Quico . No entanto, esses cenários não fazem parte da nossa matriz de testes e problemas imprevistos podem ocorrer.

Dependências da plataforma

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

Windows

  • Windows 11, Windows Server 2022 ou posterior. (Versões anteriores do Windows estão faltando as APIs criptográficas necessárias para suportar QUIC.)

No Windows, o msquic.dll é distribuído como parte do tempo de execução do .NET e nenhuma outra etapa é necessária para instalá-lo.

Linux

Nota

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

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

Instalação libmsquic a partir do repositório oficial de pacotes Linux da Microsoft

Você deve adicionar esse repositório ao seu gerenciador de pacotes antes de instalar o pacote. Para obter mais informações, consulte Linux Software Repository for Microsoft Products.

Atenção

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 sua distribuição fornece .NET e outros pacotes da Microsoft. Para evitar ou solucionar problemas de misturas de pacotes, consulte 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
    

Instalação libmsquic a partir do repositório do pacote 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 seguintes
apk add libmsquic
  • Alpine 3.20 e mais antigo
# 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 libmsquic pacote e são instaladas automaticamente pelo gerenciador de pacotes:

  • OpenSSL 3+ 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.

  • Libnuma1

macOS

O QUIC agora é parcialmente suportado no macOS através de um gerenciador de pacotes Homebrew não padrão com algumas limitações. Você pode instalar libmsquic no macOS usando o Homebrew com o seguinte comando:

brew install libmsquic

Para executar um aplicativo .NET que usa libmsquico , você precisa definir a variável de ambiente antes de executá-lo. Isso garante que o aplicativo possa encontrar a biblioteca durante o libmsquic carregamento dinâmico em tempo de execução. Você pode fazer isso adicionando o seguinte comando antes do comando principal:

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

ou

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

Como alternativa, você pode definir a variável de ambiente com:

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

e, em seguida, execute o comando principal:

./binaryname

Descrição Geral da API

System.Net.Quic traz três classes principais que permitem o uso do protocolo QUIC:

Mas antes de usar essas classes, seu código deve verificar se o QUIC é suportado atualmente, como libmsquic pode estar faltando, ou se o TLS 1.3 pode não ser suportado. Para isso, ambos e QuicListener QuicConnection expor uma propriedade IsSupportedestática :

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 os cenários de servidor e IsSupported os de cliente.

QuicListener

QuicListener Representa uma classe do lado do servidor que aceita conexões de entrada dos clientes. O ouvinte é construído e iniciado com um método ListenAsync(QuicListenerOptions, CancellationToken)estático. O método aceita uma instância de classe com todas as configurações necessárias para iniciar o ouvinte e aceitar conexões de QuicListenerOptions entrada. Depois disso, o ouvinte está pronto para distribuir conexões via AcceptConnectionAsync(CancellationToken). As conexões retornadas por esse método estão sempre totalmente conectadas, o que significa que o handshake TLS está concluído e a conexão está pronta para ser usada. Finalmente, para parar de ouvir e liberar todos os recursos, DisposeAsync() deve ser chamado.

Considere o seguinte QuicListener código de exemplo:

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 obter mais informações sobre como o QuicListener foi projetado, consulte a proposta de API.

QuicConnection

QuicConnection é uma classe usada para conexões QUIC do lado do servidor e do cliente. As conexões do lado do servidor são criadas internamente pelo ouvinte e distribuídas via AcceptConnectionAsync(CancellationToken). As conexões do lado do cliente devem ser abertas e conectadas ao servidor. Assim como acontece com o ouvinte, há um método ConnectAsync(QuicClientConnectionOptions, CancellationToken) estático que instancia e conecta 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. Ele também fornece propriedades com informações sobre a conexão, como LocalEndPoint, RemoteEndPoint, ou RemoteCertificate.

Quando o trabalho com a conexão é feito, ele precisa ser fechado e descartado. Mandatos de protocolo QUIC usando um código de camada de aplicativo para fechamento imediato, consulte RFC 9000 Seção 10.2. Para isso, CloseAsync(Int64, CancellationToken) com o código da camada de aplicação pode ser chamado ou, se não, DisposeAsync() usará o código fornecido em DefaultCloseErrorCode. De qualquer forma, DisposeAsync() deve ser chamado no final do trabalho com a conexão para liberar totalmente todos os recursos associados.

Considere o seguinte QuicConnection código de exemplo:

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 obter mais informações sobre como o QuicConnection foi projetado, consulte a proposta de API.

QuicStream

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

Outra particularidade do fluxo QUIC é a capacidade de fechar explicitamente o lado da escrita no meio do trabalho com o fluxo, ver CompleteWrites() ou WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken) sobrecarregar com completeWrites argumento. O fechamento do lado de escrita permite que o peer saiba que não chegarão mais dados, mas o peer ainda pode continuar enviando (no caso de um fluxo bidirecional). Isso é útil em cenários como troca de solicitação/resposta HTTP quando o cliente envia a solicitação e fecha o lado de gravação para informar ao servidor que este é o fim do conteúdo da solicitação. O servidor ainda é capaz de enviar a resposta depois disso, mas sabe que não chegarão mais dados do cliente. E para casos errôneos, o lado da escrita ou da leitura do fluxo pode ser abortado, veja Abort(QuicAbortDirection, Int64).

Nota

Abrir um fluxo apenas o 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, o fluxo permanece inativo da perspetiva do par. Se você não enviar dados, o correspondente não reconhecerá o fluxo, o que pode fazer com AcceptInboundStreamAsync() que ele trave enquanto espera por um fluxo significativo. Para garantir a comunicação adequada, você precisa enviar dados depois de abrir o fluxo.

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

Método Fluxo de abertura de pares Fluxo de aceitação de pares
CanRead Bidirecional: true
unidirecional: false
true
CanWrite true Bidirecional: true
unidirecional: false
ReadAsync Bidirecional: lê dados
unidirecional: InvalidOperationException
lê dados
WriteAsync envia dados => peer read retorna os dados bidirecional: envia dados => peer read retorna os dados
unidirecional: InvalidOperationException
CompleteWrites fecha o lado de escrita => peer read retorna 0 bidirecional: fecha o lado da escrita => a leitura por pares retorna 0
unidirecional: no-op
Abort(QuicAbortDirection.Read) bidirecional: STOP_SENDING => lances de escrita entre pares QuicException(QuicError.OperationAborted)
unidirecional: no-op
STOP_SENDING => lances de escrita de paresQuicException(QuicError.OperationAborted)
Abort(QuicAbortDirection.Write) RESET_STREAM => lances de leitura por paresQuicException(QuicError.OperationAborted) bidirecional: RESET_STREAM => lances de leitura entre pares QuicException(QuicError.OperationAborted)
unidirecional: no-op

Além desses métodos, QuicStream oferece duas propriedades especializadas para ser notificado sempre que o lado de leitura ou gravação do fluxo tiver sido fechado: ReadsClosed e WritesClosed. Ambos retornam um Task que completa com seu lado correspondente sendo fechado, seja sucesso ou abortamento, caso em que o testamento Task conterá exceção apropriada. Essas propriedades são úteis quando o código do usuário precisa saber sobre o fechamento do lado do fluxo sem emitir chamada para ReadAsync ou WriteAsync.

Finalmente, quando o trabalho com o fluxo é feito, ele precisa ser descartado com DisposeAsync(). A eliminação assegurará que o lado de leitura e/ou escrita - dependendo do tipo de fluxo - está fechado. Se o fluxo não tiver sido lido corretamente até o final, o descarte emitirá um equivalente a Abort(QuicAbortDirection.Read). No entanto, se o lado de escrita do fluxo não tiver sido fechado, ele será graciosamente fechado como seria com CompleteWriteso . A razão para essa diferença é garantir que os cenários que trabalham com um comum Stream se comportem como esperado e levem a um caminho bem-sucedido. Considere o seguinte exemplo:

// 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 uso de exemplo de no cenário de QuicStream 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 do cenário no QuicStream 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 obter mais informações sobre como o QuicStream foi projetado, consulte a proposta de API.

Consulte também