Сетевой код на C++: чек-лист перед выкатом в прод
Что проверить в сетевом коде на C++ до того, как он уйдёт под нагрузку: таймауты, частичные записи, обработка EINTR, лимиты дескрипторов и корректное завершение соединений.

Сетевой код на C++ ломается не на этапе «hello world», а через две недели в проде, когда клиент оборвал соединение на середине ответа, ядро вернуло EINTR, а send() записал не все байты. Компилятор эти ошибки не ловит, юнит-тест на localhost с одним клиентом — тоже. Ниже чек-лист приёмов, которые отделяют работающий сервер от того, что падает под первой сотней одновременных подключений. Речь о POSIX-сокетах (Linux, BSD-семейство) и C++17/20; часть пунктов справедлива и для Boost.Asio, но акцент на голом API, где ошибки виднее.
Возвращаемые значения системных вызовов проверяются всегда
Самая частая ошибка — считать, что send() и recv() отработали полностью, если вернули не -1. Это не так. Для потокового сокета (SOCK_STREAM) оба вызова могут обработать меньше байт, чем вы просили, и это штатное поведение, а не ошибка.
// Полная запись буфера в сокет: send() может вернуть меньше len
#include <sys/socket.h>
#include <cerrno>
#include <cstddef>
// Возвращает true, если весь буфер отправлен
bool send_all(int fd, const char* data, size_t len) {
size_t sent = 0;
while (sent < len) {
ssize_t n = ::send(fd, data + sent, len - sent, MSG_NOSIGNAL);
if (n < 0) {
if (errno == EINTR) continue; // прервано сигналом — повторяем
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// неблокирующий сокет: буфер ядра полон, ждём готовности к записи
return false; // здесь должна быть логика ожидания через epoll/poll
}
return false; // реальная ошибка
}
sent += static_cast<size_t>(n);
}
return true;
}
Флаг MSG_NOSIGNAL критичен: без него запись в закрытый сокет пошлёт процессу сигнал SIGPIPE, который по умолчанию убивает процесс. Проверьте, что он либо стоит в каждом send(), либо SIGPIPE глобально игнорируется через signal(SIGPIPE, SIG_IGN).
recv() возвращает 0 — это не ошибка
Возврат 0 из recv() означает, что другая сторона корректно закрыла соединение (получен FIN). Это отдельная ветка обработки: не -1, не «пришли данные», а «пора закрывать этот сокет и убирать его из epoll». Код, который путает 0 с ошибкой или зацикливается на пустом чтении, — источник висящих соединений и 100% CPU.
Три исхода recv на потоковом сокете нужно разводить явно: положительное число — данные, ноль — закрытие соединения, минус один — смотреть errno. Слить их в одно условие if (n <= 0) — значит потерять разницу между нормальным закрытием и сбоем.
Таймауты: без них соединение висит вечно
Блокирующий connect() к недоступному хосту может висеть десятки секунд, пока не сработает системный таймаут TCP. recv() на медленном клиенте — сколько угодно. В проде это оборачивается пулом потоков, забитым висящими соединениями, и отказом в обслуживании без единого злого умысла.
- Таймаут на чтение/запись — через
setsockoptсSO_RCVTIMEOиSO_SNDTIMEOдля блокирующих сокетов, либо через таймаутepoll_wait/pollдля неблокирующих. - Таймаут на connect — перевести сокет в неблокирующий режим, вызвать
connect(), дождаться готовности к записи черезpollс явным лимитом времени, затем проверитьSO_ERRORчерезgetsockopt. - Idle-таймаут на уровне приложения — отдельный счётчик времени последней активности per-connection, а не только TCP keepalive. Keepalive по умолчанию срабатывает через часы.
Неблокирующий режим и мультиплексирование
Модель «поток на соединение» упирается в память под стеки и переключение контекста уже на нескольких тысячах клиентов. Стандарт для C++-сервера на Linux — неблокирующие сокеты плюс epoll в режиме edge-triggered. Здесь свои грабли.
В edge-triggered режиме epoll сообщает о готовности один раз на переход состояния. Если вы не вычитали весь доступный буфер за один заход, повторного уведомления не будет, и данные повиснут. Правило: в ET-режиме читать в цикле, пока recv() не вернёт EAGAIN/EWOULDBLOCK. Пропустите этот цикл — получите «залипшие» соединения, которые оживают только при следующем пакете от клиента.
Что проверить в конфигурации epoll
| Пункт | Level-triggered | Edge-triggered |
|---|---|---|
| Флаг при регистрации | без EPOLLET | с EPOLLET |
| Чтение в цикле до EAGAIN | не обязательно | обязательно |
| Риск бесконечного срабатывания | при неполном чтении | нет |
| Сложность кода | ниже | выше |
Если не уверены в модели — начните с level-triggered: он прощает неполное чтение ценой лишних пробуждений. ET берут ради максимальной производительности, когда точно готовы вычитывать буфер до конца.
Лимиты и ресурсы
Каждое соединение — это файловый дескриптор. Дефолтный RLIMIT_NOFILE (часто 1024) кончится задолго до того, как упрётесь в CPU или память. Проверьте текущий лимит через ulimit -n и при необходимости поднимите его через setrlimit в коде или в systemd-юните (LimitNOFILE).
- Закрывайте дескрипторы на всех путях выхода, включая обработку ошибок. Утечка fd на ошибочной ветке — классика, которую видно только на длинной дистанции по росту
lsof. - Ставьте флаг
SOCK_CLOEXEC(илиFD_CLOEXEC), чтобы сокеты не утекали в дочерние процессы приfork/exec. - Для слушающего сокета включайте
SO_REUSEADDR, иначе после перезапуска процесса получитеbind: Address already in use, пока сокет висит вTIME_WAIT.
Байтовый порядок и границы сообщений
TCP — это поток байтов, а не поток сообщений. Одно send() на отправителе не соответствует одному recv() на получателе: данные склеиваются и режутся произвольно. Разбор протокола обязан работать с частичными и склеенными пакетами. Держите буфер накопления на соединение и разбирайте сообщения по мере накопления полного заголовка и тела.
Числовые поля в бинарном протоколе приводите к сетевому порядку байтов через htons/htonl при отправке и ntohs/ntohl при приёме. Код, который пишет uint32_t в сокет напрямую, работает между машинами одной архитектуры и молча ломается при первом клиенте на другой.
Корректное завершение соединения
Грубый close() при непрочитанных входящих данных может послать клиенту RST вместо FIN, и часть уже отправленного вами ответа потеряется. Если важна доставка последнего байта: используйте shutdown(fd, SHUT_WR), дочитайте остаток от клиента до recv() == 0, и только потом close(). Это не всегда нужно, но для протоколов с ответом-подтверждением критично.
Итоговый чек-лист перед выкатом
- Каждый
send/recvобрабатывает частичный результат,EINTR,EAGAINи возврат 0 отдельными ветками. SIGPIPEигнорируется либо стоитMSG_NOSIGNALна всех записях.- Есть таймауты на connect, чтение, запись и idle — не только TCP keepalive.
- В edge-triggered epoll чтение идёт в цикле до
EAGAIN. RLIMIT_NOFILEподнят под ожидаемое число соединений.- Дескрипторы закрываются на всех путях, включая ошибки; стоит
SOCK_CLOEXEC. - На слушающем сокете
SO_REUSEADDR. - Разбор протокола устойчив к склейке и разрезанию пакетов; числа идут через
htonl/ntohl. - Завершение соединения через
shutdownтам, где нельзя терять хвост ответа.
Что дальше
Проверьте поведение под реальной параллельностью, а не на одном клиенте: инструменты вроде wrk или самописный генератор нагрузки быстро вскроют пропущенный цикл чтения в ET-режиме и утечку дескрипторов. Смотрите в проде на счётчик открытых fd (ls /proc/PID/fd | wc -l) и на число соединений в TIME_WAIT через ss -s. Если пишете с нуля и не хотите вручную закрывать все эти дыры — берите Boost.Asio или libuv: они снимают большую часть перечисленного, но понимать, что именно они делают за вас, всё равно придётся. Асинхронная модель с корутинами C++20 поверх epoll — отдельная большая тема, здесь мы её не разворачивали.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Linux manual pages: send(2), recv(2), epoll(7), Boost.Asio documentation
Частые вопросы
Всё работает на localhost с одним клиентом, зачем этот чек-лист?
Я использую Boost.Asio, эти пункты меня касаются?
У меня Windows, а не Linux — что меняется?
Что делать, если под нагрузкой сервер отдаёт 100% CPU без роста запросов?
Нужен ли SO_REUSEPORT вместе с SO_REUSEADDR?
Достаточно ли TCP keepalive вместо приложенческого таймаута?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.