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.Quic
o . 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 libmsquic
o , 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:
- QuicListener - Classe do lado do servidor para aceitar conexões de entrada.
- QuicConnection - Conexão QUIC, correspondente à RFC 9000 Seção 5.
- QuicStream - Fluxo QUIC, correspondente à RFC 9000 Secção 2.
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 IsSupported
está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 CompleteWrites
o . 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.