Сокеты в C++: как выбрать между Boost.Asio, libuv и голым API
Разбираем, когда хватит сырых POSIX-сокетов, когда нужен Boost.Asio, а когда — libuv или standalone Asio. С ценой каждого подхода в строках кода, зависимостях и скорости разработки.

Вам нужно, чтобы C++-сервис принимал TCP-соединения. Дальше вилка: одни пишут через socket()/epoll вручную, другие тянут Boost целиком ради Asio, третьи берут libuv из мира Node.js. Все три варианта дают рабочий результат, но платите вы разным: временем на отладку, размером зависимостей и тем, насколько код переживёт переход на другую платформу. Разберём, где чья граница.
Статья про выбор инструмента и цену каждого решения для сетевого слоя на C++17/C++20. Про тонкую настройку TCP (TCP_NODELAY, размеры буферов) и про TLS здесь только упоминания — это отдельные темы.
Четыре способа открыть сокет в C++
Практический выбор сводится к четырём вариантам. Пятый — самому написать реактор поверх epoll/kqueue/IOCP — тоже существует, но это уже не «работа с сетью», а разработка сетевой библиотеки, и в прикладном коде почти всегда лишняя.
- Сырые POSIX/Winsock-сокеты — стандартный C API операционной системы. Максимальный контроль, ноль зависимостей, ручная работа с мультиплексированием.
- Boost.Asio — асинхронная сеть внутри Boost. Кроссплатформенно, модель проактора1, интеграция с корутинами C++20.
- Standalone Asio — тот же Asio, но без остального Boost. Header-only, ставится отдельно.
- libuv — C-библиотека событийного цикла, на которой построен Node.js. C++ через обёртку, зато один цикл на сеть, файлы, таймеры и процессы.
Сырые сокеты: когда контроль важнее удобства
Blocking-сокет на POSIX — это буквально несколько вызовов. Проблемы начинаются, когда клиентов больше одного и нужно ждать сразу нескольких дескрипторов. Тогда появляется epoll на Linux, kqueue на BSD/macOS и IOCP на Windows — три разных API с разной семантикой. Кроссплатформенность вы пишете руками.
// Linux, минимальный TCP-сервер, blocking accept. Компилятор: g++ -std=c++17
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
int main() {
int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) { perror("socket"); return 1; }
int opt = 1;
// Позволяет перезапустить сервер сразу после падения, не ловя "Address already in use"
::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(8080);
if (::bind(listen_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
perror("bind"); return 1;
}
::listen(listen_fd, 128); // 128 — длина очереди ожидающих соединений
for (;;) {
int conn = ::accept(listen_fd, nullptr, nullptr);
if (conn < 0) continue;
char buf[1024];
ssize_t n = ::recv(conn, buf, sizeof(buf), 0);
if (n > 0) ::send(conn, buf, n, 0); // эхо
::close(conn);
}
::close(listen_fd);
}
Этот код обслуживает клиентов строго по одному: пока recv ждёт данных, остальные стоят в очереди. Чтобы масштабироваться, вы добавляете epoll и переписываете весь цикл на неблокирующие сокеты с ручным разбором частично прочитанных сообщений. Цена сырого API — именно этот объём кода, а не сами вызовы socket/bind.
Boost.Asio: асинхронность из коробки
Asio берёт на себя мультиплексирование и прячет разницу между epoll, kqueue и IOCP за единой моделью. С C++20 асинхронный код пишется корутинами и читается почти линейно.
// Boost.Asio, эхо-сервер на корутинах. Проверено на Boost 1.84, компилятор с C++20
#include <boost/asio.hpp>
#include <iostream>
namespace asio = boost::asio;
using asio::ip::tcp;
asio::awaitable<void> echo(tcp::socket socket) {
char data[1024];
for (;;) {
// co_await не блокирует поток: пока нет данных, цикл обслуживает другие соединения
std::size_t n = co_await socket.async_read_some(
asio::buffer(data), asio::use_awaitable);
co_await asio::async_write(
socket, asio::buffer(data, n), asio::use_awaitable);
}
}
asio::awaitable<void> listener() {
auto executor = co_await asio::this_coro::executor;
tcp::acceptor acceptor(executor, {tcp::v4(), 8080});
for (;;) {
tcp::socket socket = co_await acceptor.async_accept(asio::use_awaitable);
// Каждое соединение — отдельная корутина в том же io_context
asio::co_spawn(executor, echo(std::move(socket)), asio::detached);
}
}
int main() {
asio::io_context ctx;
asio::co_spawn(ctx, listener(), asio::detached);
ctx.run();
}
Один поток здесь обслуживает тысячи соединений: io_context сам решает, какая корутина готова продолжиться. За это вы платите зависимостью от Boost и временем компиляции — Asio активно использует шаблоны.
Правило, которое экономит часы отладки: буфер, который вы передали вasync_read_some, обязан жить до завершения операции. Локальныйchar data[1024]работает, только пока он в кадре корутины. Асинхронный вызов, который читает в уже уничтоженный буфер, — самый частый source of use-after-free в Asio-коде.
Standalone Asio против Boost.Asio
Это одна и та же библиотека одного автора. Различия — упаковка, а не поведение:
- подключение других заголовков и другой namespace:
asio::вместоboost::asio::,<asio.hpp>вместо<boost/asio.hpp>; - standalone header-only, ему не нужен собранный Boost;
- standalone не тянет остальной Boost в проект, что заметно на времени сборки и размере зависимостей.
Если Boost уже есть в проекте — берите Boost.Asio, лишней зависимости нет. Если Boost нужен только ради сети — standalone избавляет от гигантской зависимости ради одного модуля.
libuv: событийный цикл на C
libuv — не про сокеты как таковые, а про единый цикл событий: TCP, UDP, файловый ввод-вывод, таймеры, дочерние процессы и пул потоков в одном API. Это C-библиотека, поэтому в C++ вы либо пишете обёртки над её колбэками, либо берёте готовую (например, uvw). Модель — реактор с колбэками, а не корутины, поэтому линейного чтения кода, как в Asio с C++20, не будет.
libuv оправдан, когда сеть — лишь часть событийной нагрузки и вам нужен один цикл на всё, либо когда вы уже завязаны на его экосистему. Для чисто сетевого C++-сервиса Asio обычно ложится на язык естественнее.
Сравнение вариантов
| Критерий | Сырые сокеты | Boost.Asio | Standalone Asio | libuv |
|---|---|---|---|---|
| Язык API | C (ОС) | C++ | C++ | C |
| Кроссплатформенность | Пишете сами | Есть | Есть | Есть |
| Внешние зависимости | Нет | Boost | Нет (header-only) | libuv |
| Модель | Ручной epoll/IOCP | Проактор, корутины C++20 | Проактор, корутины C++20 | Реактор, колбэки |
| Кроме сети | — | Таймеры, сигналы | Таймеры, сигналы | Файлы, процессы, пул |
| Порог входа | Высокий | Средний | Средний | Средний |
Когда что выбирать
- Сырые сокеты — если соединений мало и логика простая (утилита, тест, embedded под одну ОС), либо если вам нужен контроль над каждым флагом сокета и любая абстракция мешает. Не берите их «для скорости»: правильно написанный epoll-цикл — это недели отладки, а не бонус к производительности.
- Boost.Asio — если Boost уже в проекте и нужна асинхронная сеть с C++20-корутинами. Дефолтный выбор для большинства сетевых C++-сервисов.
- Standalone Asio — то же, но когда Boost в проект тащить не хочется.
- libuv — если событийная нагрузка шире сети: файлы, таймеры, процессы под одним циклом, или вы работаете рядом с экосистемой Node.
Что ломается
Ошибки, которые всплывают одинаково во всех вариантах, независимо от библиотеки:
- Расчёт на то, что recv/read вернёт всё сообщение целиком. TCP — поток байтов, а не сообщений. Один
recvможет вернуть половину вашего пакета или полтора пакета сразу. Границы сообщений вы задаёте сами — длиной в заголовке или разделителем. - Игнорирование частичной записи.
send/writeвправе отправить меньше, чем вы просили. В сыром коде это ваша забота;asio::async_writeдожимает до конца сам — в этом и разница сasync_write_some. - SIGPIPE при записи в закрытый сокет. На Linux запись в разорванное соединение по умолчанию роняет процесс сигналом. Лечится флагом
MSG_NOSIGNALвsendили игнорированиемSIGPIPE; Asio разбирается с этим за вас. - Время жизни буфера в асинхронном коде. Буфер обязан пережить всю асинхронную операцию — см. врезку выше.
- Забытый порядок байтов. Порт и адрес в сетевом порядке требуют
htons/htonl. Пропустите — сервер слушает не тот порт, и это не всегда очевидно из логов.
Что делать дальше
Определитесь с зависимостями проекта: если Boost уже подключён — стартуйте с Boost.Asio и его примеров из документации, они компилируются как есть. Дальше по мере роста сервиса смотрите на конкретные показатели: число одновременных соединений, задержку на установку соединения и объём памяти на соединение под пиковой нагрузкой. Отдельно закладывайте время на протокол поверх TCP — обрамление сообщений и разбор частичных чтений съедают больше сил, чем сам выбор библиотеки. TLS добавляйте через asio::ssl поверх готового сетевого слоя, а не переписывая всё заново.
1 Проактор — модель, где вы инициируете операцию (например, чтение) и получаете уведомление по её завершении с уже готовым результатом. В отличие от реактора, который сообщает лишь о готовности дескриптора к операции, а саму операцию вы выполняете сами.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: Boost.Asio — официальная документация, libuv — Documentation
Частые вопросы
У меня старый компилятор без поддержки C++20. Заработает ли Asio?
Что выбрать для одного-двух соединений — стоит ли тянуть библиотеку?
Сервер принимает данные, но иногда приходит только часть сообщения. Это баг библиотеки?
Процесс падает при записи в закрытое соединение. Почему?
В чём практическая разница между Boost.Asio и standalone Asio?
Почему нельзя использовать локальный буфер в async_read?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.