Поделиться через


Протокол QUIC

QUIC — это протокол сетевого транспортного уровня, стандартизированный в RFC 9000. Он использует UDP в качестве базового протокола, и он по сути безопасно, так как он требует использования TLS 1.3. Дополнительные сведения см. в статье RFC 9001. Еще одно интересное отличие от известных транспортных протоколов, таких как TCP и UDP, заключается в том, что он имеет мультиплексирование потоков, встроенных на транспортном уровне. Это позволяет иметь несколько параллельных, независимых потоков данных, которые не влияют друг на друга.

Сам QUIC не определяет семантику для обменных данных, так как это транспортный протокол. Она используется скорее в протоколах прикладного уровня, например в HTTP/3 или в SMB через QUIC. Его также можно использовать для любого пользовательски-определенного протокола.

Протокол предлагает множество преимуществ перед TCP с TLS, вот некоторые из них:

  • Быстрое установление подключения, так как для него не требуется столько раунд-трипов, чем в случае с TLS поверх TCP.
  • Избежание проблемы блокировки передней части очереди, когда один потерянный пакет не блокирует данные всех остальных потоков.

С другой стороны, существуют потенциальные недостатки, которые следует учитывать при использовании QUIC. Как более новый протокол, его внедрение по-прежнему растет и ограничено. Помимо этого, трафик QUIC может быть даже заблокирован некоторыми сетевыми компонентами.

QUIC в .NET

Реализация QUIC появилась в .NET 5 в качестве библиотеки System.Net.Quic . Однако до .NET 7 библиотека была строго внутренней и служила только реализацией HTTP/3. С помощью .NET 7 библиотека была открыта таким образом, предоставляя свои API.

Примечание.

В .NET 7.0 и 8.0 API были опубликованы в качестве предварительных версий функций. Начиная с .NET 9 эти API больше не считаются предварительными версиями функций и теперь считаются стабильными.

С точки зрения реализации, System.Net.Quic зависит от MsQuic, собственной реализации протокола QUIC. В результате поддержка System.Net.Quic платформы и зависимости наследуются от MsQuic и описаны в разделе зависимостей платформы. Короче говоря, библиотека MsQuic поставляется в составе .NET для Windows. Но для Linux необходимо вручную установить libmsquic с помощью соответствующего диспетчера пакетов. Для других платформ по-прежнему можно создать MsQuic вручную, независимо от SChannel или OpenSSL, и использовать его с System.Net.Quic. Однако эти сценарии не являются частью матрицы тестирования и непредвиденные проблемы могут возникнуть.

Зависимости платформы

В следующих разделах описаны зависимости платформы для QUIC в .NET.

Виндоус

  • Windows 11, Windows Server 2022 или более поздней версии. В более ранних версиях Windows отсутствуют криптографические API, необходимые для поддержки QUIC.

В Windows msquic.dll распространяется в рамках среды выполнения .NET, а для установки не требуется никаких других действий.

Линукс

Примечание.

.NET 7+ совместим только с версиями libmsquic версии 2.2+ .

Пакет libmsquic требуется в Linux. Этот пакет опубликован в официальном репозитории пакетов Linux корпорации Майкрософт, https://packages.microsoft.com а также доступен в некоторых официальных репозиториях, таких как Alpine Packages — libmsquic.

Установка libmsquic из официального репозитория пакетов Linux корпорации Майкрософт

Перед установкой пакета необходимо добавить этот репозиторий в диспетчер пакетов. Дополнительные сведения см. в репозитории программного обеспечения Linux для продуктов Майкрософт.

Внимание

Добавление репозитория пакетов Майкрософт может конфликтуть с репозиторием дистрибутива, когда репозиторий дистрибутива предоставляет .NET и другие пакеты Майкрософт. Чтобы избежать или устранить неполадки с пакетами, ознакомьтесь с разделом Устранение ошибок .NET, связанных с отсутствующими файлами в Linux.

Примеры

Ниже приведены некоторые примеры использования диспетчера пакетов для установки 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
    

Установка libmsquic из репозитория пакетов распространения

Установка libmsquic из репозитория пакетов распространителя также возможна, но в настоящее время она доступна только для Alpine.

Примеры

Ниже приведены некоторые примеры использования диспетчера пакетов для установки libmsquic:

  • Alpine 3.21 и более поздних версий
apk add libmsquic
  • Alpine 3.20 и старше
# Get libmsquic from community repository edge branch.
apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ libmsquic
Зависимости libmsquic

Все следующие зависимости указаны в манифесте libmsquic пакета и автоматически устанавливаются диспетчером пакетов:

  • OpenSSL 3+ или 1.1 — зависит от версии OpenSSL по умолчанию для версии дистрибутива, например OpenSSL 3 для Ubuntu 22 и OpenSSL 1.1 для Ubuntu 20.

  • libnuma1

macOS

QUIC теперь частично поддерживается в macOS с некоторыми ограничениями через нестандартный диспетчер пакетов Homebrew. Вы можете установить libmsquic в macOS с помощью Homebrew с помощью следующей команды:

brew install libmsquic

Чтобы запустить приложение .NET, которое используется libmsquic, необходимо задать переменную среды перед его запуском. Это гарантирует, что приложение может найти библиотеку libmsquic во время динамической загрузки. Это можно сделать, добавив следующую команду перед основной командой:

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

или

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

Кроме того, можно задать переменную среды следующим образом:

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

а затем выполните главную команду:

./binaryname

Обзор API

System.Net.Quic включает три основных класса, которые позволяют использовать протокол QUIC:

Но прежде чем использовать эти классы, код должен проверить, поддерживается ли QUIC в настоящее время, так как libmsquic может быть отсутствует, или TLS 1.3 может не поддерживаться. Для этого и QuicListener и QuicConnection предоставляют статическое свойство IsSupported:

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

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

Эти свойства будут сообщать о том же значении, но это может измениться в будущем. Рекомендуется проверить сценарии для сервера с помощью IsSupported, а для клиента — с помощью IsSupported.

QuicListener

QuicListener представляет серверный класс, который принимает входящие подключения от клиентов. Прослушиватель создается и запускается с помощью статического метода ListenAsync(QuicListenerOptions, CancellationToken). Метод принимает экземпляр QuicListenerOptions класса со всеми параметрами, необходимыми для запуска прослушивателя и приема входящих подключений. После этого слушатель готов раздавать соединения через AcceptConnectionAsync(CancellationToken). Подключения, возвращаемые этим методом, всегда полностью подключены, что означает, что подтверждение TLS завершено и подключение готово к использованию. Наконец, чтобы прекратить прослушивание и освободить все ресурсы, DisposeAsync() необходимо вызвать.

Рассмотрим следующий 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();

Дополнительные сведения о том, как QuicListener было разработано, см. в документе API proposal.

QuicConnection

QuicConnection — это класс, используемый как для серверных, так и клиентских подключений QUIC. Серверные подключения создаются прослушивателем внутренне и передаются через AcceptConnectionAsync(CancellationToken). Клиентские подключения должны быть открыты и подключены к серверу. Как и в случае с прослушивателем, существует статический метод ConnectAsync(QuicClientConnectionOptions, CancellationToken), который создает экземпляры и устанавливает соединение. Он принимает экземпляр класса QuicClientConnectionOptions, аналогичного QuicServerConnectionOptions. После этого работа с подключением на клиенте и сервере не отличается. Он может открывать исходящие потоки и принимать входящие. Он также предоставляет свойства со сведениями о подключении, например LocalEndPoint, RemoteEndPointили RemoteCertificate.

После завершения работы с подключением его необходимо закрыть и удалить. Протокол QUIC требует использования кода уровня приложения для немедленного закрытия, см . раздел RFC 9000 10.2. Для этого CloseAsync(Int64, CancellationToken) с кодом уровня приложения можно вызвать или, если нет возможности, DisposeAsync() использует код, указанный в DefaultCloseErrorCode. В любом случае DisposeAsync() необходимо вызвать при завершении работы с подключением, чтобы полностью освободить все связанные ресурсы.

Рассмотрим следующий 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();

Дополнительные сведения о том, как был разработан QuicConnection, см. в предложении API .

QuicStream

QuicStream — фактический тип, используемый для отправки и получения данных в протоколе QUIC. Он является производным от обычных Stream и может использоваться как таковой, но также предлагает несколько функций, относящихся к протоколу QUIC. Во-первых, поток QUIC может быть однонаправленным или двунаправленным, см . раздел RFC 9000 2.1. Двунаправленный поток может отправлять и получать данные на обеих сторонах, тогда как однонаправленный поток может передавать данные только от инициирующей стороны и читать на принимающей стороне. Каждый одноранговый узел может ограничить количество одновременных потоков каждого типа, которые он готов принять; см. MaxInboundBidirectionalStreams и MaxInboundUnidirectionalStreams.

Другая особенность потока QUIC — это возможность закрыть сторону записи в процессе работы с потоком. Подробнее см. CompleteWrites() или WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken), перезагрузка с аргументом completeWrites. Закрытие стороны записи позволяет одноранговому узлу знать, что больше данных не будет прибывать, однако одноранговый узел по-прежнему может продолжать отправлять (в случае двунаправленного потока). Это полезно в таких сценариях, как обмен HTTP-запросом и ответом, когда клиент отправляет запрос и закрывает канал записи, чтобы сообщить серверу, что содержимое запроса завершено. Сервер по-прежнему может отправить ответ после этого, но знает, что больше данных не будет поступать от клиента. И для ошибочных случаев, процесс записи или чтения потока можно прервать, см. Abort(QuicAbortDirection, Int64).

Примечание.

Открытие потока резервирует его только без отправки данных. Этот подход предназначен для оптимизации использования сети, избегая передачи почти пустых кадров. Так как одноранговый узел не уведомляется до отправки фактических данных, поток остается неактивным с точки зрения однорангового узла. Если вы не отправляете данные, одноранговый узел не распознает поток, что может привести AcceptInboundStreamAsync() к зависаю по мере ожидания значимого потока. Чтобы обеспечить надлежащее взаимодействие, необходимо отправить данные после открытия потока.

Поведение отдельных методов для каждого типа потока приведено в следующей таблице (обратите внимание, что клиент и сервер могут открывать и принимать потоки):

Способ Поток открытия однорангового узла Одноранговый прием потока
CanRead двунаправленный: true
unidirectional: false
true
CanWrite true двунаправленный: true
unidirectional: false
ReadAsync двунаправленный: считывает данные
unidirectional: InvalidOperationException
считывает данные
WriteAsync отправляет данные => пир читает и возвращает данные двунаправленное: отправляет данные => одноранговое чтение возвращает данные
unidirectional: InvalidOperationException
CompleteWrites закрывает сторону записи => одноранговое чтение возвращает 0 двунаправленное: закрывает сторону записи => одноранговое чтение возвращает 0
однонаправленный: no-op
Abort(QuicAbortDirection.Read) двунаправленный: STOP_SENDING => вызовы одноранговой записи QuicException(QuicError.OperationAborted)
однонаправленный: no-op
STOP_SENDING => вызовы одноранговой записиQuicException(QuicError.OperationAborted)
Abort(QuicAbortDirection.Write) RESET_STREAM => вызовы однорангового чтенияQuicException(QuicError.OperationAborted) двунаправленный: RESET_STREAM => вызовы однорангового чтения QuicException(QuicError.OperationAborted)
однонаправленный: no-op

Помимо этих методов, QuicStream предлагает два специализированных свойства для получения уведомлений, когда сторона для чтения или записи потока была закрыта: ReadsClosed и WritesClosed. Оба возвращают Task, который завершается с закрытием соответствующей стороны, будь то успешное завершение или прерывание, в случае чего Task будет содержать соответствующее исключение. Эти свойства полезны, если пользовательский код должен знать о закрытии конца потока без вызова ReadAsync или WriteAsync.

Наконец, когда выполняется работа с потоком, его необходимо удалить с помощью DisposeAsync(). Освобождение гарантирует, что как чтение, так и запись данных (в зависимости от типа потока) закрываются. Если поток не был правильно прочитан до конца, удаление выдает эквивалент Abort(QuicAbortDirection.Read). Тем не менее, если сторона записи потока не была закрыта, она будет корректно закрыта, как это будет с CompleteWrites. Причина этой разницы заключается в том, чтобы убедиться, что сценарии, работающие с обычным Stream, ведут себя как ожидалось и приводят к успешному результату. Рассмотрим следующий пример:

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

Пример использования QuicStream в сценарии клиента:

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

И пример использования QuicStream в сценарии сервера:

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

Для получения дополнительной информации о том, как было разработано QuicStream, см. предложение о разработке API.

См. также