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:
- QuicListener – classe do servidor para aceitar conexões de entrada.
- QuicConnection – conexão QUIC, correspondente à seção 5 do RFC 9000.
- QuicStream – fluxo QUIC, correspondente à seção 2 do RFC 9000.
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.