Работа с сокетом в C/C++
Сетевые сокеты
Мы объясним всю необходимую и базовую логику работы сокетов на примере Unix. А в конце представим примеры кода как для Unix (unistd), так и для Windows (winsocks2)
Сокет - это файловый дескриптор, открытый как для чтения, так и для записи. Предназначен для взаимодействия:
- разных процессов, работающих на одном компьютере (хосте);
- разных процессов, работающих на разных хостах.
Создается сокет с помощью системного вызова socket:
#include <sys/types.h>
#include <sys/socket.h>
int socket(
int domain, // тип пространства имён
int type, // тип взаимодействия через сокет
int protocol // номер протокола или 0 для авто-выбора
)
Механизм сокетов появился ещё в 80-е годы XX века, когда не было единого стандарта для сетевого взаимодействия, и сокеты являлись абстракцией поверх любого механизма сетевого взаимодействия, поддерживая огромное количество разных протоколов.
В современных системах используемыми можно считать несколько механизмов, определяющих пространство имен сокетов; все остальное - это legacy, которое мы дальше рассматривать не будем.
AF_UNIX(man 7 unix) - пространство имен локальных UNIX-сокетов, которые позволяют взаимодействовать разным процессам в пределах одного компьютера, используя в качестве адреса уникальное имя (длиной не более 107 байт) специального файла.AF_INET(man 7 ip) - пространство кортежей, состоящих из 32-битных IPv4 адресов и 16-битных номеров портов. IP-адрес определяет хост, на котором запущен процесс для взаимодействия, а номер порта связан с конкретным процессом на хосте.AF_INET6(man 7 ipv6) - аналогичноAF_INET, но используется 128-разрядная адресация хостов IPv6; пока этот стандарт поддерживается не всеми хостерами и провайдерами сети Интернет.AF_PACKET(man 7 packet) - взаимодействие на низком уровне.
В абсолютном большинстве наших задач будет использоваться пространство адресов AF_INET
Через сокеты обычно происходит взаимодействие одним из двух способов (указывается в качестве второго параметра type):
SOCK_STREAM- взаимодействие с помощью системных вызововreadиwriteкак с обычным файловым дескриптором. В случае взаимодействия по сети, здесь подразумевается использование протоколаTCP.SOCK_DGRAM- взаимодейтсвие без предвариательной установки взаимодействия для отправки коротких сообщений. В случае взаимодействия по сети, здесь подразумевается использование протоколаUDP.
Для протокола TCP используйте SOCK_STREAM. Для протокола UDP используйте SOCK_DGRAM
Концептуальный алгоритм работы сокетов
Сокет -- это просто файловый дескриптор, который позволяет читать и писать в выделенную область памяти нескольким процессам
Сокет - это НЕ сервер! Сокет должен быть открыт как на "клиентском" процессе, так и на "серверном".
Сокет может делать следующее.
- Привязаться к адресу (bind). Т.к. сокет -- это просто файловый дескриптор, а мы хотим работать с его памятью по сети, то мы должны привязать его адрес к сетевому адресу (чтобы обеспечить связь по сети с областью памяти в локальной файловой системе). Эта команда позволяет как раз сообщить ОС, какой сетевой адрес связать с памятью сокета в локальной файловой системе.
- Читать (read или recv) (т.к. это просто файловый дескриптор и нам доступны все те же операции, что и для файлов). Этим действием просто происходит запись в файл по файловому дескриптору .
- Писать (write или send) (т.к. это просто файловый дескриптор и нам доступны все те же операции, что и для файлов). Этим действием просто происходит чтение из файла по файловому дескриптору.
- Слушать (listen). Делает сокет "пассивным". В рамках этой операции сокет отслеживает, пока какой-нибудь клиентский сокет (в большинстве случаев из другого процесса) не подключится к нему.
- Принимать соединение (accept). Принимает соединение с клиентского сокета и создает НОВЫЙ сокет для обмена данными.
Для подключения и установки соединение сервер использует ОДИН сокет, а для последующего общения с клиентом (после установки соединения) использует ДРУГОЙ сокет. Файловый дескриптор именно этого сокета возвращается методом accept.
- Открывать подключение (connect). Эта функция используется клиентским сокетом, чтобы осуществить попытку соединения с серверным сокетом.
- Закрывать подключение (close). Эта функция используется для закрытия как клиентским, так и серверным сокетом.
Внимание! Количество открытых файловых дескрипторов ограничено системой! Не забывайте закрывать сокеты. Особенно, если вы делает сервисы, которые работают 24/7. Исчерпать лимит неаккуратно открываемых и закрываемых сокетов можно вполне реально
| Функции серверного сокета | Функции клиентского сокета |
|
|
Нюанс и договоренности
Для решения наших задач обсудим нюансы и договоренности, которым мы будем следовать при работе с сокетами
Существуют и другие подходы, но мы будем для общности и упрощения пользоваться теми, что описаны здесь
Асинхронная vs Синхронная работа с сокетами
Сокеты можно сделать как блокирующими (синхронными), так и нет (асинхронными). Для большинства операций мы будем пользоваться блокирующими сокетами (однако иногда прибегать и к блокирующим).
Получение данных по сокету
Когда мы получаем данные по сети, не всегда они могут приходить в рамках одного TCP кадра (TCP frame). Иногда пакет может разделяться на несколько кадров, а реальная временная задержка между получением этих кадров клиентским сокетом может быть такой, что сокет в операции recv отдаст только часть данных, которые ему отправили.
Одной операции recv недостаточно для принятие всех данных по сокету в ОБЩЕМ случае
Пакеты на кадры бьются по ходу своего следования в сети. Это могут делать маршрутизаторы и коммутаторы на транспортном уровне. Отладить такое поведение очень сложно, потому что оно зависит от множества реальных параметров настоящей сети, стохастических эффектов и прочего.
Поэтому, в нашем случае мы будем использовать цикл while для получения данных с сокета до тех пор, пока на нем не закончатся данные, которые он нам может отдать.
Примеры программной реализации
Общие функции
Создание сокета
В данном примере создадим сокет для взаимодействия по сети по TCP протоколу.
Первым параметром мы указываем AF_INET, сообщая, что мы хотим определить сокет в пространстве адресов сети inet.
Вторым параметром мы определяем тип сокета (SOCK_STREAM указывает на то, что мы планируем тот тип сокета, который всегда применяется при использовании TCP протокола).
Третий параметр говорит нам об используемом протоколе, но это рудимент, который остался от изначальной версии socket API (ещё с древних времен). Мы указываем его равным 0, т.к., в любом случае тип сокета SOCK_STREAM использует TCP протокол.
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
Далее нам необходимо обработать ошибки. Если socket возвращает -1, это значит, что случилась ошибка. Её можно узнать с помощью макроса errno, а удостовериться, какой код ошибки что означает можно воспользовавшись мануалом
man 2 socket
Клиентский сокет
Подключение к удаленному сокету
// Для удобства заводим IP адрес и порт в привычном для нас виде
std::string ip = "127.0.0.1";
uint16_t port = 4001;
// Надо сделать структуру serv_addr,
// где будет храниться адрес сокета, к которому мы хотим подключиться
struct sockaddr_in serv_addr;
// Определяем для сокета пространство адресов сети inet
serv_addr.sin_family = AF_INET;
// Переводим байтовый порядок хоста в сетевой байтовый порядок для значения порта
// Чаще всего в сетях используется Big Endian,
// а на ОС может использоваться как Little Endian, так и Big Endian (зависит от архитектуры CPU)
serv_addr.sin_port = htons(port);
// Далее надо преобразовать IP адрес из читаемого (Presentation) формата в сетевой (бинарный) (Network)
// Inet Presentation to Network
// В отличие функции перевода порта тут уже могут быть ошибки.
// Входная строка, в общем случае может быть любая и мы не можем валидировать её на семантическом уровне
if (inet_pton(serv_addr.sin_family, ip.c_str(), &serv_addr.sin_addr) <= 0) {
std::cout << "Address " << ip.c_str() << " not supported" << std::endl;
return EXIT_FAILURE;
}
// Ну а именно здесь происходит попытка соединения
// Т.е., в нашем TCP случае -- попытка TCP рукопожатия (TCP handshake)
if (connect(client_socket, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) < 0) {
std::cout << "Error to connect " << errno << std::endl;
return EXIT_FAILURE;
}
// Работайте с сокетом дальше!
// И не забудьте закрыть :)
close(client_socket)
Отправка данных
Для отправки данных по сокету (записи в сокет) используется функция send или write. По сути, эти функции делают одно и то же, но send чуть более низкоуровневая и позволяет достичь большей гибкости при настройке. Несмотря на это, send не сильно сложнее write, поэтому предлагаем использовать именно её.
Используйте функцию send для отправки, а не write!
// Для начала необходимо подготовить буфер для отправки
// в сигнатуре функции send буфер имеет неопределенный тип void
// в действительности мы пересылаем байты (т.е. те значения, которые укладываются в этот размер)
// Поэтому в качестве типа мы можем брать int8_t, uint8_t, char и т.п.
uint8_t buff[256] = {0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, 0xC5, 0xCD};
// В функции send мы указываем наш сокет, указатель на буфер и размер буфера, который стоит отправить
// Последним параметром идут флаги. Чаще всего мы оставляем их равными 0
ssize_t sent_bytes = send(client_socket, buff, 8, 0);
// Функция send возвращает количество успешно отправленных байт.
// Если функция вернула -1, то случилась ошибка и её можно проверить по макросу errno
if (sent_bytes < 0) {
int sent_error = errno;
switch (sent_error) {
case ECONNREFUSED:
// В случае этой ошибки соединение было разорвано удаленным сокетом (он отправил нам RST в случае TCP)
std::cerr << "Connection refused by peer" << std::endl;
break;
case EPIPE:
// Эта ошибка говорит о том, что соединение было потеряно
std::cerr << "Connection failed" << std::endl;
break;
}
}
Получение данных
// Определяем буфер для куска данных, которые мы будем
// получать по сокету
// Если данных будет больше, чем размер этого буфера,
// то мы получим их на несколько операций recv
uint8_t response_buffer_chunk[1024];
// Эта переменная будет нам показывать, сколько ВСЕГО байт мы получили
size_t recv_bytes = 0;
// Эта переменная будет использовать для возвращаемого значения
// из функции recv. Мы не можем использовать одну переменную вместо recv_bytes и recv_response,
// Потому что recv_bytes -- это беззнаковое число,
// которое характеризует количество полученных байт,
// а recv_resonse -- это знаковое число,
// которое может возвращать -1 в случае ошибки
ssize_t recv_result = 0;
// Нам надо определить структуру динамического размера, чтобы класть туда весь наш ответ
// по кусочкам. Для этого проще всего взять вектор
std::vector<uint8_t> response;
do {
static int chunk_count = 0;
std::cout << "chunk " << chunk_count++ << std::endl;
// Эта структура определяет набор файловых дескриторов,
// которые мы будем проверять на доступность для чтения
fd_set rset;
// В этой структуре мы храним таймаут для выполнения функции select,
// которая как раз и проверяет доступность файловых дескрипторов
struct timeval tv;
// Таймаут задается как сумма СЕКУНДЫ + МИКРОСЕКУНДЫ.
// Если бы мы все хранили только в секундах, то потеряли бы гибкость,
// а если бы использовали только микросекунды,
// то не могли бы себе оптимально хранить большие значения
tv.tv_sec = 0;
tv.tv_usec = 100000;
// Этим макросом сбрасываем набор файловых дескриторов
FD_ZERO(&rset);
// Этим макросом добавляем файловый дексриптор нашего сокета в набор rset
FD_SET(client_socket, &rset);
// Здесь мы проверяем, доступны ли файловые дескрипторы из rset на чтение
// В функцию можно передать три множества файловых дескрипторов (fd_set),
// чтобы проверить их на готовность к чтению, к записи и к наблюдению за исключениями.
// Первым параметром надо взять максимальное значение файловых дескрипторов из всех трех
// множеств и прибавить к нему 1.
// Вторым, третьим и четвертым параметрами передаются множества файловых дескрипторов,
// готовность которых проверяется для чтения, записи и слежением за исключением соответственно
// Пятым параметром передается таймаут для функции select.
int rc = select(client_socket + 1, &rset, nullptr, nullptr, &tv);
std::cout << "RC: " << rc << std::endl;
if (rc == 0) {
// В случае, если select возвращает 0 -- данные закончились
std::cout << "Data ended!" << std::endl;
break;
}
if (rc == -1) {
// В случае, если select возвращает -1 -- случилась ошибка
// см. man и errno
std::cerr << "RC error: " << errno << std::endl;
break;
}
// Функция recv позволяет считать данные из сокета в буфер (в наш chunk)
recv_result = recv(client_socket, response_buffer_chunk, 1024, 0);
std::cout << "Recv result " << recv_result << std::endl;
// Функция возвращает либо -1 (в случае ошибки), либо количество полученных байт
if (recv_result > 0) {
recv_bytes += recv_result;
// После получения данных в буфер,
// мы должны их положить в конец нашей динамической структуры ответа
response.insert(response.end(), response_buffer_chunk, response_buffer_chunk + recv_result);
}
if (recv_result == -1) {
int error = errno;
if (error != EAGAIN) std::cerr << "Error while reading " << error << std::endl;
}
} while (recv_result > 0);
Серверный сокет
// TODO: дописать
