Сокеты
Предпосылки: файловый ввод-вывод (fd, read/write, O_NONBLOCK), файловые дескрипторы (три уровня таблиц).
← Файловый ввод-вывод | Мультиплексирование ввода-вывода →
Pipe связывает два процесса на одной машине. Но веб-серверу нужно принимать запросы от браузеров на другом конце планеты, а микросервису — обмениваться данными с соседним контейнером. Нужна абстракция сетевого соединения, которая при этом вписывается в философию «всё — файл». Такой абстракцией в Unix стал сокет (socket) — конечная точка коммуникации, представленная файловым дескриптором. К сокету применимы те же read(), write() и close(), что и к обычному файлу, но за дескриптором стоит не inode на диске, а сетевой стек ядра.
Создание сокета
Системный вызов socket() создаёт дескриптор, но не связывает его ни с каким адресом и не устанавливает соединения:
#include <sys/socket.h>
int sockfd = socket(domain, type, protocol);Три параметра определяют, какой именно сокет создаётся.
Домен (address family) задаёт пространство адресов. AF_INET — IPv4-адреса (Internet Protocol версии 4: 32-битный адрес вида 192.168.1.1 + порт), AF_INET6 — IPv6-адреса (версия 6: 128 бит + порт), AF_UNIX — локальный путь в файловой системе, без сетевого стека.
Тип определяет семантику передачи данных. SOCK_STREAM — надёжный двунаправленный поток байтов: данные приходят в том же порядке, что были отправлены, без дубликатов и потерь. Для AF_INET за этим стоит TCP (Transmission Control Protocol). SOCK_DGRAM — отдельные датаграммы без гарантий порядка и доставки; для AF_INET это UDP (User Datagram Protocol). Оба протокола подробно разобраны в транспортном уровне; на уровне сокетов достаточно знать их семантику: поток байтов vs отдельные сообщения.
Протокол обычно равен 0 — ядро само выбирает протокол по комбинации домена и типа (TCP для AF_INET + SOCK_STREAM, UDP для AF_INET + SOCK_DGRAM).
Возвращённый fd ещё ни к чему не привязан. Дальнейшие действия зависят от роли: сервер привязывает сокет к адресу и слушает, клиент подключается к серверу.
TCP-сервер: полный цикл
Веб-сервер, обслуживающий HTTP-запросы, проходит пять этапов: создать сокет, привязать к адресу, начать слушать, принять соединение, обработать запрос. Каждый этап — отдельный системный вызов.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main(void) {
// 1. Создание сокета
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket");
exit(1);
}
// 2. SO_REUSEADDR — разрешить повторное использование адреса
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. Привязка к адресу и порту
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
.sin_addr.s_addr = htonl(INADDR_ANY) // все интерфейсы
};
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
perror("bind");
exit(1);
}
// 4. Перевод в режим прослушивания
if (listen(server_fd, 128) == -1) {
perror("listen");
exit(1);
}
printf("listening on port 8080\n");
// 5. Цикл обработки соединений
while (1) {
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(server_fd,
(struct sockaddr *)&client_addr,
&client_len);
if (client_fd == -1) {
perror("accept");
continue;
}
char buf[4096];
ssize_t n = read(client_fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("received: %s\n", buf);
}
const char *response =
"HTTP/1.1 200 OK\r\n"
"Content-Length: 6\r\n"
"\r\n"
"hello\n";
write(client_fd, response, strlen(response));
close(client_fd);
}
close(server_fd);
return 0;
}Разберём каждый шаг.
bind() связывает дескриптор с конкретным адресом. INADDR_ANY означает «слушать на всех сетевых интерфейсах», htons(8080) переводит номер порта из порядка байтов хоста (host byte order) в сетевой (network byte order, big-endian). Если не вызвать bind(), а сразу перейти к listen(), ядро назначит произвольный свободный порт — годится для тестов, но не для сервера, порт которого клиенты должны знать заранее.
setsockopt() с SO_REUSEADDR решает практическую проблему. После завершения TCP-соединения сокет попадает в состояние TIME_WAIT на 60 секунд (значение 2 * MSL, MSL — Maximum Segment Lifetime, максимальное время жизни сегмента в сети). Если сервер перезапустился в этом окне, bind() вернёт EADDRINUSE — адрес занят. SO_REUSEADDR разрешает привязку к порту, на котором ещё висят сокеты в TIME_WAIT. Практически каждый TCP-сервер устанавливает эту опцию. Подробно про закрытие соединения и смысл TIME_WAIT — в TCP.
listen() переводит сокет из состояния «создан» в состояние «принимает входящие соединения». Аргумент backlog (здесь 128) задаёт размер accept queue (очередь принятых соединений) — соединений, полностью прошедших трёхэтапное TCP-рукопожатие (three-way handshake: клиент отправляет SYN — запрос на соединение, сервер отвечает SYN-ACK, клиент подтверждает ACK), но ещё не извлечённых вызовом accept(). Отдельно существует SYN queue (очередь полуоткрытых соединений — клиент отправил SYN, но рукопожатие не завершено) для соединений в процессе рукопожатия (ограничена net.ipv4.tcp_max_syn_backlog). Когда accept queue заполнена, ядро не отбрасывает SYN нового клиента — оно перестаёт отвечать финальным ACK, и соединение зависает на стороне клиента. В Linux параметр backlog ограничен сверху значением net.core.somaxconn (по умолчанию 4096). nginx и PostgreSQL обычно устанавливают backlog в 511 и 128 соответственно. Сам handshake, разделение SYN queue / accept queue и practical tuning backlog разобраны в TCP и TCP Tuning.
accept() извлекает из очереди первое установленное соединение и возвращает новый дескриптор. Это ключевой момент: server_fd продолжает слушать, а client_fd привязан к конкретному клиенту. Серверный сокет один, а клиентских — столько, сколько принято соединений. После обработки запроса client_fd закрывается, а server_fd остаётся открытым на весь срок жизни сервера.
TCP-клиент
Клиенту не нужны bind() и listen(). Он создаёт сокет и вызывает connect(), который инициирует TCP three-way handshake (SYN → SYN-ACK → ACK):
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main(void) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket");
return 1;
}
struct sockaddr_in server_addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
};
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
if (connect(sockfd, (struct sockaddr *)&server_addr,
sizeof(server_addr)) == -1) {
perror("connect");
return 1;
}
const char *request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
write(sockfd, request, strlen(request));
char buf[4096];
ssize_t n = read(sockfd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("response: %s\n", buf);
}
close(sockfd);
return 0;
}connect() блокируется до завершения handshake (обычно один RTT (Round-Trip Time, время обхода) — ~0.5 мс на loopback, ~50-200 мс через интернет). Ядро само выбирает для клиента произвольный порт из диапазона ip_local_port_range (по умолчанию 32768-60999). Порт на стороне клиента называется ephemeral port (эфемерный порт) — он существует, пока живёт соединение.
Что происходит при write() на сокет
Вызов write(sockfd, buf, len) не отправляет данные по сети. Он копирует байты из пользовательского буфера в буфер отправки (send buffer) ядра и возвращает управление. Дальнейшее — дело TCP-стека: он разбивает данные на сегменты (обычно до MSS (Maximum Segment Size) = 1460 байт для Ethernet), добавляет заголовки, передаёт IP-уровню, тот — драйверу сетевой карты, а DMA-контроллер (DMA — Direct Memory Access, прямой доступ к памяти) копирует данные из памяти ядра в NIC (Network Interface Controller, сетевой контроллер).
write(sockfd, buf, len)
|
v
user-space buffer --> copy --> kernel send buffer (SO_SNDBUF, 128KB-6MB)
|
v
TCP segmentation (MSS ~1460B)
|
v
IP header + routing
|
v
DMA --> NIC --> wire
Размер буфера отправки определяется опцией SO_SNDBUF. Linux поддерживает автонастройку: типичный диапазон 128 КБ - 6 МБ в зависимости от доступной памяти и характеристик соединения. Когда буфер отправки заполнен, write() блокируется — именно поэтому медленный получатель замедляет отправителя, и TCP обеспечивает flow control без участия приложения.
write() возвращает число записанных байт. Оно может быть меньше запрошенного: если в буфере отправки свободно 1000 байт, а приложение передаёт 4000, write() скопирует 1000 и вернёт 1000. Надёжный код вызывает write() в цикле, пока не отправит все данные.
Чтение из сокета: поток без границ
read(sockfd, buf, sizeof(buf)) возвращает от 1 байта до sizeof(buf) — сколько данных есть в буфере приёма (receive buffer) на данный момент. TCP — потоковый протокол без понятия «сообщение». Если клиент отправил 5000 байт одним write(), сервер может получить их тремя read() по 1460, 1460 и 2080 — или одним, или пятьюдесятью. Границы write() на стороне отправителя не сохраняются.
Это означает, что протокол прикладного уровня должен сам определять, где заканчивается одно сообщение и начинается другое. Существует три классических подхода:
Префикс длины. Перед каждым сообщением отправляется 4 байта с длиной тела. Получатель сначала читает 4 байта, узнаёт длину N, затем читает ровно N байт. Так работают протоколы PostgreSQL (wire protocol) и Redis (RESP3 для bulk strings).
Разделитель. Сообщения отделяются специальной последовательностью, например \r\n. HTTP/1.1 использует \r\n для разделения заголовков и \r\n\r\n для границы между заголовками и телом. Проблема: разделитель не должен встречаться в данных.
Фиксированный размер. Каждое сообщение ровно K байт, более короткие дополняются нулями. Проще всего в реализации, но расточительно и негибко.
Unix domain sockets: локальный обмен без сетевого стека
Когда два процесса работают на одной машине — веб-сервер и база данных, приложение и Docker daemon — сетевой стек создаёт лишние затраты. TCP-пакет, адресованный 127.0.0.1, проходит через формирование TCP-заголовков, вычисление контрольных сумм, IP-маршрутизацию и обработку в loopback-интерфейсе, хотя данные никуда не уходят из ядра.
Unix domain socket (UDS, AF_UNIX) обходит весь сетевой стек. Данные копируются из буфера отправителя в буфер получателя напрямую в ядре, без TCP-заголовков, контрольных сумм и маршрутизации. Адрес — не IP + порт, а путь в файловой системе:
#include <sys/socket.h>
#include <sys/un.h>
int sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/var/run/app.sock", sizeof(addr.sun_path) - 1);
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
listen(sockfd, 128);Файл /var/run/app.sock появляется в файловой системе с типом s (socket):
$ ls -la /var/run/app.sock
srwxr-xr-x 1 app app 0 Mar 23 10:00 /var/run/app.sock
Разница в производительности измерима. Бенчмарки на типичном сервере показывают: задержка передачи короткого сообщения через UDS — 2-5 мкс, через TCP loopback — 10-20 мкс. Пропускная способность UDS на ~30% выше за счёт отсутствия обработки TCP/IP-заголовков и контрольных сумм.
PostgreSQL по умолчанию слушает на UDS /var/run/postgresql/.s.PGSQL.5432 — локальные клиенты подключаются через него, а не через TCP. Docker daemon принимает команды на /var/run/docker.sock. Wayland-композитор и клиенты обмениваются событиями через UDS. Во всех этих случаях клиент и сервер гарантированно на одной машине, и сетевой стек не нужен.
При использовании UDS нужно помнить: файл сокета не удаляется автоматически при аварийном завершении процесса. Следующий запуск получит EADDRINUSE при bind(). Стандартное решение — вызвать unlink() перед bind():
unlink("/var/run/app.sock");
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));Настройка поведения сокета
setsockopt() изменяет параметры сокета. Три опции встречаются чаще других.
TCP_NODELAY отключает алгоритм Нейгла (Nagle’s algorithm). По умолчанию TCP задерживает отправку маленьких сегментов, собирая данные, пока не накопится MSS или не придёт ACK на предыдущий сегмент. Это снижает количество пакетов в сети, но добавляет задержку до 200 мс при интерактивном обмене. Для протоколов, где каждый write() — отдельное сообщение, которое нужно доставить немедленно (Redis, игровые серверы, торговые системы), алгоритм Нейгла вреден:
int flag = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));SO_SNDBUF и SO_RCVBUF устанавливают размер буферов отправки и приёма. Обычно авто-настройка ядра справляется, но для bulk transfer через высоколатентные каналы (bandwidth-delay product (произведение пропускной способности на задержку) > размера буфера по умолчанию) ручная настройка увеличивает пропускную способность:
int bufsize = 4 * 1024 * 1024; // 4 MB
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize));Linux удваивает переданное значение (добавляет место для метаданных). Максимум ограничен net.core.rmem_max / net.core.wmem_max.
sendfile(): передача файла без копирования
Типичный веб-сервер, отдающий статический файл, выполняет два шага: read() файла в буфер пользовательского пространства, затем write() этого буфера в сокет. Данные проходят четыре копирования: диск → буфер ядра → буфер приложения → буфер ядра (сокета) → NIC. Два перехода через границу ядра (kernel-user-kernel) расходуют CPU на копирование и переключение контекста.
Обычный путь: sendfile():
disk --> kernel buf disk --> kernel buf
| |
v copy to user | splice в send buffer
user buf | (нет копирования
| | в user-space)
v copy to kernel |
socket send buf socket send buf
| |
v DMA v DMA
NIC NIC
4 копирования, 2 context switch 2 копирования, 0 context switch
sendfile() устраняет промежуточный шаг. Ядро передаёт данные из файлового буфера напрямую в буфер сокета, минуя пользовательское пространство:
#include <sys/sendfile.h>
// отправить 1 МБ файла в сокет, начиная со смещения 0
off_t offset = 0;
sendfile(client_fd, file_fd, &offset, 1024 * 1024);Один системный вызов вместо пары read/write. Данные не покидают ядро — не нужен буфер в адресном пространстве приложения, не нужны два переключения контекста. На современных NIC с поддержкой scatter-gather DMA ядро может избежать даже копирования между буферами внутри ядра — передаёт NIC дескрипторы страниц напрямую.
nginx использует директиву sendfile on для раздачи статических файлов. При отдаче файла размером 100 МБ разница заметна: без sendfile() CPU тратит ~30% времени на копирование, с sendfile() — близко к нулю, нагрузка ложится на DMA-контроллер.
Пределы последовательной обработки
Приведённый выше сервер обрабатывает клиентов по одному: пока read() ждёт данных от первого клиента, остальные стоят в очереди accept(). При 100 одновременных соединениях и среднем времени обработки 10 мс 99-й клиент ждёт ~1 секунду. При 10000 соединений задержка растёт до 100 секунд — то, что называют проблемой C10K.
Наивное решение — создать поток или процесс на каждое соединение. Но 10000 потоков — это ~80 ГБ виртуальной памяти только на стеки (8 МБ по умолчанию на каждый, большая часть виртуальная), плюс переключение контекста между ними. Ядру нужен другой механизм: способ сообщить процессу, какие из тысяч сокетов готовы к чтению или записи, без блокировки на каждом по отдельности. Это задача мультиплексирования ввода-вывода — epoll, kqueue и io_uring.
Sources
- Michael Kerrisk, 2010, The Linux Programming Interface — Chapters 56-61: Sockets: https://man7.org/tlpi/
- W. Richard Stevens, 2003, Unix Network Programming — Volume 1: https://www.pearson.com/en-us/subject-catalog/p/unix-network-programming-volume-1-the-sockets-networking-api/P200000009464/
man 2 socket: https://man7.org/linux/man-pages/man2/socket.2.htmlman 2 bind: https://man7.org/linux/man-pages/man2/bind.2.htmlman 7 tcp: https://man7.org/linux/man-pages/man7/tcp.7.htmlman 2 sendfile: https://man7.org/linux/man-pages/man2/sendfile.2.html