Разработка многопоточного TCP-сервера на языке C

Практический курс по созданию надежных многопоточных TCP-серверов на C. Вы изучите жизненный цикл сокетов, работу с потоками (pthread), корректную обработку ошибок и управление памятью на готовых шаблонах кода.

1. Основы сокетов и TCP в C: жизненный цикл и структуры данных

Жизненный цикл TCP-сервера и абстракция сокетов

Сетевое взаимодействие в современных операционных системах строится вокруг концепции сокетов (от англ. socket — разъём). В операционных системах семейства Linux и UNIX сокет представляет собой абстракцию, позволяющую приложению отправлять и получать данные так же, как если бы оно читало или писало в обычный файл.

С технической точки зрения сокет — это дескриптор файла (целое число), который операционная система связывает с сетевым соединением. Когда программа хочет отправить данные по сети, она просто записывает их в этот дескриптор, а всю сложную работу по маршрутизации, упаковке в пакеты и контролю доставки берёт на себя ядро ОС.

> Сокет — это конечная точка двустороннего канала связи между двумя программами, работающими в сети. Он привязывается к номеру порта, чтобы транспортный уровень мог идентифицировать приложение, которому предназначены данные. > > Основы сетевого программирования

Для создания надёжных серверов используется протокол TCP (Transmission Control Protocol). В отличие от UDP, TCP гарантирует доставку данных, сохраняет порядок следования байтов и автоматически запрашивает повторную передачу при потерях. Это делает его идеальным выбором для веб-серверов, баз данных и мессенджеров.

Этапы работы TCP-сервера

Жизненный цикл любого TCP-сервера на языке C состоит из строгой последовательности системных вызовов. Пропуск или неправильный порядок этих шагов приведёт к ошибке.

!Схема жизненного цикла TCP-соединения

  • Создание (socket): ОС выделяет ресурсы для нового сокета и возвращает его дескриптор.
  • Привязка (bind): Сокету назначается конкретный IP-адрес и порт на сервере.
  • Прослушивание (listen): Сокет переводится в пассивный режим ожидания входящих подключений.
  • Принятие соединения (accept): Сервер извлекает запрос из очереди и создаёт новый сокет специально для общения с этим клиентом.
  • Обмен данными (recv/send): Чтение и запись данных.
  • Закрытие (close): Освобождение ресурсов и разрыв соединения.
  • Важнейшая концепция, которую часто упускают новички: сервер работает с двумя типами сокетов. Первый — это слушающий сокет (listening socket), который только принимает новые подключения. Второй — подключённый сокет (connected socket), который создаётся функцией accept для каждого нового клиента. Слушающий сокет никогда не передаёт данные, он работает как швейцар на входе, направляя гостей к их столикам (подключённым сокетам).

    Структуры данных и сетевой байтовый порядок

    Прежде чем вызывать функции API, необходимо разобраться с тем, как в C описываются сетевые адреса. Исторически сложилось так, что API сокетов разрабатывалось универсальным, чтобы поддерживать разные протоколы (IPv4, IPv6, локальные UNIX-сокеты).

    Из-за этого в C используется своеобразный полиморфизм. Базовой структурой является sockaddr, но на практике для IPv4 используется sockaddr_in.

    | Структура | Назначение | Особенности | | :--- | :--- | :--- | | struct sockaddr | Универсальная структура для любых адресов | Используется только для приведения типов (кастинга) в функциях bind и accept | | struct sockaddr_in | Специфичная структура для протокола IPv4 | Содержит удобные поля для IP-адреса и порта. Легко заполняется программистом |

    Определение структуры sockaddr_in выглядит так:

    Проблема порядка байтов (Endianness)

    Разные процессоры хранят многобайтовые числа в памяти по-разному. Архитектура x86 (Intel/AMD) использует порядок Little-Endian (младший байт по меньшему адресу). Однако в компьютерных сетях стандартом является Big-Endian (старший байт по меньшему адресу), который также называют сетевым байтовым порядком.

    Представьте, что вы пишете дату. В России принято писать день-месяц-год (01.12.2023), а в США — месяц-день-год (12.01.2023). Если не договориться о едином формате, возникнет путаница. То же самое происходит с IP-адресами и портами.

    Чтобы код работал корректно на любой архитектуре, перед записью порта и адреса в структуру их нужно конвертировать с помощью специальных функций:

  • htons() (Host TO Network Short) — конвертирует 16-битное число (порт).
  • htonl() (Host TO Network Long) — конвертирует 32-битное число (IP-адрес).
  • Номер порта может принимать значения в диапазоне . Порты до 1024 зарезервированы системой (например, 80 для HTTP), поэтому для своих серверов лучше использовать порты выше 1024.

    Практическая реализация: шаг за шагом

    Рассмотрим создание базового TCP-сервера, который принимает подключение и отправляет клиенту приветственное сообщение. Это фундамент, на котором в дальнейшем будет построена многопоточная архитектура.

    Шаг 1: Создание сокета

    Функция socket возвращает файловый дескриптор.

    Здесь AF_INET указывает на использование IPv4, а SOCK_STREAM — на использование протокола TCP.

    Шаг 2: Настройка SO_REUSEADDR

    Это критически важная практика. Когда вы останавливаете сервер и сразу запускаете его снова, ОС может удерживать порт в состоянии TIME_WAIT ещё несколько минут. Попытка привязать сокет к этому порту выдаст ошибку "Address already in use". Чтобы избежать этого, устанавливаем опцию SO_REUSEADDR.

    Шаг 3: Привязка (bind)

    Заполняем структуру sockaddr_in и привязываем сокет к порту 8080 и всем доступным сетевым интерфейсам (INADDR_ANY).

    Обратите внимание на приведение типа (struct sockaddr *)&address. Это классический паттерн сетевого программирования на C.

    Шаг 4: Прослушивание (listen)

    Переводим сокет в режим ожидания. Второй аргумент — это backlog, размер очереди ожидающих подключений. Если одновременно придут 15 клиентов, а backlog равен 10, последние 5 получат отказ в соединении, пока сервер не вызовет accept для первых.

    Шаг 5: Принятие соединения (accept)

    Функция accept блокирует выполнение программы до тех пор, пока не появится новый клиент. Как только клиент подключается, функция возвращает новый дескриптор.

    Шаг 6: Обмен данными и закрытие

    Теперь мы можем отправить данные клиенту через client_fd с помощью функции send и закрыть соединение.

    Этот код описывает синхронный, однопоточный сервер. Он может обслужить только одного клиента за раз. Если первый клиент подключится и не закроет соединение, второй клиент будет висеть в очереди backlog, ожидая своей очереди. Именно поэтому для обслуживания множества клиентов одновременно применяются потоки (threads) или механизмы мультиплексирования (epoll), которые будут подробно разобраны в следующих частях курса.

    2. Создание простого TCP-сервера: базовые системные вызовы

    Обработка данных и многопоточность в TCP-сервере

    Как мы разобрали ранее, жизненный цикл сервера начинается с подготовки слушающего сокета и перехода в режим ожидания через accept. Однако получение дескриптора подключённого сокета — это лишь начало работы. Настоящая инженерия начинается на этапе обмена данными и масштабирования сервера для обслуживания сотен клиентов одновременно.

    Надежный обмен данными: recv и send

    Для чтения и записи данных в сетевые сокеты в языке C используются системные вызовы recv и send. Они похожи на стандартные функции работы с файлами read и write, но принимают дополнительный аргумент с флагами, позволяющими тонко настраивать поведение сети.

    Чтение данных (recv)

    Функция recv приостанавливает (блокирует) выполнение программы до тех пор, пока от клиента не поступят данные.

    Критически важно правильно обрабатывать возвращаемое значение этой функции. Оно сообщает о текущем состоянии соединения:

    * : Успешно прочитано байт. Данные помещены в буфер. * : Клиент штатно закрыл соединение. Это эквивалент достижения конца файла (EOF). Сервер должен освободить ресурсы и вызвать close для этого сокета. * : Произошла ошибка. Конкретный код ошибки записывается в глобальную переменную errno.

    > Переменная errno — это стандартный механизм языка C для информирования об ошибках системных вызовов. Чтобы получить текстовое описание ошибки, используется функция perror(), которая выводит в консоль ваше сообщение и автоматически добавляет к нему расшифровку из errno.

    Специфика TCP: Потоковый протокол

    Новички часто совершают одну и ту же ошибку, предполагая, что один вызов send на стороне клиента всегда равен одному вызову recv на стороне сервера.

    TCP — это потоковый протокол (stream protocol). Он не знает, что такое «сообщение» или «пакет данных приложения». Для TCP данные — это просто непрерывный поток байтов. Если клиент отправляет строку «Hello, World!» (13 байт), маршрутизаторы в интернете могут разбить её на фрагменты. Сервер может получить сначала «Hel» (3 байта), затем «lo, W» (5 байт) и, наконец, «orld!» (5 байт).

    Поэтому чтение данных всегда должно происходить в цикле, пока не будет найден логический конец сообщения (например, символ переноса строки \n или заранее переданный размер полезной нагрузки).

    Запись данных (send) и частичная отправка

    Функция send отправляет данные из буфера в сеть.

    Здесь кроется вторая классическая ловушка сетевого программирования — частичная отправка (partial send). Операционная система имеет внутренний сетевой буфер ограниченного размера. Если вы пытаетесь отправить 10 мегабайт данных, а в буфере ОС свободно только 2 мегабайта, функция send скопирует только 2 мегабайта и вернёт число отправленных байт. Остальные данные приложение должно отправить повторно.

    Чтобы гарантировать доставку всего сообщения, профессиональные разработчики пишут функцию-обёртку:

    Проблема синхронного сервера

    Если мы напишем сервер, который вызывает accept, затем в цикле читает данные через recv и отвечает через send, мы получим рабочий, но абсолютно неэффективный инструмент.

    Поскольку recv блокирует выполнение программы, сервер «зависнет», ожидая сообщения от первого клиента. Если в этот момент подключится второй клиент, он будет помещён в очередь ожидания ОС. Сервер не сможет даже принять его соединение, пока не закончит работу с первым. Это неприемлемо для реальных задач.

    Решение — делегировать обработку каждого нового клиента отдельному независимому процессу или потоку.

    Многопоточная архитектура с POSIX Threads

    В Linux и UNIX-подобных системах стандартом де-факто для работы с потоками является библиотека POSIX Threads (pthreads).

    Поток (thread) — это легковесный процесс, который выполняется параллельно с основной программой, но разделяет с ней общее адресное пространство (память).

    !Архитектура многопоточного TCP-сервера

    Логика многопоточного сервера выглядит так:

  • Главный поток (Main Thread) крутится в бесконечном цикле, вызывая только accept.
  • Как только появляется новый клиент, главный поток создаёт новый рабочий поток (Worker Thread).
  • Главному потоку больше не нужен этот клиент, он немедленно возвращается к accept.
  • Рабочий поток общается с клиентом (recv/send), а по завершении закрывает сокет и уничтожается.
  • Управление памятью и состояние гонки

    При передаче дескриптора сокета в новый поток возникает опасная ситуация, называемая состоянием гонки (race condition).

    Представьте, что вы передаёте в поток указатель на локальную переменную client_socket. Пока новый поток запускается, главный поток может пойти на следующую итерацию цикла accept и перезаписать значение client_socket новым подключением. В итоге оба потока будут читать данные из сокета второго клиента, а первый клиент будет потерян.

    Чтобы этого избежать, дескриптор каждого клиента должен выделяться в динамической памяти (куче) с помощью malloc.

    Отсоединённые потоки (Detached Threads)

    По умолчанию потоки в C создаются «присоединёнными» (joinable). Это значит, что после завершения работы потока ОС сохраняет его код возврата и ресурсы до тех пор, пока главный поток не вызовет функцию pthread_join. Если этого не сделать, возникает утечка памяти — поток превращается в «зомби».

    Поскольку нашему серверу не нужно дожидаться завершения каждого клиента, мы переводим потоки в состояние отсоединённого потока (detached thread). Такой поток автоматически освобождает все свои ресурсы сразу после завершения своей функции.

    Практическая реализация: Многопоточный Эхо-сервер

    Объединим все концепции в готовый шаблон многопоточного сервера. Этот сервер принимает сообщения от клиентов и отправляет их обратно (эхо).

    Этот шаблон решает проблему блокировок: функция recv внутри client_handler блокирует только один конкретный рабочий поток, в то время как главный поток main мгновенно возвращается к ожиданию новых подключений в accept.

    Однако при тысячах одновременных подключений создание отдельного потока на каждого клиента становится слишком ресурсозатратным для операционной системы. Для высоконагруженных систем применяются асинхронные механизмы мультиплексирования.

    3. Многопоточность и обработка нескольких клиентов с использованием pthread

    Архитектура многопоточного сервера: от теории к продакшену

    Переход от синхронного сервера к многопоточному требует изменения архитектурного мышления. Когда сервер обслуживает одного клиента, весь поток выполнения принадлежит ему. Но как только мы вводим параллелизм, возникают новые вызовы: управление памятью, защита общих данных и обработка системных сигналов, которые могут внезапно завершить работу всего приложения.

    Разделение обязанностей: Главный и рабочие потоки

    В классической модели «один поток на клиента» (Thread-per-Client) архитектура строго разделена на две роли.

    Главный поток выполняет роль диспетчера. Его единственная задача — быстро принимать новые соединения через accept и делегировать их обработку. Он не должен выполнять никаких длительных операций (чтение из базы данных, запись в файл или ожидание данных от сети).

    Рабочие потоки создаются индивидуально для каждого принятого соединения. Они изолированно общаются со своим клиентом, а после завершения сеанса связи самостоятельно освобождают выделенные ресурсы.

    !Архитектура диспетчера и рабочих потоков с общим ресурсом

    Защита общих ресурсов: Мьютексы

    Пока рабочие потоки полностью независимы, система работает стабильно. Но в реальных задачах потокам часто нужно взаимодействовать с общими данными. Например, серверу может потребоваться вести глобальный счетчик активных подключений, писать логи в один файл или обновлять общую базу данных в оперативной памяти.

    Если два потока попытаются одновременно изменить значение глобальной переменной active_clients, произойдет искажение данных. Для решения этой проблемы используется мьютекс.

    > Мьютекс (от англ. mutual exclusion — взаимное исключение) — это программный замок, который гарантирует, что в любой момент времени только один поток может выполнять критический участок кода.

    Работа с мьютексом в библиотеке POSIX Threads состоит из трех этапов:

  • Захват замка (pthread_mutex_lock). Если замок уже захвачен другим потоком, текущий поток приостанавливается и ждет своей очереди.
  • Работа с общими данными.
  • Освобождение замка (pthread_mutex_unlock).
  • Пример безопасного увеличения счетчика:

    Скрытая угроза: Сигнал SIGPIPE

    Одна из самых частых причин падения TCP-серверов на языке C, написанных новичками, — это игнорирование системных сигналов.

    Представьте ситуацию: сервер обрабатывает тяжелый запрос и начинает отправлять клиенту большой объем данных через send. В этот момент у клиента пропадает интернет, или он принудительно закрывает приложение. Соединение разрывается.

    Когда сервер попытается записать данные в разорванный сокет, операционная система сгенерирует сигнал SIGPIPE. По умолчанию реакция программы на этот сигнал — немедленное аварийное завершение (crash) всего процесса. Из-за одного отключившегося клиента упадет весь сервер, оборвав связь с сотнями других пользователей.

    Чтобы этого избежать, сервер должен явно сообщить ОС, что он готов самостоятельно обрабатывать ошибки записи. Это делается одной строкой в начале функции main:

    Теперь, вместо падения программы, функция send просто вернет -1, а в глобальную переменную запишется ошибка EPIPE, которую можно корректно обработать.

    Полный шаблон многопоточного TCP-сервера

    Объединим концепции безопасной передачи памяти, отсоединенных потоков, мьютексов и обработки сигналов в готовый к использованию шаблон.

    Ограничения модели и Проблема C10k

    Рассмотренная архитектура отлично работает для внутренних корпоративных сервисов, игровых серверов с небольшим онлайном или IoT-шлюзов. Однако при попытке масштабировать такой сервер на десятки тысяч одновременных подключений система столкнется с физическими ограничениями операционной системы. Это явление известно в индустрии как Проблема C10k (от англ. 10 000 connections).

    Каждый созданный поток требует ресурсов:

  • Память: По умолчанию в Linux каждому потоку выделяется стек размером от 2 до 8 мегабайт. Создание 10 000 потоков мгновенно съест десятки гигабайт оперативной памяти только на поддержание их жизнедеятельности.
  • Контекстное переключение: Процессор не может выполнять 10 000 потоков одновременно. Ядро ОС вынуждено постоянно ставить потоки на паузу, сохранять их состояние, загружать состояние следующего потока и давать ему квант времени. При огромном количестве потоков процессор начинает тратить больше времени на переключение между ними, чем на полезную работу.
  • | Характеристика | Thread-per-Client | Пул потоков + Мультиплексирование | | :--- | :--- | :--- | | Потребление памяти | Высокое ( от числа клиентов) | Низкое (фиксированное число потоков) | | Сложность кода | Низкая (линейная логика) | Высокая (асинхронные конечные автоматы) | | Макс. подключений | ~1 000 - 5 000 | Сотни тысяч | | Идеальное применение | Долгие сессии, тяжелые вычисления | Короткие запросы, высокая конкурентность |

    Для преодоления этих ограничений современные высоконагруженные серверы (такие как Nginx или Redis) используют пул потоков в связке с механизмами неблокирующего ввода-вывода (select, poll, epoll). В такой архитектуре заранее создается фиксированное небольшое количество потоков (равное числу ядер процессора), которые асинхронно обрабатывают события от тысяч сокетов одновременно.

    4. Обработка ошибок, управление памятью и лучшие практики разработки

    Искусство создания отказоустойчивых серверов на C

    Разработка сетевого приложения не заканчивается на успешной передаче первого байта данных. Прототип сервера написать легко, но создание системы, способной работать месяцами без перезагрузки, требует строгой дисциплины. В языке C нет сборщика мусора или встроенных механизмов перехвата исключений. Вся ответственность за стабильность ложится на архитектуру кода.

    Элегантная обработка ошибок: Паттерн очистки ресурсов

    В современных высокоуровневых языках программирования разработчики привыкли к конструкции try-catch. В C системные вызовы возвращают код статуса (обычно -1 при неудаче), а детали записываются в глобальную переменную errno.

    Когда функция выполняет несколько выделений памяти или открывает несколько файлов, обработка ошибок превращается в проблему. Если на третьем шаге произошла ошибка, нужно освободить ресурсы, выделенные на первом и втором шагах. Использование вложенных условий if быстро делает код нечитаемым.

    Для решения этой проблемы в системном программировании применяется Паттерн очистки ресурсов (Cleanup pattern) с использованием оператора goto.

    > Несмотря на то, что использование goto часто критикуют в академической среде, в ядре Linux и профессиональных C-серверах это стандарт де-факто для линейной и безопасной очистки памяти.

    Пример правильной организации функции обработки клиента:

    Такой подход гарантирует, что функция имеет ровно одну точку выхода, а ресурсы освобождаются в порядке, обратном их выделению.

    Управление памятью: цена одного забытого байта

    В многопоточном сервере рабочие потоки постоянно создаются и завершаются. Если поток выделяет память в куче, но завершается без вызова free, возникает Утечка памяти (Memory leak) — ситуация, при которой оперативная память компьютера заполняется потерянными данными, к которым программа больше не имеет доступа.

    !Калькулятор утечки памяти

    Для поиска подобных проблем в индустрии используется Valgrind — мощный профилировщик, который запускает скомпилированную программу в виртуальном окружении и отслеживает каждый вызов malloc и free. Если при завершении сервера Valgrind сообщает о потерянных байтах, код нельзя выпускать в продакшен.

    Гарантированная доставка: обертка для send

    Ранее мы выяснили, что TCP является потоковым протоколом, и функция send может отправить меньше данных, чем вы запросили. В реальных условиях, особенно при отправке больших файлов или при плохом интернет-соединении, частичная отправка происходит постоянно.

    Чтобы сервер не терял данные, необходимо написать функцию-обертку, которая будет в цикле вызывать send, сдвигая указатель буфера до тех пор, пока все байты не уйдут в сеть.

    Этот простой цикл — фундамент надежности любого сетевого приложения на C.

    Защита от зависаний: Таймауты сокетов

    Представьте ситуацию: клиент успешно установил соединение с сервером (функция accept отработала), рабочий поток запустился и вызвал recv. Но клиент ничего не отправляет. Он просто молчит.

    По умолчанию сокеты работают в блокирующем режиме. Это значит, что поток навсегда зависнет на строке с recv, ожидая данных. Злоумышленники используют эту особенность для проведения Атаки Slowloris — они открывают тысячи соединений и ничего не отправляют, заставляя сервер исчерпать весь пул потоков и зависнуть.

    Чтобы защитить сервер, необходимо устанавливать Таймаут сокета (Socket timeout). Это настройка, которая указывает операционной системе прервать операцию чтения или записи, если она длится дольше заданного времени.

    Настройка таймаута выполняется через функцию setsockopt:

    Теперь, если клиент будет молчать 10 секунд, функция recv вернет -1, а errno будет установлен в значение EAGAIN или EWOULDBLOCK. Сервер сможет корректно закрыть соединение и освободить поток.

    Изящное завершение: close vs shutdown

    Обычно для разрыва соединения используется системный вызов close. Он немедленно уничтожает файловый дескриптор и очищает буферы. Однако в сложных сетевых протоколах резкое закрытие может привести к потере данных, которые еще находились в пути.

    Для более тонкого управления используется функция shutdown. Она позволяет создать Полузакрытое соединение (Half-closed connection) — состояние, при котором сервер сообщает клиенту, что больше не будет отправлять данные, но всё ещё готов принимать их.

    | Характеристика | close(socket) | shutdown(socket, SHUT_WR) | | :--- | :--- | :--- | | Дескриптор | Уничтожается, использовать больше нельзя | Остается активным | | Чтение (recv) | Невозможно | Возможно (ожидание последних данных от клиента) | | Запись (send) | Невозможно | Невозможно (отправляется TCP-пакет FIN) | | Применение | Полное завершение работы с клиентом | Сигнал клиенту об окончании передачи файла |

    !Разница между close и shutdown

    Правильный алгоритм изящного завершения (graceful shutdown) выглядит так:

  • Сервер вызывает shutdown(socket, SHUT_WR).
  • Сервер продолжает вызывать recv в цикле, пока тот не вернет 0 (клиент подтвердил закрытие и тоже завершил передачу).
  • Только после этого сервер вызывает close(socket).
  • Соблюдение этих практик — управление памятью через goto, защита от частичной отправки, установка таймаутов и корректное закрытие соединений — превращает нестабильный студенческий проект в надежный сервер промышленного уровня.

    5. Полный пример многопоточного TCP-сервера: архитектура и разбор кода

    Полный пример многопоточного TCP-сервера: архитектура и разбор кода

    Проектирование сетевого приложения требует строгого разделения зон ответственности. Смешивание логики принятия соединений, обработки данных и управления памятью в одной функции неизбежно приводит к нестабильности. Для решения этой задачи в системном программировании применяется Архитектура «Диспетчер-Рабочий» (Dispatcher-Worker).

    Суть подхода заключается в том, что главный поток программы (диспетчер) занимается исключительно прослушиванием порта и принятием новых клиентов. Как только соединение установлено, диспетчер передает его отдельному рабочему потоку и немедленно возвращается к ожиданию следующих подключений.

    !Схема архитектуры Диспетчер-Рабочий

    Структуры данных и передача контекста

    При создании нового потока ему необходимо передать информацию о клиенте: файловый дескриптор сокета и, как правило, IP-адрес для логирования. В языке C функция потока принимает только один аргумент типа void *.

    Чтобы передать несколько параметров, создается специальная структура данных — Контекст потока (Thread context). Это блок памяти, содержащий всю необходимую информацию для изолированной работы потока с конкретным клиентом.

    Исходный код надежного сервера

    Ниже представлен полный шаблон многопоточного TCP-сервера, объединяющий все лучшие практики: защиту от сигналов, управление памятью, изоляцию контекста и корректное завершение.

    Разбор критических узлов кода

    Безопасность работы со строками

    Обратите внимание на строку buffer[bytes_received] = '\0';. Сетевые данные — это просто массив байтов. Если вы планируете выводить их на экран через printf или обрабатывать строковыми функциями C (например, strstr), массив обязан быть Нуль-терминированной строкой (Null-terminated string) — последовательностью символов, которая заканчивается специальным байтом с числовым значением ноль.

    Именно поэтому мы читаем BUFFER_SIZE - 1 байт: один байт всегда резервируется под символ конца строки. Без этого printf выйдет за пределы буфера, что приведет к выводу мусора из памяти или падению программы (Segmentation fault).

    Изоляция памяти

    В главном цикле мы вызываем malloc для структуры client_context_t. Если бы мы передавали указатель на локальную переменную из цикла while, следующий вызов accept перезаписал бы данные до того, как рабочий поток успел бы их прочитать. Выделение памяти в куче (heap) гарантирует, что каждый поток получит свой уникальный, неизменяемый извне экземпляр данных.

    Эволюция архитектуры: от потоков к мультиплексированию

    Представленная выше модель «один поток на клиента» отлично работает для сотен одновременных соединений. Однако при достижении десятков тысяч клиентов накладные расходы на память (каждый поток требует стек около 2-8 МБ) и переключение контекста становятся критическими.

    Для высоконагруженных систем применяется Мультиплексирование ввода-вывода (I/O Multiplexing). Это механизм операционной системы, позволяющий одному потоку одновременно наблюдать за множеством файловых дескрипторов и блокироваться до тех пор, пока хотя бы один из них не будет готов к чтению или записи.

    В Linux исторически существовали функции select и poll. Их проблема заключалась в линейной сложности : при каждом вызове ядро ОС должно было проверять весь список из сокетов. Если у вас 10 000 сокетов, а данные пришли только на один, система впустую проверяет 9 999 дескрипторов.

    Современным стандартом в Linux является системный вызов epoll. Он работает со сложностью . Ядро ОС само поддерживает список активных сокетов и при вызове epoll_wait мгновенно возвращает только те дескрипторы, на которых реально произошли события.

    Использование epoll порождает архитектурный Паттерн Реактор (Reactor pattern). В этой модели сервер работает в один поток, который крутится в бесконечном цикле — Event Loop (Цикл событий).

    Алгоритм Реактора:

  • Добавить слушающий сокет в epoll.
  • Запустить Event Loop: ждать событий от epoll.
  • Если событие на слушающем сокете — вызвать accept и добавить новый сокет клиента в epoll.
  • Если событие на клиентском сокете — прочитать данные, обработать их и отправить ответ.
  • В самых производительных серверах (например, Nginx или Node.js) паттерн Реактор комбинируется с пулом потоков: Event Loop только принимает данные из сети, а тяжелая вычислительная работа передается рабочим потокам. Это позволяет достичь максимальной пропускной способности при минимальном потреблении оперативной памяти.