Привет. Я Александр Донин, я технический менеджер платформы VDI и терминального доступа Termit. В прошлой статье мы рассказывали, почему делаем собственный протокол удаленного доступа Pulsar с нуля, а не форкаем Open Source. Это не значит, что мы вообще против Open Source — используем его, когда это экономит время и не создает проблем: например, в Termit мы используем PostgreSQL для хранения данных и NGINX как веб-сервер, да и начинал Termit с RDP и X2Go. Транспорт в Pulsar — это еще один пример, когда компонент Open Source дал нам как раз то, что требовалось, и вошел в протокол в виде модуля.
Именно на уровне транспорта решается, помешает ли передача тяжелого файла мыши и клавиатуре, сколько потерь пакетов протокол выдержит, переживет ли сессия обрыв связи. Расскажем, почему остановились на QUIC, а не на легаси-протоколах, и с чем пришлось разбираться при реализации.

Зачем вообще отдельный транспорт
Pulsar рассчитан на сценарии, которые для Enterprise-заказчиков не редкость, а норма: нестабильные каналы связи, много одновременных сессий при большом числе пользователей, переключение сети без разрыва соединения. Для банков, нефтегазовых и энергетических компаний, у которых много удаленных площадок со слабой связью, это не форс-мажор, а обычный рабочий день.
Заказчики хотят простую и надежную систему, которая не разваливается на плохом канале и не требует отдельной настройки под каждый сценарий подключения. Транспортный уровень — это ровно то место, где эта надежность либо есть, либо ее нет, вне зависимости от того, насколько хорошо сделано все остальное в протоколе.
Что не устроило в TCP и в голом UDP
Есть старая айтишная шутка про эти протоколы:
Я знаю отличную шутку про UDP, но не факт, что она до вас дойдет.
Я знаю отличную шутку про TCP, но если она до вас не дойдет, я буду повторять ее снова и снова, пока вы ее не подтвердите.
Шутка почти в точности описывает суть: UDP ничего не гарантирует, а TCP гарантирует доставку ценой постоянных подтверждений и повторов. Нам не подошел ни один вариант в чистом виде.
Решающая проблема TCP — не задержка на установке соединения, хотя она тоже есть. Дело в том, как TCP ведет себя, если по одному соединению передавать несколько разных потоков данных: скажем, изображение экрана, сигналы мыши и клавиатуры, буфер обмена. TCP гарантирует доставку и порядок, и ради этого блокирует всю очередь. Если один пакет потерялся или повредился, TCP обязан переслать именно его, а все, что идет следом, включая данные из совершенно других логических потоков, ждет, пока это не случится. Хуже того: если механизм Selective Acknowledgment (SACK) не сработал, при потере пакета TCP пересылает все, начиная с последнего подтвержденного, даже если что-то после потерянного пакета уже успешно дошло.
Протоколы, которые исторически строились на TCP, решают это в лоб: открывают отдельное TCP-соединение под каждый канал данных. Десять типов данных — десять соединений. Это работает, но обходится дорогой ценой: каждое соединение — это отдельные сетевые ресурсы и отдельный порт, а держать открытыми десятки портов на сервере это еще и лишняя поверхность для атаки, с которой потом разбирается администратор.
От UDP отказались по обратной причине: он вообще не дает гарантий доставки и порядка, и это в нем заложено намеренно, ради простоты. Но нам от этого не легче: контроль доставки, повторную отправку, сборку в правильном порядке пришлось бы писать самим, с нуля, дублируя то, что для части сценариев давно решено в другом месте.
Почему QUIC
В прошлой статье писали про многоканальность как один из принципов, заложенных в протокол с самого начала. Вот как она устроена на практике.
QUIC умеет то, что нам было нужно, из коробки: несколько независимых стримов данных внутри одного соединения. Технически это не «многоканальность» в смысле обвязки поверх TCP, а собственный механизм QUIC.
Если по одному из таких стримов идет тяжелая передача, например, юзер решил скопировать файл на пару гигабайт, остальные потоки продолжают работать как ни в чем не бывало: курсор мыши двигается, экран обновляется. С TCP в одном соединении такого нет в принципе, а с несколькими TCP-соединениями это возможно, но ценой, о которой говорилось выше.
Также QUIC дает возможность расставлять приоритеты между стримами: управляющий сигнал, то есть движение мыши или нажатие клавиши, можно сделать гарантированно проходящим, а что-то менее критичное, например передачу в буфер обмена, оставить по остаточному принципу.
Если начистоту, то мы не тестировали TCP и UDP на прототипах, чтобы прийти к QUIC методом проб и ошибок. Решение было принято на этапе формирования требований к протоколу — по совокупности уже известных свойств QUIC и накопленной боли с протоколами, которые работают поверх классического TCP. Переизобретать то, что в QUIC уже решено, было бы нерационально.
QUIC при этом пока не полностью стандартизирован, и крупные компании относятся к нему настороженно (и мы в том числе). Но на выбор повлияло и то, что на QUIC уже делают ставку игроки с большими ресурсами: Chrome использует его под капотом, Microsoft — участник рабочей группы по стандартизации QUIC и разработчик msquic — одной из лучших доступных реализаций для C++, которую мы и взяли за основу.
За что QUIC многие хвалят, так это за миграцию соединений. Архитектурно QUIC привязывает сессию не к IP-адресу, а к идентификатору соединения, поэтому смена IP теоретически не должна ее рвать.
Сессия Pulsar, кстати, переживает и смену активной сетевой карты и рестарт сервера. Соединение само восстанавливается.
Как справились с множеством потоков в одном соединении
Про внутреннюю механику, а именно: что торчит наружу как API, расскажем в одном из следующих материалов. Если коротко: стримы QUIC — это транспортный уровень, а поверх него нам понадобился свой канальный уровень, который умеет из одного физического соединения разложить данные по логическим каналам и правильно их адресовать между клиентом, сервером и другими внутренними процессами.
Для этого мы используем фреймворк сериализации и RPC, который позволяет распределенным компонентам общаться друг с другом, как будто они находятся в одном процессе, не задумываясь о том, что на самом деле работают по сети.
Это оказалась самая трудоемкая часть реализации — не техническая сложность в смысле «сложно написать», а инженерная задача держать баланс между производительностью и простотой, когда через один управляющий сервер маршрутизируются данные множества одновременных клиентских сессий.
Ну и немного про многопоточность и асинхронность: количество потоков у нас не фиксировано и меняется в зависимости от нагрузки, а асинхронная модель поверх многопоточности используется для оптимизации производительности. Это деталь реализации, которая на поведение протокола для читателя не влияет, поэтому дальше в нее углубляться не будем.
Что показали тесты
Устойчивость к потере пакетов мы тестировали с помощью traffic control (tc) — эмулировали потери и ограничение полосы и смотрели, как ведет себя протокол.
По предварительным замерам Pulsar переживает потери на уровне 10–20% без критичной деградации качества. Как раз к публикации пересчитали эти цифры заново на более актуальной инфраструктуре, их можно увидеть ниже.

Что касается изображения: у Pulsar пока нет видеокодека, так как мы сознательно вложились в другое. Протокол ориентирован на офисные сценарии, а в них экран в течение рабочего дня в основном статичен: если весь день на экране открыта таблица, в которую изредка что-то вписывают, по сети имеет смысл передавать не поток видео, а только сжатое изображение изменившейся области — иногда это буквально небольшой прямоугольник. Для таких сценариев кодирование дельт картинки эффективнее видеокодека. В будущем к этой теме вернемся, но пока это не в приоритете: с точки зрения транспорта ничего не поменяется, в канале изображения с сервера просто пойдут другие данные.
С ГОСТ-шифрованием поверх QUIC ситуация такая: это технически возможно, но пока не реализовано. В первой статье писали, что TLS выбран основой криптографии именно с расчетом на будущую поддержку ГОСТ. Это по-прежнему так: даже если для ГОСТ-сертификатов понадобится отдельная сборка клиента под другой набор шифров, сам фундамент не меняется, речь не о пересмотре подхода к шифрованию, а о сборке под конкретный набор алгоритмов.
Что дальше
API, на котором строится интеграция с Termit, разберем в следующей статье. А мы продолжаем развивать Pulsar в сторону версии, которая полностью заменит X2Go — именно этого разрыва сегодня реально не хватает на Linux. Затем наступит очередь и реализации под Windows и для веб-клиента. А следом возьмемся зв видеокодеки.
