Основы сокетов и TCP в C: жизненный цикл и структуры данных
Жизненный цикл TCP-сервера и абстракция сокетов
Сетевое взаимодействие в современных операционных системах строится вокруг концепции сокетов (от англ. socket — разъём). В операционных системах семейства Linux и UNIX сокет представляет собой абстракцию, позволяющую приложению отправлять и получать данные так же, как если бы оно читало или писало в обычный файл.
С технической точки зрения сокет — это дескриптор файла (целое число), который операционная система связывает с сетевым соединением. Когда программа хочет отправить данные по сети, она просто записывает их в этот дескриптор, а всю сложную работу по маршрутизации, упаковке в пакеты и контролю доставки берёт на себя ядро ОС.
Сокет — это конечная точка двустороннего канала связи между двумя программами, работающими в сети. Он привязывается к номеру порта, чтобы транспортный уровень мог идентифицировать приложение, которому предназначены данные.
Для создания надёжных серверов используется протокол TCP (Transmission Control Protocol). В отличие от UDP, TCP гарантирует доставку данных, сохраняет порядок следования байтов и автоматически запрашивает повторную передачу при потерях. Это делает его идеальным выбором для веб-серверов, баз данных и мессенджеров.
Этапы работы TCP-сервера
Жизненный цикл любого TCP-сервера на языке C состоит из строгой последовательности системных вызовов. Пропуск или неправильный порядок этих шагов приведёт к ошибке.
- Создание (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 выглядит так:
struct sockaddr_in {
short sin_family; // Семейство адресов (AF_INET для IPv4)
unsigned short sin_port; // Номер порта (16 бит)
struct in_addr sin_addr; // IP-адрес (32 бита)
char sin_zero[8]; // Заглушка для выравнивания размера с sockaddr
};
Проблема порядка байтов (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 возвращает файловый дескриптор.
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("Ошибка создания сокета");
exit(EXIT_FAILURE);
}
Здесь AF_INET указывает на использование IPv4, а SOCK_STREAM — на использование протокола TCP.
Шаг 2: Настройка SO_REUSEADDR
Это критически важная практика. Когда вы останавливаете сервер и сразу запускаете его снова, ОС может удерживать порт в состоянии TIME_WAIT ещё несколько минут. Попытка привязать сокет к этому порту выдаст ошибку "Address already in use". Чтобы избежать этого, устанавливаем опцию SO_REUSEADDR.
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
perror("Ошибка setsockopt");
exit(EXIT_FAILURE);
}
Шаг 3: Привязка (bind)
Заполняем структуру sockaddr_in и привязываем сокет к порту 8080 и всем доступным сетевым интерфейсам (INADDR_ANY).
struct sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);
address.sin_port = htons(8080);
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("Ошибка привязки (bind)");
exit(EXIT_FAILURE);
}
Обратите внимание на приведение типа (struct sockaddr *)&address. Это классический паттерн сетевого программирования на C.
Шаг 4: Прослушивание (listen)
Переводим сокет в режим ожидания. Второй аргумент — это backlog, размер очереди ожидающих подключений. Если одновременно придут 15 клиентов, а backlog равен 10, последние 5 получат отказ в соединении, пока сервер не вызовет accept для первых.
if (listen(server_fd, 10) < 0) {
perror("Ошибка listen");
exit(EXIT_FAILURE);
}
Шаг 5: Принятие соединения (accept)
Функция accept блокирует выполнение программы до тех пор, пока не появится новый клиент. Как только клиент подключается, функция возвращает новый дескриптор.
int addrlen = sizeof(address);
int client_fd = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
if (client_fd < 0) {
perror("Ошибка accept");
exit(EXIT_FAILURE);
}
Шаг 6: Обмен данными и закрытие
Теперь мы можем отправить данные клиенту через client_fd с помощью функции send и закрыть соединение.
char *hello = "Привет от TCP-сервера!\n";
send(client_fd, hello, strlen(hello), 0);
// Закрываем сокет клиента
close(client_fd);
// Закрываем слушающий сокет сервера (при завершении работы)
close(server_fd);
Этот код описывает синхронный, однопоточный сервер. Он может обслужить только одного клиента за раз. Если первый клиент подключится и не закроет соединение, второй клиент будет висеть в очереди backlog, ожидая своей очереди. Именно поэтому для обслуживания множества клиентов одновременно применяются потоки (threads) или механизмы мультиплексирования (epoll), которые будут подробно разобраны в следующих частях курса.