Приветствую Вас, уважаемый читатель! Предоставляю Вашему суровому вниманию свою вторую статью, «Приёмы сетевого программирования: Сокетный движок». Надеюсь, чтение этой статьи будет для Вас не только полезным, но и приятным.

Disclaimer

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

Для понимания технических деталей этой статьи, потребуются:

1. Базовый опыт в программировании на классическом ANSI C;
2. Базовые знания стандрата posix.1b, в частности, системных вызовов BSD Sockets и файловых API UNIX и/или знания WinSock2 API под Windows (в идеале, и то, и другое);
3. Небольшой опыт в программировании сетевых задач или в анализе кода сетевых программ.

Надеюсь, пользователи UNIX не брезгуют программой man, а пользователи Windows всегда смогут найти руководство по WinSock2 в хелпах тех IDE, в которых они привыкли работать. Кроме того, в Интернете более чем достаточно учебников и по [1], по [2] и по [3].

Сокетный движок: что это такое?

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

Этот метод сугубо противоположен вызову отдельного процесса или потока выполнения для обработки каждого конкретного сокета. И именно в этом заключается основной его плюс (многократное понижение требований к ресурсам) и минус (многократное повышение требований к структурированию алгоритма программы).

В качестве примера реализации самого простого сокетного движка я могу предложить
исходный текст программы Grinder v1.1
(49Kb), конкретно в файлах <sockeng.cpp> и <sockeng.h>. Здесь Вы можете взять бинарник этой программы под
WinSock (40 Kb).

Блокирующие вызовы сокетных API

Прежде чем заняться моделированием сокетного движка, следует чётко уяснить разницу между блокирующими и неблокирующими вызовами сокетных API. Возьмём самый простой пример, в котором мы коннектимся к удалённому http-серверу и получаем список поддерживаемых им команд (пример для WinSock2):

#include <winsock2.h>

SOCKET s;
int rc;
struct sockaddr_in dst;
char buf[8192];

memset(buf,0,8129);
memset(&dst,0,sizeof(struct sockaddr_in));
dst.sin_addr.s_addr = inet_addr(«XXX.XXX.XXX.XXX»);
dst.sin_port = htons(80);
dst.sin_family = AF_INET;
s = socket(AF_INET,SOCK_STREAM,0);
rc = connect(s,(struct sockaddr*)&dst,sizeof(dst));
if(rc == SOCKET_ERROR) {
printf(«Error occured while connecting!\n»);
return 1;
}
rc = send(s,»OPTIONS / HTTP/1.1\x0D\x0A\x0D\x0A»,22,0);
if(rc<0) {
printf(«Error occured while sending data!\n»);
return 1;
} else printf(«[%u] bytes sent!\n»,rc);
rc = recv(s,buf,8192,0);
if(rc<0) {
printf(«Error occured while receiving data!\n»);
return 1;
} else printf(«[%u] bytes received!\n%s\n»,rc,buf);

Сценарий задачи описан линейно: мы просим у ядра сокет и коннектимся по структуре dst. В случае успеха, мы отправляем данные на сервер и, опять же, в случае успеха, принимаем данные с сервера.

Вызовы connect() и recv() в данном примере являются блокирующими: они блокируют ход выполнения вызывающей их функции на время своего исполнения, которое может длиться неопределённое время. Вызов send() блокирующим не является: и в UNIX, и в Windows сокетный дескриптор представляет собой FIFO-структуру и send() лишь добавляет в очередь сокета необходимое количество данных, сразу же возвращая управление.

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

Неблокирующие вызовы сокетных API

Когда мы открываем сокет в UNIX, ядро устанавливает ему дефолтный атрибут доступа: O_RDWR, «чтение/запись». С помощью файлого API fcntl() мы можем установить для открытого сокета неблокирующий режим работы, сохранив бит O_RDWR:


s = socket(AF_INET,SOCK_STREAM,0);
if(s<0) {
printf(«Error allocating a socket!\n»);
return 1;
)
rc = fcntl(s,F_SETFL,O_NONBLOCK + (rc_old = fcntl(s,F_GETFL,0)));


Следует отметить, что константа O_NONBLOCK в разных UNIX’ах может называться по-разному. Мне встречались такие: O_NDELAY, FNONBIO. Если Ваша система не знает O_NONBLOCK, загляните к себе в /usr/include/sys/fcntl.h.

Таким образом, любая операция чтения или записи по дескриптору ‘s’ не будет блокировать ход выполнения вызывающей функции и будет моментально возвращать управление вызывающей функции. Для того, чтобы закрыть сокет, нам необходимо вернуть дескриптору ‘s’ его первоначальное состояние:


fcntl(s,F_SETFL,rc_old);
close(s);

WinSock (и первый, и второй), насколько это возможно в рамках ОС Windows, копирует названия и синтаксис API BSD Sockets. Принципиальное различие заключается в том, что API BSD Sockets «воспринимают» сокеты как файловые дескрипторы, к которым можно применять файловые API UNIX. В WinSock для сокетов выделен специальный тип данных, который обрабатывается особыми API. Открываем сокет и переводим его в неблокирующий режим:

on = 1;
WSADATA wsaData;

if (WSAStartup(MAKEWORD(2,1),&wsaData) != 0) {
printf(«Bad WinSock!\n»);
return 1;
}

s = socket(AF_INET,SOCK_STREAM,0);
if(s == SOCKET_ERROR) {
printf(«Error allocating a socket!\n»);
return 1;
}
ioctlsocket(s,FIONBIO,&on);

Вводим сокет в блокирующий режим и закрываем его:

off = 0;

ioctlsocket(s,FIONBIO,&off);
closesocket(s);
WSACleanup();

Чтение из неблокирующего сокета и запись в него

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

В стандарте posix.1 для UNIX, такой инструмент реализован в виде файлового API select() и макросов FD_*. WinSock2 полностью копирует вызов select() и макросы FD_*, за исключением того, что select() в WinSock2 фактически игнорирует значение первого аргумента, сообщающего функции select() количество сокетов, которые нужно обработать. Для обратной совместимости, всё же, имеет смысл всегда сообщать select()’у значение FD_SETSIZE для сохранения обратной совместимости, а компилятор уже подставит вместо FD_SETSIZE нужное значение.

Рассмотрим следующий пример (действительный и для Windows, и для UNIX): мы имеем некую структуру сокетных дескрипторов int *s в количестве snum, уже открытых и обрабатываемых некой программой. Нам необходимо отобрать среди всех этих сокетов те, из которых мы имеем возможность произвести чтение в данный момент времени. И произвести эту запись.

int i, rc;
#ifdef POSIX
int * s; // указатель на пачку сокетов для posix
#else
SOCKET * s; // указатель на пачку сокетов для Windows
#endif
int snum; // количество сокетов
fd_set fdset; // дескриптор сокетов
struct timeval tv = {0,0}; // 0 секунд, 0 микросекунд
unsigned char * data_to_recv; // буфер

FD_ZERO(&fdset); // обнуляем дескриптор сокетов
for(i=0;i<snum;i++)
FD_SET(s[i],&fdset); // выставляем сокеты в дескриптор
rc = select(FD_SETSIZE,&fdset,NULL,NULL,&tv); // выбираем те, откуда можно производить чтение
for(i=0;i<snum;i++) {
if(FD_ISSET(s[i],&fdset)) // если таковой установлен в дескрипторе,

recv(s[i],data_to_recv,SIZE_TO_RECV,0); // читаем из него

// операции по обработке полученных данных

}
}

Рассмотрим синтаксис вызова select() более подробно (взято из cygwin, /usr/include/sys/select.h):

int select __P ((int __n, fd_set *__readfds, fd_set *__writefds, fd_set *__exceptfds, struct timeval *__timeout));

int __n: количество сокетов, для обработки;
fd_set *__readfds: указатель на дескриптор, в который надобно выставить сокеты, готовые к чтению;
fd_set *__writefds: указатель на дескриптор, в который выставляются сокеты, годные к чтению;
fd_set *__exceptfds: указатель на дескриптор, в который выставляются «порванные», ошибочные сокеты;
struct timeval *__timeout: указатель на структуру timeval, где содержится количество времени, на которое select() задерживает возвращение управления вызывающей функции.

Моделирование простого сокетного движка

Теоретически, внутри одного потока выполнения можно обрабатывать сколь угодно большое количество сокетов и реализовывать сколь угодно сложные и разветвлённые алгоритмы. Благо, все сокетные и файловые API, работающие в неблокирующем режиме, возвращают управление очень быстро.

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

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

Рассмотрим моделирование сокетного движка под UNIX и под Windows на примере написанной мной программы. Это консольный сканер IP-диапазона на халявные анонимные SOCKSv5-прокси, Tiny Anonymous SOCKSv5 scanner. Пакет исходных текстов программы, в котором Вы найдёте обещанные мною в предыдущей статье подробные комментарии, Вы можете взять
тут (10.5 Кб). Бинарник, скомпилированный под cygwin,
в этом архиве, а бинарник под Windows
тут.

Итак, основной тип данных, с которым работает сокетный движок, это структура

typedef struct {
#ifdef SSCAN_WS2
SOCKET s; // сокет в Windows
#else
int s; // сокет в posix
#endif
u32 job; // джоб, который сокет должен выполнить
u32 timestamp; // отпечаток времени с последней успешно выполненной операции
u32 addr; // адрес, с которым сокет, в данный момент, осуществляет соединение
} s_st;

В теле программы объявляется глобальный указатель

s_st *s; // socket engine structure

Сокетный движок обращается к структурам s_st, разыменовывая данный указатель.

Джобов, обрабатываемых сокетным движком, в этой программе немного:

1) J_CLOSED, сокет закрыт.
2) J_CONNECTING, сокету дана команда connect().
3) J_CONNECTED, connect() успешно завершён, можно записывать в сокет.
4) J_SENT, сообщение в сокет отправлено.
5) J_IDLE, обработка джоба завершена.

Алгоритм в программе линейный:

while(!enough) {
1) последовательная обработка сокетов, связанных, в настоящий момент времени, с определённым джобом; переключение джоба в случае успешного прохождения всех необходимых операций, в противном случае s[i].job устанавливается в J_CLOSED;
2) проверка таймаутов сокетов, джобы которых находятся в состоянии J_CONNECTING и J_SENT;
3) проверка ошибок в tcp-соединении;
4) запись файла-отпечатка прогресса работы сканера;
5) поиск ответа на вопрос: «а не пора ли закругляться?» 🙂
6) задержка движка на время, определённое пользователем.
}

Сигналы

При реализации сокетного движка и под UNIX, и под Windows, крайне важно перехватывать и контролировать сигналы SIGINT (UNIX+Windows), SIGPIPE и SIGQUIT в UNIX, SIGBREAK и SIGTERM в Windows.

Я игнорирую сигнал SIGPIPE в UNIX, который отправляется в мой процесс в том случае, если я пытаюсь произвести какую-то, с точки зрения ядра, недопустимую операцию с сокетом. Если этот сигнал не перехватывать и не игнорировать, программа будет завершаться с ошибкой «Broken Pipe». Я лично не вижу никакого смысла обрабатывать эту ситуацию в рамках моей программы.

Сигналы SIGINT и SIGQUIT в UNIX, сигналы SIGBREAK и SIGTERM в Windows отвечают за прерывание потока выполнения программы пользователем. Если программа завершается подобным образом, необходимо «опустить» в блокирующий режим и закрыть все сокеты, открытые программой. Не знаю, как для UNIX (который, вроде бы, закрывает все файловые дескрипторы, открытые потоком выполнения при завершении этого потока), но для Windows это критично!

return 0;

Ну вот, вроде бы, и всё. Смело обращайтесь ко мне в мыло со своими вопросами. Я не гарантирую своевременный и конструктивный ответ на Ваше сообщение, но, в любом случае, он не останется незамеченным. Если таких вопросов окажется много, я начну вести FAQ по вопросам мультиплатформенного программирования на cygwin. Заглядывайте на мою страницу, там я буду выкладывать новые версии всех своих публичных программ.

Удачного Вам дня, уважаемый читатель!

Оставить мнение

Check Also

Эхо кибервойны. Как NotPetya чуть не потопил крупнейшего морского перевозчика грузов

Российское кибероружие, построенное на утекших у АНБ эксплоитах, маскировалось под вирус-в…