1. Основы сетевого программирования на C: сокеты и адресация
Сетевое программирование на C — это фундамент, на котором строятся все современные высоконагруженные системы, от веб-серверов (Nginx, Apache) до баз данных (PostgreSQL, Redis). В отличие от высокоуровневых языков, где сеть скрыта за удобными абстракциями, C заставляет разработчика работать напрямую с интерфейсом операционной системы. Это даёт абсолютный контроль над памятью и производительностью, но требует глубокого понимания каждого этапа передачи данных.
В основе сетевого взаимодействия в UNIX-подобных системах лежит концепция сокета (от англ. socket — разъём, гнездо).
> Сокет — это абстрактная конечная точка сетевого соединения, которая со стороны операционной системы выглядит как обычный файловый дескриптор.
Поскольку в UNIX «всё есть файл», работа с сетью концептуально похожа на работу с текстовым файлом: мы открываем сокет, пишем в него данные, читаем из него и закрываем. Однако сеть непредсказуема: данные могут потеряться, прийти частями или в неправильном порядке.
Сетевая адресация и порядок байт
Чтобы два компьютера могли обмениваться данными, им нужны координаты друг друга. В стеке протоколов TCP/IP эти координаты состоят из двух элементов:
Аналогия из жизни: IP-адрес — это номер многоквартирного дома, а порт — номер конкретной квартиры, где живёт нужный вам адресат.
Порт — это 16-битное целое число, поэтому его значения лежат в диапазоне . Порты до 1024 зарезервированы системой (например, 80 для HTTP, 443 для HTTPS), и для их прослушивания требуются права суперпользователя (root).
Проблема порядка байт (Endianness)
Разные архитектуры процессоров хранят многобайтовые числа в памяти по-разному.
Представьте число 305 419 896. В шестнадцатеричной системе оно выглядит как 0x12345678 и занимает 4 байта.
78 56 34 12.12 34 56 78.Если компьютер x86 отправит число 0x12345678 как есть компьютеру с архитектурой SPARC, тот прочитает его как 0x78563412 (что равно 2 018 915 346). Произойдёт катастрофа.
Чтобы этого избежать, был введён сетевой порядок байт (Network Byte Order), который всегда является Big-Endian. Перед отправкой адреса или порта в сеть, мы обязаны перевести их из порядка хоста (Host Byte Order) в сетевой. Для этого в C есть специальные функции:
htons() (Host TO Network Short) — конвертирует 16-битное число (обычно порт).htonl() (Host TO Network Long) — конвертирует 32-битное число (обычно IPv4 адрес).ntohs() и ntohl() — делают обратное преобразование при получении данных из сети.TCP против UDP: выбор фундамента
Прежде чем писать код, необходимо выбрать транспортный протокол. Socket API позволяет работать с обоими, но их поведение кардинально различается.
| Характеристика | TCP (Transmission Control Protocol) | UDP (User Datagram Protocol) | | :--- | :--- | :--- | | Тип связи | С предварительным установлением соединения | Без установления соединения | | Надёжность | Гарантирует доставку. Потерянные пакеты запрашиваются заново | Не гарантирует доставку («выстрелил и забыл») | | Порядок | Гарантирует строгий порядок байт | Пакеты могут прийти в любом порядке | | Формат данных | Непрерывный поток байт (Stream) | Отдельные сообщения (Datagram) | | Скорость | Медленнее (из-за подтверждений и рукопожатий) | Максимально быстрый | | Применение | Веб-сайты, базы данных, передача файлов, SSH | Онлайн-игры, стриминг видео, DNS, VoIP |
Для высоконагруженных серверов, где важна целостность данных (например, HTTP-сервер), всегда используется TCP. Если вы пишете сервер для многопользовательского шутера, где потеря координаты игрока на одну миллисекунду не критична (в следующем кадре придёт новая), выбирают UDP.
Анатомия TCP-сервера: пошаговый разбор
Жизненный цикл классического блокирующего TCP-сервера состоит из строгой последовательности системных вызовов: socket() bind() listen() accept() recv()/send() close().
!Жизненный цикл сокетов: от создания до обмена данными
Рассмотрим каждый этап детально, так как ошибки на любом из них в высоконагруженной среде приводят к падению сервиса.
1. Создание сокета: socket()
Функция socket() просит ядро ОС выделить ресурсы для нового сетевого соединения.
AF_INET (Address Family) указывает, что мы используем IPv4. Для IPv6 используется AF_INET6.SOCK_STREAM указывает на использование TCP (поток байт). Для UDP нужно указать SOCK_DGRAM.0 — протокол по умолчанию для выбранной комбинации (TCP для SOCK_STREAM).Возвращаемое значение — это целое число (файловый дескриптор). Если оно равно -1, значит, ОС не смогла создать сокет (например, закончился лимит открытых файлов в системе).
2. Привязка к адресу: bind()
Созданный сокет пока висит в вакууме. Ему нужно назначить IP-адрес и порт. Для этого используется структура sockaddr_in.
Константа INADDR_ANY (которая равна 0.0.0.0) говорит ядру: «Принимай соединения на порт 8080 со всех сетевых интерфейсов этого компьютера» (и по Wi-Fi, и по кабелю, и через локальный 127.0.0.1).
Обратите внимание на приведение типов (struct sockaddr*). В C нет объектно-ориентированного наследования, поэтому Socket API использует «сишный полиморфизм». Функция bind принимает универсальную структуру sockaddr, а мы передаём специфичную для IPv4 структуру sockaddr_in, жёстко приводя указатель.
Важный нюанс для серверов (SO_REUSEADDR):
Если ваш сервер упадёт и вы попытаетесь запустить его снова, bind() часто возвращает ошибку Address already in use. Это происходит потому, что ОС удерживает порт в состоянии TIME_WAIT ещё пару минут после закрытия программы, чтобы «доловить» заблудившиеся в сети пакеты. Для разработки и высоконагруженных систем это неприемлемо. Решение — установить опцию сокета перед bind():
3. Открытие дверей: listen()
Функция listen() переводит сокет в пассивный режим. Теперь он не может отправлять данные, его единственная задача — слушать стук в дверь.
Второй аргумент (1024) — это backlog (очередь ожидающих соединений). Когда клиенты пытаются подключиться к серверу, ОС помещает их в очередь. Если сервер не успевает их обрабатывать (вызывать accept), очередь заполняется. Когда очередь переполнена, новые клиенты получают ошибку Connection refused. В старых учебниках часто пишут listen(fd, 5), но для высоконагруженных серверов это значение устанавливают в тысячи (например, 4096 или SOMAXCONN).
4. Приём клиента: accept()
Это критически важный момент для понимания архитектуры. Функция accept() по умолчанию блокирует выполнение программы. Она останавливает поток и ждёт, пока в очереди не появится хотя бы один клиент.
Когда клиент появляется, accept() делает магию: она возвращает новый файловый дескриптор (client_fd).
Оригинальный server_fd продолжает слушать порт 8080. А новый client_fd используется исключительно для общения с этим конкретным клиентом.
> Аналогия: server_fd — это оператор на ресепшене, который только принимает звонки. Как только звонок поступает, он переводит его на свободного менеджера (client_fd), а сам возвращается к ожиданию новых звонков.
5. Обмен данными: recv() и send()
Получив client_fd, мы можем читать запросы и отправлять ответы.
Главная ловушка новичков: частичные отправки и чтения
Здесь кроется самая частая ошибка при написании сетевых приложений на C.
Многие думают, что если вызвать send(fd, data, 10000, 0), то функция отправит ровно 10 000 байт. Это не так.
TCP — это потоковый протокол. У ОС есть внутренний буфер отправки для каждого сокета. Если буфер почти заполнен, send() может скопировать туда только часть ваших данных, например, 1400 байт, и вернуть число 1400. Остальные 8600 байт не будут отправлены, и ваша программа должна сама позаботиться о том, чтобы отправить остаток.
!Интерактивный симулятор системного вызова send()
В высоконагруженных серверах вы обязаны писать обёртку вокруг send(), которая в цикле досылает данные:
То же самое касается recv(). Если клиент отправил вам JSON размером 50 КБ, один вызов recv() может вернуть только первые 2 КБ. Вы должны вызывать recv() в цикле, пока не получите весь JSON целиком (для этого нужно знать длину сообщения заранее, например, из заголовка Content-Length в HTTP).
Закрытие соединения
Когда обмен данными завершён, сокет необходимо закрыть, чтобы вернуть файловый дескриптор операционной системе:
Если сервер обрабатывает тысячи соединений и забывает вызывать close(), у него быстро закончатся доступные файловые дескрипторы (утечка ресурсов), и accept() начнёт возвращать ошибку EMFILE (Too many open files).
Итоги и взгляд в будущее
Мы рассмотрели базовую модель блокирующего сервера. Её главная проблема в том, что вызов recv() блокирует выполнение программы, пока клиент не пришлёт данные. Если клиент подключился, но ничего не отправляет (например, из-за медленного мобильного интернета), весь наш сервер зависнет, ожидая его, и не сможет принять других клиентов.
Чтобы решить эту проблему и обрабатывать десятки тысяч соединений одновременно, нам потребуется перейти к многопоточности, а затем — к неблокирующему вводу-выводу и механизмам мультиплексирования (таким как epoll в Linux). Эти продвинутые архитектурные паттерны мы начнём разбирать в следующей статье курса.