Если вы хоть раз писали систему, которая должна показывать «живые» данные — показания датчиков, состояние оборудования, любые теги технологического процесса — рано или поздно вы упираетесь в один и тот же вопрос: как доставлять обновления от сервера к клиенту.
В данной статье рассмотрим три:
Short polling — клиент отправляет серверу запросы через равные промежутки времени;
Long polling — клиент делает запрос. А на сервере висит соединение, пока не появятся новые данные;
WebSocket — один раз открыли соединение и отправляем данные в обе стороны, пока оно живо.
Всё это давно известно и много раз обсуждалось применительно к обычным веб-приложениям. Но диспетчерский уровень АСУ ТП — это немного другая история: SCADA не ждёт, пока пользователь кликнет кнопку, она тянет свежие значения тегов с контроллеров. Профиль нагрузки принципиально другой, и интуиция из мира обычных сайтов здесь работает не всегда.
Мне захотелось померить: как эти три способа доставки данных ведут себя при росте числа клиентов от одного до тысячи, если внутри — типовая архитектура распределённой системы управления с контроллерным и диспетчерским уровнями.
Что именно меряем и зачем
Задача была разбита на несколько частей:
собрать стенд, который честно воспроизводит двухуровневую архитектуру РСУ — контроллеры (эмулированные через Modbus TCP) снизу и веб-сервер с диспетчерской логикой сверху;
подключить к серверу синтетическую нагрузку, которая ведёт себя как реальные автоматизированные рабочие места, но для каждого из трёх методов доставки;
снять с сервера системные метрики (CPU, память) и параллельно захватить весь сетевой трафик диспетчерского уровня;
вытащить из трафика задержку доставки тегов, объём полезной нагрузки и накладных расходов, а также поведение TCP-соединений;
сравнить три метода между собой по всем этим параметрам.
Как устроен стенд
Стенд получился из двух физически разных машин, чтобы не мешать в кучу трафик диспетчерского уровня с внутренними процессами одной ОС.
Хостовая машина играла сразу две роли:
источник данных — на ней был запущен OpenPLC Runtime, эмулирующий ПЛК и работающий как Modbus TCP сервер с набором holding-регистров;
генератор нагрузки — консольная утилита, которая эмулирует рабочие места для каждого из трёх методов.
Виртуальная машина была сервером диспетчерского уровня: там был запущен веб-сервер ASP.NET Core, который сам выступал Modbus TCP клиентом по отношению к «контроллеру» на хосте.
Сервер был развернут в Docker и его статистику сохранял docker stats.
Получилось два независимых потока трафика через границу хост—ВМ: в одну сторону — Modbus TCP запросы к «контроллеру», в другую — HTTP/WebSocket нагрузка к диспетчерскому серверу. Сеть виртуальной машины была в режиме моста, так что она вела себя как отдельный узел в локальной сети — это позволило спокойно перехватывать трафик диспетчерского уровня прямо на физическом адаптере хоста через tshark, отфильтровав всё лишнее.
Как это работает внутри
На сервере фоновая служба опрашивала «контроллер» каждые 100 мс: считывала регистр, сохраняла новое значение в кольцевой буфер на 200 записей и отправляла событие об изменении — на него подписаны все три обработчика.
Под каждый метод — свой эндпоинт:
/api/shortPolling— сразу отдаёт текущее состояние, без ожидания;/api/longPolling?lastSequence— смотрит наlastSequenceи либо сразу отдаёт данные из буфера, либо держит соединение открытым до появления нового значения;/api/ws— держит постоянное соединение и рассылает обновления всем подписчикам сразу, как только они появляются.
Клиентское поведение: short polling отправляет запрос каждые 100 мс, long polling — без пауз между запросами, WebSocket открывает одно соединение на весь тест и слушает его.
Как тестировали
Число одновременных клиентов: 1, 10, 30, 50, 100, 500, 1000. Каждый тест — 30 секунд, захват трафика запускался заранее, чтобы покрыть весь интервал. Каждую конфигурацию запускали 5 раз и усредняли результат, заодно снимая CPU и память сервера.
Из каждого захваченного дампа tshark вытаскивал:
Задержку доставки — разницу между временем прихода кадра и таймстампом, который сервер сам вшивал в тело сообщения. Чтобы не ловить рассинхронизацию часов хоста и ВМ, значения нормировались относительно минимума внутри каждого теста. Помимо среднего — перцентили p50, p95, p99, чтобы видеть не только «в среднем», но и «как плохо бывает в худшем случае».
Объём трафика — отдельно общий объём (по размеру кадров) и полезная нагрузка (по содержимому
http.file_dataиwebsocket.payload); разница между ними — это оверхед.Поведение TCP-соединений — число новых соединений считалось по пакетам с флагом SYN без ACK.
Что получилось
Нагрузка на сервер


С ростом числа клиентов от 0 до 1000 загрузка CPU растёт у всех трёх методов почти линейно, но с очень разным наклоном:
WebSocket держится лучше всех: при 1000 клиентах — около 36% CPU. Логично: одно постоянное TCP-соединение, никаких пересозданий сессий и лишних HTTP-заголовков на каждое сообщение.
Short polling — где-то посередине, 50% CPU при 1000 клиентах.
Long polling показал себя хуже всех: уже на 500 клиентах CPU перевалил за 50%, а на 1000 — почти 99%. Причина в архитектуре: серверу приходится держать открытыми кучу соединений в состоянии ожидания или блокировки, а это дорого с точки зрения управления пулом потоков.
С памятью всё куда проще — все три метода ведут себя почти одинаково: рост с 1% до 4,6–5% при увеличении числа клиентов от 0 до 1000. Графики практически ложатся друг на друга, а разброс укладывается в доверительный интервал. То есть память в этом сценарии — не то место, которое влияет на выбор технологии.
Трафик: сколько «полезного», сколько «мусора»


В абсолютных числах трафик у всех трёх методов растёт пропорционально числу клиентов — тут ничего неожиданного. Интереснее — доля полезной нагрузки в общем объёме:
WebSocket держит стабильные 17% полезной нагрузки на всём диапазоне — больше чем вдвое с сравниваемыми способами доставки.
Short polling и long polling тащат весь HTTP-заголовков в каждом запросе. У long polling доля полезной нагрузки стабильно держится на уровне 7% от 10 до 1000 клиентов. У short polling при максимальной нагрузке (500–1000 клиентов) она чуть подрастает — с 7% до 8%.
Иными словами: даже в лучшем случае полезные данные — это меньшинство трафика. Но у WebSocket куда больше полезной данных.
Задержка и что происходит с соединениями


Здесь начинается самое любопытное.
Short polling — стабильный и предсказуемый: медиана задержки 50 мс, p99 — 100 мс. Всего за тест около 1980 SYN-пакетов (~2 на клиента), то есть HTTP Keep-Alive честно переиспользует соединения. Задержка тут жёстко привязана к интервалу опроса на клиенте и вообще не зависит от того, насколько загружен сервер.
Long polling ведёт себя странно. При 1000 клиентах медианная задержка вроде бы неплохая — 22 мс (p99 — 60 мс), но количество новых соединений почти не растёт. Причина, скорее всего, в исчерпании пула потоков ASP.NET Core: при синхронной блокирующей реализации каждый long-polling запрос держит поток до 60 секунд, а пул не успевает расшириться под резкий всплеск блокирующих запросов. В итоге CPU занят не полезной работой, а переключением контекста между растущей очередью потоков — отсюда и те самые 99% загрузки, которые мы видели выше. Сервер физически не успевает принимать новые соединения, и часть SYN-пакетов просто отбрасывается. То есть красивые цифры по задержке на графике честны только для примерно 20% клиентов, которые вообще успели подключиться — для остальных канал управления фактически не работает.
WebSocket — лучший результат по обоим характеристикам: медианная задержка около 10 мс при 1000 клиентах, p99 не выше 33 мс. При этом график новых соединений идёт почти вровень с short polling (2050 пакетов при 1000 клиентов) — но раз открытые сессии дальше не рвутся, задержка остаётся минимальной и предсказуемой на всём тесте.
Выводы
Если коротко:
По CPU: WebSocket — 36% при 1000 клиентах, short polling — 50%, long polling утыкается в потолок ресурсов сервера.
По сети: WebSocket даёт более чем двукратный выигрыш по доле полезной нагрузки (17% против 7%) — минимальный заголовок фрейма против полновесных HTTP-заголовков на каждый запрос.
По задержке: WebSocket быстрее и стабильнее всех (медиана 10 мс, p99 — 33 мс при 1000 клиентах). Short polling медленнее, зато его задержка железно детерминирована интервалом опроса — не самый плохой вариант, если предсказуемость важнее скорости. Long polling в синхронной реализации — самый рискованный выбор: рост задержки на фоне исчерпания пула потоков делает его ненадёжным при большом числе клиентов.
По памяти: разницы между методами практически нет, так что на выбор этот параметр не влияет.
Что с этим делать на практике. Для диспетчерского уровня АСУ ТП разумным выбором по умолчанию выглядит WebSocket — он даёт минимальную задержку и лучшую пропускную способность при большом числе одновременно подключённых клиентов. Short polling имеет смысл там, где детерминизм важнее скорости. А вот long polling в синхронном виде я бы не рекомендовал использовать при числе клиентов больше сотни — деградация слишком резкая. При этом если переписать рассылку на асинхронную модель, метод вполне может быть конкурентоспособен для сценариев, где обновления происходят редко.
Со всем кодом, который был использован данный тест и проведен анализ можно ознакомиться на github: https://github.com/EldenFan/WebSocketBenchmark

