Тема сетевого программирования является для разработчиков одной из важнейших в современном цифровом мире. Правда, надо признать, что большая часть сетевого программирования сосредоточена в области написания скриптов исполнения для web-серверов на языках PHP, Python и им подобных. Как следствие - по тематике взаимодействия клиент-сервер при работе с web-серверами написаны терабайты текстов в Интернете. Однако когда я решил посмотреть, что же имеется в Интернете по вопросу программирования сетевых приложений с использованием голых сокетов, то обнаружил интересную вещь: да, такие примеры конечно же есть, но подавляющее большинство написано под *nix-системы с использованием стандартных библиотек (что понятно – в области сетевого программирования Microsoft играет роль сильно отстающего и менее надежного «собрата» *nix-ов). Другими словами все эти примеры просто не будут работать под Windows. При определенных танцах с бубнами код сетевого приложения под Linux можно запустить и под Windows, однако это еще более запутает начинающего программиста, на которого и нацелены большинство статей в Интернете с примерами использования сокетов.

Ну а что же с документацией по работе с сетевыми сокетами в Windows от самой Microsoft? Парадоксальность ситуации заключается в том, что непосредственно в самой документации приведено очень беглое описание функций и их использования, а в примерах имеются ошибки и вызовы старых «запрещенных» современными компиляторами функций (к примеру, функция inet_addr() - https://docs.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-listen ) - такие функции конечно же можно вызывать, заглушив бдительность компилятора через #define-директивы, однако такой подход является полным зашкваром для любого даже начинающего программиста и категорически не рекомендуется к использованию. Более того, фрагмент кода в примере от Microsoft по ссылке выше:

service.sin_addr.s_addr = inet_addr("127.0.0.1");

вообще не заработает, т.к. полю Service.sin_addr.s_addr невозможно присвоить значение целого типа, которое возвращает функция inet_addr (возвращает unsigned long). То есть это ни много, ни мало - ошибка! Можно себе представить, сколько пытливых бойцов полегло на этом месте кода.

В общем, посмотрев на всё это, я решил написать базовую статью по созданию простейшего клиент-сервер приложения на С++ под Windows с детальным описанием всех используемых функций. Это приложение будет использовать Win32API и делать незамысловатую вещь, а именно: передавать сообщения от клиента к серверу и обратно, или, иначе говоря – напишем программу по реализации чата для двух пользователей.

Сразу оговорюсь, что статья рассчитана на начинающих программистов, которые только входят в сетевое программирование под Windows. Необходимые навыки – базовое знание С++, а также теоретическая подготовка по теме сетевых сокетов и стека технологии TCP/IP.

Теория сокетов за 30 секунд для "dummies"

Начну всё-таки немного с теории в стиле «for dummies». В любой современной операционной системе, все процессы инкапсулируются, т.е. скрываются друг от друга, и не имеют доступа к ресурсам друг друга. Однако существуют специальные разрешенные способы взаимодействия процессов между собой. Все эти способы взаимодействия процессов можно разделить на 3 группы: (1) сигнальные, (2) канальные и (3) разделяемая память.

Когда мы говорим про работу сетевого приложения, то всегда подразумеваем взаимодействие процессов: процесс 1 (клиент) пытается что-то послать или получить от Процесса 2 (сервер). Наиболее простым и понятным способом организации сетевого взаимодействия процессов является построение канала между этими процессами. Именно таким путём и пошли разработчики первых сетевых протоколов. Получившийся способ взаимодействия сетевых процессов в итоге оказался многоуровневым: основной программный уровень - стек сетевой технологии TCP/IP, который позволяет организовать эффективную доставку пакетов информации между различными машинами в сети, а уже на прикладном уровне тот самый «сокет» позволяет разобраться какой пакет какому процессу доставить на конкретной машине.

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

Для того, чтобы сокеты заработали под Windows, необходимо при написании программы пройти следующие Этапы:

  1. Инициализация сокетных интерфейсов Win32API.

  2. Инициализация сокета, т.е. создание специальной структуры данных и её инициализация вызовом функции.

  3. «Привязка» созданного сокета к конкретной паре IP-адрес/Порт – с этого момента данный сокет (его имя) будет ассоциироваться с конкретным процессом, который «висит» по указанному адресу и порту.

  4. Для серверной части приложения: запуск процедуры «прослушки» подключений на привязанный сокет.

    Для клиентской части приложения: запуск процедуры подключения к серверному сокету (должны знать его IP-адрес/Порт).

  5. Акцепт / Подтверждение подключения (обычно на стороне сервера).

  6. Обмен данными между процессами через установленное сокетное соединение.

  7. Закрытие сокетного соединения.

     Итак, попытаемся реализовать последовательность Этапов, указанных выше, для организации простейшего чата между клиентом и сервером. Запускаем Visual Studio, выбираем создание консольного проекта на С++ и поехали.

Этап 0: Подключение всех необходимых библиотек Win32API для работы с сокетами

Сокеты не являются «стандартными» инструментами разработки, поэтому для их активизации необходимо подключить ряд библиотек через заголовочные файлы, а именно:

  • WinSock2.h – заголовочный файл, содержащий актуальные реализации функций для работы с сокетами.

  • WS2tcpip.h – заголовочный файл, который содержит различные программные интерфейсы, связанные с работой протокола TCP/IP (переводы различных данных в формат, понимаемый протоколом и т.д.).

  • Также нам потребуется прилинковать к приложению динамическую библиотеку ядра ОС: ws2_32.dll. Делаем это через директиву компилятору: #pragma comment(lib, “ws2_32.lib”)

  • Ну и в конце Этапа 0 подключаем стандартные заголовочные файлы iostream и stdio.h   

Итого по завершению Этапа 0 в Серверной и Клиентской частях приложения имеем:

#include <iostream>
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <stdio.h>
#include <vector>

#pragma comment(lib, "Ws2_32.lib")

Обратите внимание: имя системной библиотеки ws2_32.libименно такое, как это указано выше. В Сети есть различные варианты написания имени данной библиотеки, что, возможно, связано иным написанием в более ранних версиях ОС Windows. Если вы используете Windows 10, то данная библиотека называется именно ws2_32.libи находится в стандартной папке ОС: C:/Windows/System32 (проверьте наличие библиотеки у себя, заменив расширение с “lib” на “dll”).

Этап 1: Инициализация сокетных интерфейсов Win32API

Прежде чем непосредственно создать объект сокет, необходимо «запустить» программные интерфейсы для работы с ними. Под Windows это делается в два шага следующим образом:

  • Нужно определить с какой версией сокетов мы работаем (какую версию понимает наша ОС) и

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

Первый шаг делается с помощью создания структуры типа WSADATA, в которую автоматически в момент создания загружаются данные о версии сокетов, используемых ОС, а также иная связанная системная информация:WSADATA wsData;

Второй шаг – непосредственный вызов функции запуска сокетов с помощью WSAStartup(). Упрощённый прототип данной функции выглядит так:

int WSAStartup (WORD <запрашиваемая версия сокетов>, WSADATA* <указатель на структуру, хранящую текущую версию реализации сокетов>)

Первый аргумент функции – указание диапазона версий реализации сокетов, которые мы хотим использовать и которые должны быть типа WORD. Этот тип данных является внутренним типом Win32API и представляет собой двухбайтовое слово (аналог в С++: unsigned short). Функция WSAStartup() просит вас передать ей именно WORD, а она уже разложит значение переменной внутри по следующему алгоритму: функция считает, что в старшем байте слова указана минимальная версия реализации сокетов, которую хочет использовать пользователь, а в младшем – максимальная. По состоянию на дату написания этой статьи (октябрь 2021 г.) актуальная версия реализации сокетов в Windows – 2. Соответственно, желательно передать и в старшем, и в младшем байте число 2. Для того, чтобы создать такую переменную типа WORD и передать в её старший и младший байты число 2, можно воспользоваться Win32API функцией MAKEWORD(2,2).

Можно немного повыёживаться и вспомнить (или полистать MSDN), что функция MAKEWORD(x,y) строит слово по правилу y << 8 | x.Нетрудно посчитать, что при x=y=2 значение функции MAKEWORD в десятичном виде будет 514. Можешь смело передать в WSAStartup() это значение, и всё будет работать.

Второй аргумент функции – просто указатель на структуру WSADATA, которую мы создали ранее и в которую подгрузилась информация о текущей версии реализации сокетов на данной машине.

WSAStartup() в случае успеха возвращает 0, а в случае каких-то проблем возвращает код ошибки, который можно расшифровать последующим вызовом функции WSAGetLastError().

Важное замечание: поскольку сетевые каналы связи и протоколы в теории считаются ненадежными (это отдельный большой разговор), то критически важно для сетевого приложения анализировать все возможные ошибки, которые возникают в процессе вызовов сокетных функций. По этой причине каждый вызов таких функций мы будем анализировать на ошибки и в случае их обнаружения завершать сетевые сеансы и закрывать открытые сокеты. Используем для этого переменную erStat типа int.

Также важно после работы приложения обязательно закрыть использовавшиеся сокеты с помощью функции closesocket(SOCKET <имя сокета>) и деинициализировать сокеты Win32API через вызов метода WSACleanup().

Итого код Этапа 1 следующий:

WSADATA wsData;
		
int erStat = WSAStartup(MAKEWORD(2,2), &wsData);
	
	if ( erStat != 0 ) {
		cout << "Error WinSock version initializaion #";
		cout << WSAGetLastError();
		return 1;
	}
	else
		cout << "WinSock initialization is OK" << endl;

Да, кода мало, а описания много. Так обычно и бывает, когда хочешь глубоко в чем-то разобраться. Так что на лабе будешь в первых рядах.

Этап 2: Создание сокета и его инициализация

Сокет в С++ – это структура данных (не класс) типа SOCKET. Её инициализация проводится через вызов функции socket(), которая привязывает созданный сокет к заданной параметрами транспортной инфраструктуре сети. Выглядит прототип данной функции следующим образом:

SOCKET socket(int <семейство используемых адресов>, int <тип сокета>, int <тип протокола>)

  • Семейство адресов: сокеты могут работать с большим семейством адресов. Наиболее частое семейство – IPv4. Указывается как AF_INET.

  • Тип сокета: обычно задается тип транспортного протокола TCP (SOCK_STREAM) или UDP (SOCK_DGRAM). Но бывают и так называемые "сырые" сокеты, функционал которых сам программист определяет в процессе использования. Тип обозначается SOCK_RAW

  • Тип протокола: необязательный параметр, если тип сокета указан как TCP или UDP – можно передать значение 0. Тут более детально останавливаться не будем, т.к. в 95% случаев используются типы сокетов TCP/UDP.

При необходимости подробно почитать про функцию socket() можно здесь.

Функция socket() возвращает дескриптор с номером сокета, под которым он зарегистрирован в ОС. Если же инициализировать сокет по каким-то причинам не удалось – возвращается значение INVALID_SOCKET.

Код Этапа 2 будет выглядеть так:

SOCKET ServSock = socket(AF_INET, SOCK_STREAM, 0);

	if (ServSock == INVALID_SOCKET) {
		cout << "Error initialization socket # " << WSAGetLastError() << endl; 
		closesocket(ServSock);
		WSACleanup();
		return 1;
	}
	else
		cout << "Server socket initialization is OK" << endl;

Этап 3: Привязка сокета к паре IP-адрес/Порт

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

Такое назначение делается с помощью функции bind(), имеющей следующий прототип:

int bind(SOCKET <имя сокета, к которому необходимо привязать адрес и порт>, sockaddr* <указатель на структуру, содержащую детальную информацию по адресу и порту, к которому надо привязать сокет>, int <размер структуры, содержащей адрес и порт>)

Функция bind() возвращает 0, если удалось успешно привязать сокет к адресу и порту, и код ошибки в ином случае, который можно расшифровать вызовом WSAGetLastError() - см. итоговый код Этапа 3 далее.

Тут надо немножно притормозить и разобраться в том, что за такая структура типа sockaddr передается вторым аргументом в функцию bind(). Она очень важна, но достаточно запутанная.

Итак, если посмотреть в её внутренности, то выглядят они очень просто: в ней всего два поля – (1) первое поле хранит семейство адресов, с которыми мы уже встречались выше при инициализации сокета, а (2) второе поле хранит некие упакованные последовательно и упорядоченные данные в размере 14-ти байт. Бессмысленно разбираться детально как именно эти данные упакованы, достаточно лишь понимать, что в этих 14-ти байтах указан и адрес, и порт, а также дополнительная служебная информация для других системных функций Win32API.

Но как же явно указать адрес и порт для привязки сокета? Для этого нужно воспользоваться другой структурой, родственной sockaddr, которая легко приводится к этому типу - структурой типа sockaddr_in.

В ней уже более понятные пользователю поля, а именно:

  • Семейство адресов - опять оно (sin_family)

  • Порт (sin_port)

  • Вложенная структура типа in_addr, в которой будет храниться сам сетевой адрес (sin_addr)

  • Технический массив на 8 байт (sin_zero[8])

При приведении типа sockaddr_in к нужному нам типу sockaddr для использования в функции bind() поля Порт (2 байта), Сетевой адрес (4 байта) и Технический массив (8 байт) как раз в сумме дают нам 14 байт, помещающихся в 14 байт, находящихся во втором поле структуры sockaddr. Первые поля у указанных типов совпадают – это семейство адресов сокетов (указываем AF_INET). Из этого видно, что структуры данных типа sockaddr и sockaddr_in тождественны, содержат одну и ту же информацию, но в разной форме для разных целей.

Соответственно, ввод данных для структуры типа sockaddr_in выглядит следующим образом:

  1. Создание структуры типа sockaddr_in : sockaddr_in servInfo;

  2. Заполнение полей созданной структуры servInfo

  • servInfo.sin_family = AF_INET;

  • servInfo.sin_port = htons(<указать номер порта как unsigned short>); порт всегда указывается через вызов функции htons(), которая переупаковывает привычное цифровое значение порта типа unsigned short в побайтовый порядок понятный для протокола TCP/IP (протоколом установлен порядок указания портов от старшего к младшему байту или «big-endian»).

  • Далее нам надо указать сетевой адрес для сокета. Тип этого поля – структура типа in_addr, которая по своей сути представляет просто особый «удобный» системным функциям вид обычного строчного IPv4 адреса. Таким образом, чтобы указать этому полю обычный IPv4 адрес, его нужно сначала преобразовать в особый числовой вид и поместить в структуру типа in_addr .

    Благо существует функция, которая переводит обычную строку типа char[], содержащую IPv4 адрес в привычном виде с точками-разделителями в структуру типа in_addr – функция inet_pton(). Прототип функции следующий:

    int inet_pton(int <семейство адресов>, char[] <строка, содержащая IP-адрес в обычном виде с точкой-разделителем>, in_addr* <указатель на структуру типа in_addr, в которую нужно поместить результат приведения строчного адреса в численный>).

    В случае ошибки функция возвращает значение меньше 0.

    Соответственно, если мы хотим привязать сокет к локальному серверу, то наш код по преобразованию IPv4 адреса будет выглядеть так:

    in_addr ip_to_num;

    erStat = inet_pton(AF_INET, “127.0.0.1”, &ip_to_num);

    if (erStat <= 0) {

                 cout << "Error in IP translation to special numeric format" << endl;

                 return 1;

           }

    Результат перевода IP-адреса содержится в структуре ip_to_num. И далее мы передаем уже в нашу переменную типа sockaddr_in значение преобразованного адреса:

    servInfo.sin_addr = ip_to_num;

Вся нужная информация для привязки сокета теперь у нас есть, и она хранится в структуре servInfo. Можно смело вызывать функцию bind(), не забыв при этом привести servInfo из типа sockaddr_in в требуемый функцииsockaddr*. Тогда итоговый код Этапа 3 (слава богу закончили) выглядит так:

in_addr ip_to_num;
erStat = inet_pton(AF_INET, “127.0.0.1”, &ip_to_num);
if (erStat <= 0) {
		cout << "Error in IP translation to special numeric format" << endl;
		return 1;
	}

sockaddr_in servInfo;
ZeroMemory(&servInfo, sizeof(servInfo));	
				
servInfo.sin_family = AF_INET;
servInfo.sin_addr = ip_to_num;	
servInfo.sin_port = htons(1234);

erStat = bind(ServSock, (sockaddr*)&servInfo, sizeof(servInfo));
if ( erStat != 0 ) {
		cout << "Error Socket binding to server info. Error # " << WSAGetLastError() << endl;
		closesocket(ServSock);
		WSACleanup();
		return 1;
	}
	else 
		cout << "Binding socket to Server info is OK" << endl;

Этап 4 (для сервера): «Прослушивание» привязанного порта для идентификации подключений

Серверная часть готова к прослушке подключающихся «Клиентов». Для того, чтобы реализовать данный этап, нужно вызвать функцию listen(), прототип которой:

int listen(SOCKET <«слушающий» сокет, который мы создавали на предыдущих этапах>, int <максимальное количество процессов, разрешенных к подключению>)

Второй аргумент: максимально возможное число подключений устанавливается через передачу параметр SOMAXCONN(рекомендуется). Если нужно установить ограничения на количество подключений – нужно указать SOMAXCONN_HINT(N), где N – кол-во подключений. Если будет подключаться больше пользователей, то они будут сброшены.

После вызова данной функции исполнение программы приостанавливается до тех пор, пока не будет соединения с Клиентом, либо пока не будет возвращена ошибка прослушивания порта. Код Этапа 4 для Сервера:

erStat = listen(ServSock, SOMAXCONN);

	if ( erStat != 0 ) {
		cout << "Can't start to listen to. Error # " << WSAGetLastError() << endl;
		closesocket(ServSock);
		WSACleanup();
		return 1;
	}
	else {
		cout << "Listening..." << endl;
	}

Этап 4 (для Клиента). Организация подключения к серверу

Код для Клиента до текущего этапа выглядит даже проще: необходимо исполнение Этапов 0, 1 и 2. Привязка сокета к конкретному процессу (bind()) не требуется, т.к. сокет будет привязан к серверному Адресу и Порту через вызов функции connect()(по сути аналог bind() для Клиента). Собственно, после создания и инициализации сокета на клиентской стороне, нужно вызвать указанную функциюconnect(). Её прототип:

int connect(SOCKET <инициализированный сокет>, sockaddr* <указатель на структуру, содержащую IP-адрес и Порт сервера>, int <размер структуры sockaddr>)

Функция возвращает 0 в случае успешного подключения и код ошибки в ином случае.

Процедура по добавлению данных в структуру sockaddr аналогична тому, как это делалось на Этапе 3 для Сервера при вызове функции bind(). Принципиально важный момент – в эту структуру для клиента должна заноситься информация о сервере, т.е. IPv4-адрес сервера и номер «слушающего» порта на сервере.

sockaddr_in servInfo;

ZeroMemory(&servInfo, sizeof(servInfo));

servInfo.sin_family = AF_INET;
servInfo.sin_addr = ip_to_num;	  // Server's IPv4 after inet_pton() function
servInfo.sin_port = htons(1234);

erStat = connect(ClientSock, (sockaddr*)&servInfo, sizeof(servInfo));
	
	if (erStat != 0) {
		cout << "Connection to Server is FAILED. Error # " << WSAGetLastError() << endl;
		closesocket(ClientSock);
		WSACleanup();
		return 1;
	}
	else 
		cout << "Connection established SUCCESSFULLY. Ready to send a message to Server" 
    << endl;

Этап 5 (только для Сервера). Подтверждение подключения

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

SOCKET accept(SOCKET <"слушающий" сокет на стороне Сервера>, sockaddr* <указатель на пустую структуру sockaddr, в которую будет записана информация по подключившемуся Клиенту>, int* <указатель на размер структуры типа sockaddr>)

 Функция accept() возвращает номер дескриптора, под которым зарегистрирован сокет в ОС. Если произошла ошибка, то возвращается значение INVALID_SOCKET.

Если подключение подтверждено, то вся информация по текущему соединению передаётся на новый сокет, который будет отвечать со стороны Сервера за конкретное соединение с конкретным Клиентом. Перед вызовом accept() нам надо создать пустую структуру типа sockaddr_in, куда запишутся данные подключившегося Клиента после вызова accept(). Пример кода:

sockaddr_in clientInfo; 

ZeroMemory(&clientInfo, sizeof(clientInfo));	

int clientInfo_size = sizeof(clientInfo);

SOCKET ClientConn = accept(ServSock, (sockaddr*)&clientInfo, &clientInfo_size);

if (ClientConn == INVALID_SOCKET) {
		cout << "Client detected, but can't connect to a client. Error # " << WSAGetLastError() << endl;
		closesocket(ServSock);
		closesocket(ClientConn);
		WSACleanup();
		return 1;
}
else 
		cout << "Connection to a client established successfully" << endl;

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

Этап 6: Передача данных между Клиентом и Сервером

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

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

Рассмотрим прототипы функций recv() и send():

int recv(SOCKET <сокет акцептованного соединения>, char[] <буфер для приёма информации с другой стороны>, int <размер буфера>, <флаги>)

int send(SOCKET <сокет акцептованного соединения>, char[] <буфер хранящий отсылаемую информацию>, int <размер буфера>, <флаги>)

Флаги в большинстве случаев игнорируются – передается значение 0.

Функции возвращают количество переданных/полученных по факту байт.

Как видно из прототипов, по своей структуре и параметрам эти функции совершенно одинаковые. Что важно знать:

  • и та, и другая функции не гарантируют целостности отправленной/полученной информации. Это значит, что при реализации прикладных задач по взаимодействию Клиента и Сервера с их использованием требуется принимать дополнительные меры для контроля того, что все посланные байты действительно посланы и, что еще более важно, получены в том же объеме на другой стороне

  • предельно внимательно надо относиться к параметру "размер буфера". Он должен в точности равняться реальному количеству передаваемых байт. Если он будет отличаться, то есть риск потери части информации или «замусориванию» отправляемой порции данных, что ведет к автоматической поломке данных в процессе отправки/приёма. И совсем замечательно будет, если размер буфера по итогу работы функции равен возвращаемому значению функции – размеру принятых/отправленных байт.

В качестве буфера рекомендую использовать не классические массивы в С-стиле, а стандартный класс С++ <vector> типа char, т.к. он показал себя как более надежный и гибкий механизм при передаче данных, в особенности при передаче текстовых строк, где важен терминальный символ и «чистота» передаваемого массива.

Сама по себе упаковка и отправка данных делается элементарным использованием функций чтения всей строки до нажатия кнопки Ввода - fgets() с последующим вызовом функции send(), а на другой стороне - приёмом информации через recv() и выводом буфера на экран через cout <<.

Процесс непрерывного перехода от send() к recv() и обратно реализуется через бесконечный цикл, из которого совершается выход по вводу особой комбинации клавиш. Пример блока кода для Серверной части:

vector <char> servBuff(BUFF_SIZE), clientBuff(BUFF_SIZE);	
short packet_size = 0;	

while (true) {
		packet_size = recv(ClientConn, servBuff.data(), servBuff.size(), 0);					
		cout << "Client's message: " << servBuff.data() << endl; 

		cout << "Your (host) message: ";
		fgets(clientBuff.data(), clientBuff.size(), stdin);

		// Check whether server would like to stop chatting 
		if (clientBuff[0] == 'x' && clientBuff[1] == 'x' && clientBuff[2] == 'x') {
			shutdown(ClientConn, SD_BOTH);
			closesocket(ServSock);
			closesocket(ClientConn);
			WSACleanup();
			return 0;
		}

		packet_size = send(ClientConn, clientBuff.data(), clientBuff.size(), 0);

		if (packet_size == SOCKET_ERROR) {
			cout << "Can't send message to Client. Error # " << WSAGetLastError() << endl;
			closesocket(ServSock);
			closesocket(ClientConn);
			WSACleanup();
			return 1;
		}

	}

Пришло время показать итоговый рабочий код для Сервера и Клиента. Чтобы не загромождать и так большой текст дополнительным кодом, даю ссылки на код на GitHub:

Исходный код для Сервера

Исходный код для Клиента

Несколько важных финальных замечаний:

  • В итоговом коде я не использую проверку на точное получение отосланной информации, т.к. при единичной (не циклической) отсылке небольшого пакета информации накладные расходы на проверку его получения и отправку ответа будут выше, чем выгоды от такой проверки. Иными словами – такие пакеты теряются редко, а проверять их целостность и факт доставки очень долго.

  • При тестировании примера также видно, что чат рабочий, но очень уж несовершенный. Наиболее проблемное место – невозможность отправить сообщение пока другая сторона не ответила на твоё предыдущее сообщение. Суть проблемы в том, что после отсылки сообщения сторона-отправитель вызывает функцию recv(), которая, как я писал выше, блокирует исполнение последующего кода, в том числе блокирует вызов прерываний для осуществления ввода. Это приводит к тому, что набирать сообщение и что-то отправлять невозможно до тех пор, пока процесс не получит ответ от другой стороны, и вызов функции recv() не будет завершен. Благо введенная информация с клавиатуры не будет потеряна, а, накапливаясь в системном буфере ввода/вывода, будет выведена на экран как только блокировка со стороны recv() будет снята. Таким образом, мы реализовали так называемый прямой полудуплексный канал связи. Сделать его полностью дуплексным в голой сокетной архитектуре достаточно нетривиальная задача, частично решаемая за счет создания нескольких параллельно работающих потоков или нитей (threads) исполнения. Один поток будет принимать информацию, а второй – отправлять.

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

Mr_Dezz

Комментарии (49)


  1. k-morozov
    08.10.2021 13:16
    +2

    Сокет в С++ – это структура данных (не класс) типа SOCKET.

    Тут все таки следует уточнить, что не в С++ сокет, а в конкретной библиотеке. С++ ничего не знает про сокеты. В том же boost::asio это класс.


    1. Mr_Dezz Автор
      08.10.2021 21:58

      Да, правильное замечание! Спасибо!


  1. DistortNeo
    08.10.2021 13:20
    +3

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


    Лучше начать изучение с высокоуровневых кроссплатформенных обёрток в C#, Java, Python. А уже потом, при необходимости, опускаться на уровень API операционной системы.


    Просто посмотрите, насколько кратко и выразительно выглядит код в Python:
    https://habr.com/ru/post/149077/


  1. kozlyuk
    08.10.2021 13:23
    +6

    Вроде бы аккуратное изложение, и вдруг такое:

    В итоговом коде я не использую проверку на точное получение отосланной информации, т.к. при единичной (не циклической) отсылке небольшого пакета информации накладные расходы на проверку его получения и отправку ответа будут выше, чем выгоды от такой проверки. Иными словами – такие пакеты теряются редко, а проверять их целостность и факт доставки очень долго.

    Во-первых, потери пакетов здесь ни при чем. Их целостность и гарантию доставки обеспечивает ОС, мы уже выбрали это, когда создали потоковый сокет. Во-вторых, не обеспечивать фрейминг поверх потокового протокола --- это грубая ошибка, не надо такому учить. Не совсем про сокеты случай, но неделю назад у меня студент при передаче десятка байт(!) через локальный(!) named pipe словил проблемы из-за этого. Наконец, накладные расходы на проверку результата --- это ни о чем по сравнению с ценой системного вызова.

    Чтобы избежать этих сложностей, проще начать с UDP и дейтаграммных сокетов, чтобы освоить API, а потом уже думать о буферах и протоколах.


    1. Mr_Dezz Автор
      09.10.2021 13:36
      -1

      Спасибо за вдумчивый комментарий. Отвечу Вам так:

      "...целостность и гарантию доставки обеспечивает ОС..."

      Я бы даже по-другому сказал - обеспечивает не только и не сколько ОС, сколько сам протокол TCP/IP, который как раз для этого и был придуман, улучшив и расширив функционал UDP. Давайте будем откровенны: полагаться на ОС и транспорт в таком щепетильном вопросе - дело опасное. Тем более, что в критических случаях, когда потеря даже одного байта в процессе передачи, приводит к полной нефункциональности пересылаемого сообщения (например, передача по сети файлов *.exe), проверку целостности пакетов делать необходимо. И я честно написал, что не сделал этого, т.к. риски в конкретно этом приложении тут минимальны, но покажу как это сделать в сл. статьях, где буду развивать тему. Для первого знакомства с темой мне кажется представленной информации достаточно.


      1. kozlyuk
        09.10.2021 14:27
        +5

        Извините, но вы смешиваете гарантии TCP и прикладной протокол.

        TCP гарантирует, что все байты, которые одна сторона отдает в send(), другая сторона получит из recv(), причем в том же порядке. В случае порчи, потери, переупорядочивания пакетов при передаче данные будут оправлены повторно, причем программа об этом не узнает, это все сделает TCP-стек ОС. Если ничего не помогает, соединение отвалится. Именно так ОС гарантирует доставку и в этом смысле мы на нее полагаемся.

        TCP --- потоковый протокол: приложение работает с потоком байт, а не с пакетами. Если клиент сделал send() на 10 байт, на сервере вызов recv() может выдать 10 байт, а может выдать 8 байт и на следующий вызов --- 2 байта. Если приложению требуется из этого потока выделять сообщения, нужно включить в прикладной протокол средства для этого (framing): длину перед сообщением, метку конца и т. п. Код обязан быть готов к тому, что recv() может вернуть меньше, чем запрошено, а send() может взять меньше, чем указано; при необходимости вызывать их несколько раз, разбирать фрейминг. Не делать этого я назвал грубой ошибкой. Это баг. Программа может сработать неправильно не потому, что в сети что-то случилось, а потому, что некорректно пользуется функциями. Учебный характер программы усугубляет это.

        В протокол прикладного уровня может быть добавлена своя проверка целостности уже не пакетов, а сообщений. Спору нет, просто об этом еще и речи не шло.

        P.S. TCP не "улучшает и расширяет" UDP, это протоколы для разных задач. TCP/IP --- это стек протоколов, UDP тоже работает поверх IP.


        1. Mr_Dezz Автор
          09.10.2021 20:01

          То, что Вы пояснили касательно фрейминга на уровне пользовательского протокола я как раз и хочу реализовать в следующих (одной из) статей, которые заявил в этой. Вы, безусловно, правы с методологической точки зрения - проверка целостности буфера на уровне пользовательского протокола - вещь архи-важная. Я и сам об этом пишу в общем виде в месте, где упоминаю критичность проверки всего и вся при работе с сетью. Однако сам же далее и пишу в приведенной Вами же цитате, что намеренно не делаю такой проверки, т.к. считаю, что риски потери какой-либо информации при таком алгоритме очень малы. Намеренный баг - это уже вроде как не баг, а фича ))

          Ок, Вы считаете это серьезным багом - я покажу как такие баги исправляются в сл. статьях.


          1. kozlyuk
            09.10.2021 21:08
            +5

            Фрейминг нужен для выделения сообщений из потока, только после этого можно говорить о проверке их целостности, например, добавляя к ним контрольную сумму. Если мы говорим о сообщениях (пишем-то чат), работать с TCP без фрейминга некорректно. Конкретно для вашей программы: клиент посылает "xxx", а на сервере recv() возвращает сначала "x", потом "xx" --- и все, не работает команда выхода. Или клиент посылает сначала "tax", потом "xxl" (двумя send), а на сервере recv() вернет "ta", потом "xxxl" --- и это будет воспринято как команда выхода, которую не посылали. В сети ничего не терялось и не искажалось, просто TCP-стек имеет право так отработать, а программа это не учитывает. Риск этого никак не зависит от алгоритма.


  1. leahch
    08.10.2021 13:33
    +6

    Ух, какой сильный замес из posix и win32 API.

    Как минимум в коде сервера следующие боольшие ошибки:

    • смешивание одного и второго, уж определитесь, либо микрософт, либо posix

    • зачем закрывать серверный сокет вместе с клиентским?

    • при акцепте нужно бы породить или тред или встать на select

    • за fgets должна быть отдельная камера пыток

    В общем, для новичков рекомендую классические примеры send-recv хотя бы отсюда https://docs.microsoft.com/en-us/windows/win32/winsock/windows-sockets-start-page-2

    Ну или отсюда - https://rsdn.org/article/unix/sockets.xml


    1. kozlyuk
      08.10.2021 13:48
      +3

      смешивание одного и второго, уж определитесь, либо микрософт, либо posix

      Зачем, если можно учиться писать универсальный код, кроме пары мест?

      при акцепте нужно бы породить или тред или встать на select

      Автор же пишет, что оставил это на будущее.

      за fgets должна быть отдельная камера пыток

      Спутали с gets()? Хотя, конечно, при живом-то std::getline() просто не нужно.


    1. Mr_Dezz Автор
      09.10.2021 13:53

      Юзер @kozlyuk ответил за меня на все Ваши комментарии, за что ему спасибо :) От себя добавлю следующее:

      1) "сильный замес из posix и win32 API" - никто и не говорил, что мы пишем программу исключительно с использованием WinAPI. Такой подход был бы крайне искусственной конструкцией, если не сказать более грубо. Задача была иной, а именно: показать каким образом написать и запустить простейшее клиент-сервер приложение под Windows и человеческим языком объяснить, что вообще за функции для этого используются с их детальным описанием.

      2) "зачем закрывать серверный сокет вместе с клиентским?" - вопрос не в полной мере понимаю. Речь о месте кода в серверной части или в частях и клиента, и сервера?

      3) "за fgets должна быть отдельная камера пыток" - опять же, не вполне понимаю, чем Вам не нравится данная функция? Как совершенно точно заметил @kozlyuk, реальная проблема возникает с другой функцией - gets(), которая дырявая и глубоко устаревшая. Функция же fgets() лишена минусов gets() (главная проблема этой функции - возможное переполнение буфера ввода и крах программы), при этом работает быстро и эффективно, решая нужную задачу для написанной в этой статье программы. Возможно Вы имели ввиду, что функция fgets() - это наследие С, однако, повторюсь, здесь она прекрасно работает и не усложняет при этом чтение программы. В целом наверное можно согласиться с тем, что "прогрессивнее" использовать более современную getline(), однако и мой вариант - это явно не ошибка.

      Вообще говоря, когда Вы пишете фразу "грубая ошибка", то это вызывает непонимание. Грубая ошибка - это такой род ошибок, которые приводят либо к полной нефункциональности программы (неприменимо, т.к. программа работает), либо к ситуациям критических отказов и уязвимостей программы. Ни то, ни другое тут не применимо. Поэтому давайте все будем аккуратнее в выражениях.


      1. Tujh
        09.10.2021 16:27

         никто и не говорил, что мы пишем программу исключительно с использованием WinAPI

        вы сами написали это в первом же предложении

        Это приложение будет использовать Win32API


        1. Mr_Dezz Автор
          10.10.2021 14:46

          Из этого никак не следует, что "мы будем использовать только Win32API". Подразумевалось, что мы будем использовать Win32API где это нужно, чтобы приложение работало под Windows для решения нашей конкретной задачи.


          1. Tujh
            11.10.2021 11:00

            Хорошо, тогда вопрос, что же было использовано из WinAPI кроме минимально требуемых WSAStartup/WSACleanup, о которых написано в любой статье по сокетам?

            Тот же полный код простых клиента и сервера расположен в официальной документации Microsoft.

            ZeroMemory спокойно меняется на вызов memset.


  1. Tujh
    08.10.2021 14:04

    подавляющее большинство написано под *nix-системы...Это приложение будет использовать Win32API

    В итоге весь сетевой код таки на posix и ни каких WSASocket() вместо socket(), WSASend() вместо send() и так далее я не увидел.

    Кстати жаль, потому что при таком подходе кроме select() ни чего продемонстрировать невозможно, а ведь WinAPI предоставляет много возможностей, начиная от простейших WSACreateEvent() и заканчивая IOCP.

    фрагмент кода в примере от Microsoft по ссылке выше:
    service.sin_addr.s_addr = inet_addr("127.0.0.1");

    вообще не заработает

    Заработает, для этого в WinSock2.h определён специальный макрос

    #define s_addr  S_un.S_addr


    1. Mr_Dezz Автор
      09.10.2021 14:41

      @Tujh, спасибо за комментарий!

      1) "В итоге весь сетевой код таки на posix "

      Универсальность никогда не бывает лишней. Если серьезно, то я расширю именно WinAPI применение в следующих статьях, которые анонсировал в этой. Другое дело - уверенно утверждать, что такое использование существенно расширит функционал программы я бы не стал. А вот в универсальности точно будут потери. Более развернуто отвечал на подобный комментарий выше.

      2) "Заработает, для этого в WinSock2.h определён специальный макрос #define s_addr S_un.S_addr"

      Да, Вы правы - данный код всё-таки заработает (не считая того, что вызов функции inet_addr() недопустим в современных компиляторах, но этот момент можно пролечить спец. директивой). Странным образом при тесте этой строчки перед написанием статьи она вызывала ошибку компиляции. Возможно проблема была в моей системе. В целом же мне кажется недопустимым, что на сайте с документацией для строго типизированного языка программирования используется присваивание разных по типу сущностей в явном виде через подобные "скрытые" в библиотеке макросы. Приведенная конструкция с сайта Microsoft, это очень плохой стиль программирования, который скорее ухудшает читаемость кода, нежели помогает в нём разобраться.


      1. Tujh
        09.10.2021 16:25
        +1

         Другое дело - уверенно утверждать, что такое использование существенно расширит функционал программы я бы не стал

        Я уже привёл пример, если ограничиваться в примерах только вызовом select(), то да, но чуть более серьёзное применении уже потребует или универсальности или функционала. Но и даже в таком варианте универсальности не получится. По умолчанию в WinAPI вызов select() ограничен 64 сокетами

        The default value of FD_SETSIZE is 64, which can be modified by defining FD_SETSIZE to another value before including Winsock2.h

        https://docs.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-select

        В Linux этот лимит установлен в 1024 и не может быть изменён в принципе

        WARNING: select() can monitor only file descriptors numbers that are less than FD_SETSIZE (1024)—an unreasonably low limit for many modern applications—and this limitation will not change. All modern applications should instead use poll or epoll, which do not suffer this limitation.

        https://man7.org/linux/man-pages/man2/select.2.htm

        вызов функции inet_addr() недопустим в современных компиляторах

        К компилятору это не имеет ни какое отношение.

        Приведенная конструкция с сайта Microsoft, это очень плохой стиль программирования, который скорее ухудшает читаемость кода, нежели помогает в нём разобраться

        А вот сейчас сами себе противоречите.

        1. Это присваивание абсолютно легально с точки зрения posix реализации, которую вы называете - универсальной. То есть пример должен быть компилируемым в любом случае.

        2. Так как реализация Microsoft не полностью совместима с posix им приходится делать код компилируемым с применением дополнительных средств, в данном случае - макроса, что может быть вводит в заблуждение порой, но совершенно нормально для переносимого кода.

        3. Если следовать вашей логике, что "недопустимо так писать в официальной документации", то и INVALID_SOCKET является недопустимым. В posix только любое отрицательное значение сокета (тип данных - int) является недопустимым, а в WinAPI это специальный тип данных - SOCKET и значение INVALID_SOCKET == 0 противоречит идее универсальности, так как ноль - валидное значение для posix (не отрицательное).

        Небольшой совет, вы стараетесь обсуждать в статье вещи, в которых не разобрались, в этом и корень проблемы. Или "копайте глубже" или не будьте таким категоричным :)


  1. brn
    08.10.2021 14:18

    Если сокет равен INVALID_SOCKET, то я бы настоятельно не рекомендовал его закрывать. Это некое служебное число за пределами массива сокетов. Как отработает его закрытие в разных реализациях я предпочитаю не думать.

    https://www.opennet.ru/man.shtml?topic=socket&category=2&russian=0


    1. Mr_Dezz Автор
      09.10.2021 14:42

      @brn, спасибо за комментарий. Принимается!


  1. redneko
    08.10.2021 16:51

    Еще одно пожелание как к программистам, так и к создателям ОС при работе с мультикастами: выдерживайте, пожалуйста, паузы при многочисленных подписках и отписках от групп, хотя бы по 10-20мс. Иначе коммутаторы сходят с ума. Если прибить процесс (или он сам упал по какой-то причине), который работает со многими группами - сама Windows штормом шлёт кучу IGMP Leave


  1. da-nie
    09.10.2021 08:13

    Другими словами все эти примеры просто не будут работать под Windows.


    Почему вы так решили? Unix-системы используют posix-реализацию и она работает в Windows (с небольшими изменениями- например, в select в windows первый параметр 0, а в Unix максимальный номер сокета). Впрочем, я догадываюсь, что вас смущает событийная модель в windows (только вы её не используете :) )? Но вы можете просто создать отдельный поток и в нём реализовать все эти posix-сокеты. А если вас смущает их блокировка, то их всегда можно сделать неблокирующими. А так — честно, не увидел, что там у вас такого в коде специфичного для Windows? Инициализация? Она примитивна и описана очень много где (да хотя бы в «С++ глазами хаккера»). А событийную модель вы не используете. Строго говоря, вы накатали простейшую обработку сокетов, но ведь таких обучалок в инете навалом. Зачем же нужна ещё одна такого же уровня? Я не знаю. Честно, со стороны выглядит словно вы прочитав вступление к книжке по сокетам бросились рассказывать миру об этом откровении. Я так видел, как понявший суть *.h и *.c файлов помчался на радиокоте нести свет в массы, рассказывая, как их применять (а никто и не в курсе :) ). И это удивляет, словно мы докатились до ситуации, когда вещи, понятные лет 20 назад, теперь стали откровением. Удивительно…
    А вот если вы с IEE1394 и связью с видеокамерами разберётесь… Вот это будет реально интересной вещью. Про такое статей и книг и не найти практически. Вот тут статья была бы нужна, несмотря на то, что шина устарела.

    Обратите ещё внимание на «сырые сокеты» (RAW). Они позволяют отличненько лабать снифферы. :)

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


    1. Mr_Dezz Автор
      09.10.2021 15:05
      +1

      @da-nie, спасибо за комментарий.

      Он, правда, немного удивляет. Вот пишешь в начале статьи дисклеймер: "Она для начинающих", и тут прилетает такой вот комментарий )) Вы наверное забыли те времена, когда были студентом и имели ограниченный временной ресурс на поиск нужной неперегруженной информации. У этой статьи была очень чёткая цель - ввести в тему сокетов и показать работающую реализацию под Windows. Человеку, который ищет в интернете, как программировать сокеты, выпадает несколько тысяч ссылок с кодом в чистой posix-нотации. У него ничего не работает под Винду, он судорожно начинает искать информацию на сайте с документацией от Microsoft, но и там не всё гладко. Наконец, ему никто не расскажет, что отдельные описания есть в спец. литературе вроде "С++ глазами хакера" (она ещё есть в продаже-то?). Мне всегда казалось, что Хабр - это то место, где уютно сосуществуют люди, которые только начали свой путь в IT, и опытные специалисты. И это сосуществование должно быть мирным. Давайте так и будет.

      В следующих статьях я разовью тему сокетов для Windows, и надеюсь Вас не разочарую :))


      1. DistortNeo
        09.10.2021 15:48

        Собственно, поэтому у меня и был совет: вместо C++ использовать другой язык.


      1. da-nie
        09.10.2021 16:40

        Вот пишешь в начале статьи дисклеймер: «Она для начинающих»


        Потому что таких статей валом. Это как статья «помигаем светодиодом на ардуино». При этом видно, что автор только недавно открыл для себя сокеты. :)

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


        Я был студентом в 2000-2006 годах (и моя специальность с IT не связана). У меня инет был через модем с нефиговой почасовой оплатой на 56600 бод и поиск много времени не занимал (я книжки покупал — в них было много чего полезного). :) Времени же у меня вполне хватало на написание всяких 3D типа DooM, не говоря уж о всём остальном. :)

        Человеку, который ищет в интернете, как программировать сокеты, выпадает несколько тысяч ссылок с кодом в чистой posix-нотации.


        Вверху ссылки.

        Вот, если конкретнее.

        Просто надо указывать про Windows и статьи будут про неё.

        Мне всегда казалось, что Хабр — это то место, где уютно сосуществуют люди, которые только начали свой путь в IT, и опытные специалисты.


        А по-моему, это рекламная платформа разных контор. :) Вон их рейтинг, справа в верхнем углу. :) Спрашивают и рассказывают же на киберфоруме и подобных. Там и формат ответов удобнее.


  1. Mabu
    10.10.2021 11:26

    C:/Windows/System32

    Что у вас с разделителями путей? Должно быть \.

    MAKEWORD(2,2)

    Это не функция, это макрокоманда в заголовочных файлах, вы не найдёте её в виндовых DLL.

    Но как же явно указать адрес и порт для привязки сокета?

    Чтобы не возиться с приведениями к нужным типам, подсчётам 14 байт, каким‐то htons, используйте функцию GetAddrInfoW (или неюникодную оболочку над ней getaddrinfo), которая сама заполнит sockaddr, sin_addr и прочие sockaddr_in. К тому же GetAddrInfoW можно использовать и для заполнения данных для функции connect.

    После вызова данной функции (listen) исполнение программы приостанавливается до тех пор, пока не будет соединения с Клиентом, либо пока не будет возвращена ошибка прослушивания порта.

    Нет. listen не блокирует поток, иначе вы не доберётесь до accept.

    Привязка сокета к конкретному процессу (bind) не требуется, т.к. сокет будет привязан к серверному Адресу и Порту через вызов функции connect()

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

    Вообще функции recv и send подходят только для учебных программ вроде телнета. Ни для GUI, ни для больших приложений это не походит, потому что они блокируют поток. Если у вас что‐то посложнее laba06.cpp, то также не мучайте select. Сразу используйте асинхронные сокеты и перекрывающиеся операции ввода‐вывода (OVERLAPPED), WSAReceive и WSASend, для сервера — порт завершения ввода‐вывода.


    1. da-nie
      10.10.2021 21:58

      Вообще функции recv и send подходят только для учебных программ вроде телнета. Ни для GUI, ни для больших приложений это не походит, потому что они блокируют поток.


      Так ведь
      unsigned long nb=1;
      ioctlsocket(socket_server,FIONBIO,&nb);

      И больше никаких блокировок.


      1. Mabu
        11.10.2021 11:38
        +4

        Неблокирующий режим — это ещё не асинхронный режим.

        В неблокирующем режиме вам придётся проверять данные либо по таймеру, либо делая Sleep() между вызовами. Это хуже, чем создание отдельного потока. 1. Если данных нет, программа всё равно будет нагружать процессор проверками, разряжать батарею. И чем меньше период — тем выше нагрузка. 2. Данные обрабатываются не сразу при поступлении, программа не сможет отвечать на данные быстро. Например, это важно для торговых ботов.

        Поэтому лучше сразу использовать асинхронные сокеты и перекрывающиеся операции.


        1. kozlyuk
          11.10.2021 15:38

          Зачем таймер? Поток же засыпает на вызове мультиплексора или функции ожидания.


          1. Mabu
            11.10.2021 16:06
            +2

            И опять пришли к блокировке потока и зависанию GUI?

            Я пока вижу только два способа не блокировать GUI:

            • асинхронный сокет на оконных сообщениях через WSAAsyncSelect;

            • OVERLAPPED‐операции ввода‐вывода + MsgWaitForMultipleObjectsEx.

            Есть ли ещё какие‐нибудь способы без дополнительных потоков и таймеров не блокировать GUI?


            1. da-nie
              11.10.2021 17:57

              Приличные люди всю обработку выносят в отдельные потоки. :)


        1. da-nie
          11.10.2021 17:59

          В неблокирующем режиме вам придётся проверять данные либо по таймеру, либо делая Sleep() между вызовами


          Вас спасёт select/poll/epool.

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


          См. выше. Сможет и будет отлично работать. Я так рабочие столы и потоковое видео с камер передавал в одном проекте. К тому же в Unix нет никаких WSA-функций.


          1. Tujh
            11.10.2021 18:02

            К тому же в Unix нет никаких WSA-функций.

            Вот только автор пишет

            Это приложение будет использовать Win32API


            1. da-nie
              11.10.2021 18:18

              Это приложение будет использовать Win32API


              «К тому же в Unix нет никаких WSA-функций.» — это как бы намёк, что другие ОС обходятся и без WSA. :)


          1. DistortNeo
            11.10.2021 18:07

            Вас спасёт select/poll/epool.

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


            См. выше. Сможет и будет отлично работать.

            Вы не сможете без танцев с бубнами мультиплексировать операции по сокетам с событиями GUI в одном потоке. Так что да, все операции с сокетами — в отдельный поток + веселуха с межпотоковой синхронизацией.


            1. da-nie
              11.10.2021 18:24

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


              Вот именно, что системонезависимый. В Windows это тоже будет работать (в отличие от WSA, которые ТОЛЬКО в Windows и есть).

              Вы не сможете без танцев с бубнами мультиплексировать операции по сокетам с событиями GUI в одном потоке.


              С чего бы это вдруг? ;)

              + веселуха с межпотоковой синхронизацией.


              Это вообще, обычно, никаких проблем не вызывает.


              1. DistortNeo
                11.10.2021 18:39
                +1

                Вот именно, что системонезависимый. В Windows это тоже будет работать (в отличие от WSA, которые ТОЛЬКО в Windows и есть).

                С каких пор в Windows завезли epoll, во FreeBSD — IOCP, а в Linux — kqueue?
                Или предлагаете по-старинке через select работать?


                С чего бы это вдруг? ;)

                Вы вообще представляете, как выглядит очередь сообщений в GUI-приложений и как к ней прикрутить сокеты?


                Это вообще, обычно, никаких проблем не вызывает.

                Пользователь в GUI-приложении нажал на кнопку отмены, нужно остановить операцию по сокету в другом потоке, висящим на select/epoll_wait. Что предложите?


                1. da-nie
                  11.10.2021 19:32

                  Или предлагаете по-старинке через select работать?


                  Почему бы и нет? Вам же нужна независимость от ОС?
                  А не через select (и не через WSA) вы покроете почти все UNIX-системы, но не покроете Windows. С WSA же вы получите всё ровно наоборот — только Windows и всё. Впрочем, тот же Pool есть как WSAPool и вы можете просто сделать макрос замены. При этом код менять не потребуется.

                  Вы вообще представляете, как выглядит очередь сообщений в GUI-приложений и как к ней прикрутить сокеты?


                  Единственное, чего я не представляю, так это выдуманные на пустом месте проблемы. :)
                  Что вам помешает передать требуемые работы с сокетами события потоку? Прямо из GUI, да. А потоку их можно хоть в очередь ставить, хоть сразу исполнять — это уже его дело.

                  Пользователь в GUI-приложении нажал на кнопку отмены, нужно остановить операцию по сокету в другом потоке, висящим на select/epoll_wait. Что предложите?


                  Вы в потоке крутитесь в цикле while(true) с внутренним select:
                  timeval timeout;
                  while(true)
                  {
                    ...
                    timeout.tv_sec=0;
                    timeout.tv_usec=timeout_us;
                    long ret=select(0,&Readen,0,&Exeption,&timeout);
                  
                    ...
                  
                  }
                  

                  Что помешает вам проверить сразу после select какое-либо событие (его можете хоть как флаг сделать, защитив мютексом или критической секцией)?

                  Кстати, а вы не смотрели, с какой частотой Windows переключает потоки? ;) Думаете, очередь сообщений (вместе с GUI) работает быстрее? Ну-ну.


                  1. DistortNeo
                    11.10.2021 19:54

                    Почему бы и нет? Вам же нужна независимость от ОС?

                    Как уже написали в соседнем комментарии, если номер дескриптора больше, чем FD_SETSIZE (который захардкожен в 1024), то вы не сможете поместить его в select. Хорошая переносимость, правда?


                    Вы в потоке крутитесь в цикле while(true) с внутренним select

                    А select-у как сообщим, что надо прерваться? Или будем ждать таймаута, что само по себе является плохой практикой?


                    Кстати, а вы не смотрели, с какой частотой Windows переключает потоки? ;) Думаете, очередь сообщений (вместе с GUI) работает быстрее? Ну-ну.

                    Как-то раньше игрался: простой пинг-понг событиями между потоками показал что-то вроде 20-30к переключений в секунду. А вот сколько сообщений можно пропустить через очередь сообщений, я не замерял.


                    Ну а так-то да, логично всю обработку делать в отдельном потоке и не насиловать GUI-поток.


                    1. da-nie
                      11.10.2021 20:10

                      Как уже написали в соседнем комментарии, если номер дескриптора больше, чем FD_SETSIZE (который захардкожен в 1024), то вы не сможете поместить его в select. Хорошая переносимость, правда?


                      Вы с этим сталкивались? ;) Нет, серьёзно, вы когда-нибудь ожидали 1024 сокета? Фантазировать, конечно, не запретишь, но всё же, как вам тогда 65535 портов хватает? Мне вот раз в 10 больше надо. :) Ну а почему бы и нет? ;)

                      А select-у как сообщим, что надо прерваться? Или будем ждать таймаута, что само по себе является плохой практикой?


                      Кто это сказал, что это плохая практика?

                      Как-то раньше игрался: простой пинг-понг событиями между потоками показал что-то вроде 20-30к переключений в секунду.


                      Не совсем.


                      1. DistortNeo
                        11.10.2021 21:37

                        Вы с этим сталкивались?

                        Нет, потому что я использую epoll и IOCP.


                        Кто это сказал, что это плохая практика?

                        Это плохая практика, потому что если таймаут будет большой, тогда отзывчивость приложения будет низкой. А если маленький, тогда поток будет постоянно просыпаться по таймауту select, что не есть хорошо.


                        Не совсем.

                        А причём тут таймер? ОС не ждёт следующего кванта времени, чтобы стартовать выполнение потока.


                      1. da-nie
                        12.10.2021 17:35

                        Нет, потому что я использую epoll и IOCP.


                        Речь о количестве сокетов, а не о ограничениях. Если у вас набирается столько сокетов на один поток, то пора пересмотреть архитектуру приложения.

                        А если маленький, тогда поток будет постоянно просыпаться по таймауту select, что не есть хорошо.


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

                        А причём тут таймер? ОС не ждёт следующего кванта времени, чтобы стартовать выполнение потока.


                        Разбор очереди сообщений привязан к этому таймеру. Если у вас один поток работает, то проблемы не будет, но если несколько, то вот с частотой этого таймера будет происходить их вытеснение. Это очень хорошо видно, если требуется в потоке опрашивать на шине какое-либо устройство чтением из порта с ожиданием готовности (если прерывание не завезли — так бывает). Там будут прелестные лаги во временной диаграмме.


                      1. DistortNeo
                        12.10.2021 18:00

                        Речь о количестве сокетов, а не о ограничениях. Если у вас набирается столько сокетов на один поток, то пора пересмотреть архитектуру приложения.

                        Таки не поток, а процесс. Тот же nginx на пару порядков больше соединений может держать, кстати.


                        У вас в любом случае потоки будут потреблять процессор.

                        Нет. В нормальном случае поток будет спать и просыпаться только при выполнении условия просыпания по дескриптору.


                        Разбор очереди сообщений привязан к этому таймеру.

                        Ну если у вас всё на sleep-ах сделано, тогда да.


                      1. da-nie
                        12.10.2021 18:06

                        Таки не поток, а процесс. Тот же nginx на пару порядков больше соединений может держать, кстати.


                        Ну может и может, вы лично 1024 использовали?

                        Нет. В нормальном случае поток будет спать и просыпаться только при выполнении условия просыпания по дескриптору.


                        Вы невнимательно читаете. Процессор в любом случае будет кем-то потребляться, хоть IDLE, но будет. И беречь в этом случае поток обработки смысла нет — ничего не выиграете.

                        Ну если у вас всё на sleep-ах сделано, тогда да.


                        Оно и без них будет идти неравномерно. То бежать, то приостанавливаться.


                      1. DistortNeo
                        12.10.2021 19:09

                        Процессор в любом случае будет кем-то потребляться, хоть IDLE, но будет.

                        Энергосбережение? Нет, не слышали.


                      1. da-nie
                        12.10.2021 19:13

                        Энергосбережение? Нет, не слышали.


                        Да ладно! :O
                        То есть, когда сайт сбербанка грузится хрен знает сколько или те же avito, youtube и ozon не стесняются кушать память и процессор в моём firefox, то это нормально, тут ни о каком энергосбережении речи как-то не заходит. :)
                        Да и ОС с остальным ПО всё жиреют и жиреют. Процессор греется от натуги, вытаскивая все эти слои абстракций и прослоек. Но «это другое». :)


                      1. DistortNeo
                        12.10.2021 20:01

                        Да, и это просто отвратительно. У меня есть и другие примеры, когда криво написанный сетевой софт отжирает в фоне процессорное время, даже если он не используется.


                        Например, в nxserver сделан epoll_wait с таймаутом 100 мс. Как следствие, он жрёт процессор. За 5 суток сожрал 7 минут процессорного времени. Мелочь, а неприятно.


                        С x2goserver ещё хуже: скрипт на perl, который раз в 2 секунды запускает несколько других процессов для очистки зависших сессий. Жрёт ещё больше. Частично пофиксил проблему, пропатчив скрипт и увеличив интервал до 120 секунд.


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


                      1. da-nie
                        12.10.2021 20:26

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


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


  1. morgot
    10.10.2021 21:52

    Есть хорошая книга - Джонс Энтони, Оланд Джим. "Программирование в сетях Microsoft Windows". Единственная по Winsock 2 , хоть ей лет 20, но в сетевом программировании мало что меняется. Еще есть несколько хороших по первой версии винсок (и где-то временам Windows 95).


  1. unC0Rr
    12.10.2021 17:48

    Неплохо бы раскрыть также и тему IPv6.