Сокеты

Предпосылки: файловый ввод-вывод (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


Файловый ввод-вывод | Мультиплексирование ввода-вывода