Содержание


Пример разработки простого многопоточного сетевого сервера

Часть 4. Обзор методик ввода/вывода в применении к сетевым соединениям

Comments

Серия контента:

Этот контент является частью # из серии # статей: Пример разработки простого многопоточного сетевого сервера

Следите за выходом новых статей этой серии.

Этот контент является частью серии:Пример разработки простого многопоточного сетевого сервера

Следите за выходом новых статей этой серии.

В предыдущих статьях цикла мы рассматривали некоторые стороны внутренней работы и внутреннего устройства нашего сервера. Теперь приступим к разбору сетевого взаимодействия нашей программы с другими: посмотрим, как можно организовать серверную часть соединения по протоколу TCP и познакомимся с несколькими вариантами ожидания входящих данных. Кстати, вопрос ожидания данных не так прост, как может показаться на первый взгляд, так как чем дальше (в контексте сетевой топологии) программа-клиент от нашего сервера и чем хуже канал связи с ней, тем сложнее наладить надёжное взаимодействие между программами. Задача разработчика на этом этапе – приложить максимум усилий к тому, чтобы на серверной стороне эффективно использовались все возможные средства и способы для надёжной передачи данных. Хорошая новость здесь в том, что многие задачи уже решены разработчиками TCP, однако нам нужно не похоронить толковые идеи разработчиков TCP, а использовать их на благо своего приложения.

Организация TCP-соединения на серверной стороне

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

Работа сервера в сети начинается с создания "места" для подключения – розетки (гнезда, сокеты). В libc для этого используется функция socket(2). Для создания сокета нужно через аргументы этой функции передать дополнительную информацию, из которой ядру ОС будет ясно, какого типа данные и как нужно передавать в эту гнездовую часть:

  • домен: в качестве аналога подойдёт что-то вроде вида разъёма (силовой, высокочастотный, оптический и т.д.); среди всех значений есть обозначающее семейство IP версии 4, что нам и нужно;
  • тип: в качестве аналога подойдёт что-то вроде параметров протекающего через разъём тока (постоянный, переменный разных видов и т.д.); здесь нужно указать, что будет производиться работа с потоком данных в рамках логического соединения с поддержкой сеансов (SOCK_STREAM);
  • протокол: по-моему, тут ближе аналогия с геометрической формой разъёма, но фактически понятие "протокол" существенно шире; в нашем случае именно здесь и нужно указывать 6, что соответствует TCP (известные протоколы с номерами перечислены в /etc/protocols).

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

Дальше нам нужно как-то "выставить" розетку в сетевой мир ("наружу") и затем подключить к датчикам активности. Наружу розетка выставляется при помощи вызова bind(2) и заключается это, в отношении TCP, в привязке к сокете IP-адреса и номеру порта (или наоборот, сокеты к адресу и порту). Если продолжать тему аналогий, то IP-адрес в данном случае можно рассматривать как полный адрес получателя почтового отправления вплоть до номера квартиры, а номер порта – как ФИО получателя, поскольку в пределах одной квартиры людей может быть несколько, и их нужно как-то различать. В этом случае квартира будет олицетворять собой работающий экземпляр операционной системы, а находящиеся в ней люди – отдельные программы. Хотя аналогия не совсем точная (одна программа может слушать несколько портов одновременно), для получения общего представления о портах её, по-моему, достаточно.

После этого можно подключать датчик к розетке – это делается при помощи вызова listen(2). В случае успеха ядро начнёт передавать в эту розетку данные, нам только останется по показаниям датчика отмечать появление данных и адекватно на них реагировать. В качестве самих датчиков используются вызовы accept(2)/poll(2)/select(2).

Собираем всё вместе, в качестве датчика – accept(2):

#include <errno.h> 
#include <netdb.h> 
#include <stdio.h> 
#include <stdlib.h> 
#include <string.h> 
#include <netinet/in.h> 
#include <sys/socket.h> 
 
int sockId, status, i = 0, clientStructSize = 0; 
struct addrinfo *hints, *server; 
struct sockaddr_in *client; 
char clientIpAddress[16]; 
memset(clientIpAddress, '\0', 16); 
client = (struct sockaddr_in*) calloc(1, sizeof(struct sockaddr_in)); 
clientStructSize = sizeof(struct sockaddr_in); 
 
sockId = socket(AF_INET, SOCK_STREAM, 6); 
if (-1 == sockId) { 
    fprintf(stderr, "Ошибка при создании сокеты: %s\n", strerror(errno)); 
    free(client); 
    close(sockId); 
    exit(EXIT_FAILURE); 
} 
// hints – набор указаний для getaddrinfo(3) 
hints = (struct addrinfo*) calloc(1, sizeof(struct addrinfo)); 
hints->ai_family = AF_INET; // использовать семейство TCP/IP 
hints->ai_socktype = SOCK_STREAM; // использовать поточную передачу данных 
hints->ai_protocol = 6; // использовать TCP 
hints->ai_flags = AI_PASSIVE; // создать принимающую часть соединения 
/* 127.0.0.1:44444 – адрес и порт, на которых будем ждать соединений, 
 * по всем этим данным getaddrinfo(3) создаст и заполнит структуру данных server 
 * для гнездовой части 
 */ 
status = getaddrinfo("127.0.0.1", "44444", hints, &server); 
if (0 != status) { 
    fprintf(stderr, "Ошибка при преобразовании адреса: %s\n", gai_strerror(status)); 
    free(client); 
    free(hints); 
    close(sockId); 
    exit(EXIT_FAILURE); 
} 
if (-1 == bind(sockId, server->ai_addr, server->ai_addrlen)) { 
    fprintf(stderr, "Ошибка привязки сокеты к адресу: %s\n", strerror(errno)); 
    free(client); 
    free(hints); 
    close(sockId); 
    exit(EXIT_FAILURE); 
} 
if (-1 == listen(sockId, 10)) { 
    fprintf(stderr, "Ошибка включения прослушивания на сокете: %s\n", strerror(errno)); 
    free(client); 
    free(hints); 
    close(sockId); 
    exit(EXIT_FAILURE); 
} 
if (-1 == accept(sockId, (struct sockaddr *)client, &clientStructSize)) { 
    fprintf(stderr, "Ошибка ожидания на сокете: %s\n", strerror(errno)); 
    free(client); 
    free(hints); 
    close(sockId); 
    exit(EXIT_FAILURE); 
} 
inet_ntop(AF_INET, &(client->sin_addr), clientIpAddress, sizeof(clientIpAddress)); 
fprintf( stdout, "Соединение от клиента [%s:%d]\n", clientIpAddress, 
		ntohs(client->sin_port) ); 
// здесь можно выполнять всякие полезные действия по поводу полученного запроса.

Традиционно код только иллюстрирует идею и не является ни законченным, ни надёжным. Тут можно немного задержаться на прямом и обратном порядках байтов. Исторически сложилось так, что на разных аппаратных платформах составляющие машинные слова байты записываются в ОЗУ по-разному: на одних первым расположен старший байт и дальше по порядку к младшему (big-endian, обратный, сетевой), на других (в частности, на x86) первым расположен младший байт и дальше, с возрастанием адресов ячеек памяти, к старшему (little-endian, прямой). Ещё есть смешанный порядок (так называемый PDP-endian), но по сравнению с другими двумя порядками он самый неудобный в работе, поэтому в настоящее время практически не используется.

Для обмена данными между разными аппаратными платформами по сети нужен был какой-то компромисс, и этим самым компромиссом решили сделать "обратный" порядок, big-endian, который благодаря этому и стал "сетевым". В связи с этим на узлах с прямым порядком байтов перед передачей данных в на другую аппаратную платформу (с обратным порядком байтов) их необходимо переупорядочить к сетевому порядку и провести обратную операцию при приёме данных от этой платформы. Для самих передаваемых данных такие операции по умолчанию не выполняются, но вот служебная информация вроде IP-адресов и номеров портов преобразовывается, так как в таком виде её ожидает получить различное сетевое оборудование в Интернет. Преобразование служебной информации к прямому порядку проиллюстрировано в последних двух строках примера. IP-адрес преобразуется в привычную нам "строку из четырёх чисел через точки" при помощи функции inet_ntop(3), а номер порта преобразовывается при помощи функции ntohs(3).

"Серверные" адрес и порт можно взять из конфига – из структуры параметров, использовавшейся в предыдущих статьях цикла (только там для хранения номера порта использовано поле с типом int). Также нужно отметить, что приведённый код работает только с IP версии 4, для шестой версии названия некоторых библиотечных структур и функций будут другими.

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

Вариант 1: "просто" блокирование

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

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

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

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

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

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

Вариант 2: "просто" блокирование с тайм-аутом

Предыдущий вариант можно немного усовершенствовать, если одновременно с запросом данных включать счётчик времени и по его останову отменять запрос (если данные за это время так и не пришли). Такой подход не отменяет минусов блокирующего подхода в целом, однако делает его немного более гибким и добавляет управляемости использующим его приложениям. Реализовать этот вариант в GNU/Linux можно двумя способами:

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

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

Также можно обойтись без poll(2)/select(2) и использовать функцию alarm(2) для отправки сигнала самим себе через заданное время и тем самым вывести программу из блокировки. Здесь остаётся в силе упомянутая выше тонкость про возврат в блокировку либо переход к следующей инструкции. Этот вариант сложнее тем, что придётся писать свой обработчик сигнала и, кроме того, при необходимости отслеживать состояние нескольких каналов одновременно код может получиться более громоздким, чем при использовании poll(2)/select(2).

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

Вариант 3: работа без блокировок

При запросе или отправке данных в неблокирующем режиме остановки выполнения программы не происходит: запрос либо обслуживается, либо сразу завершается с соответствующей ошибкой (переменная errno будет содержать код EAGAIN или EWOULDBLOCK, man 3 errno). В простейшем случае здесь можно в цикле выполнять операцию чтения или записи, проверять её результат и пытаться снова. Очевидно, что простейший подход опасен – вычислительные ресурсы будут потребляться максимально быстро и, в общем-то, бесполезно – пока что данные передаются по сети значительно медленнее, чем ЦП выполняет инструкции. Возможные выходы тут следующие:

  • самоблокировка в виде "сна" (скажем, при помощи sleep(3)) на какое-то время;
  • попросить систему сообщить о готовности данных или возможности их передать и заняться другими делами (в том числе, можно и "заснуть").

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

Сообщить ядру о нежелании блокироваться при работе с конкретной розеткой можно через команду F_SETFL и флаг O_NONBLOCK вызова fcntl(2).

Заключение

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

Ограниченный объём статьи не позволил исследовать другие протоколы (например, UDP), типы соединений (SOCK_RAW) и привести больше "готового к употреблению" кода. В то же время изначально перед этим циклом статей и не ставилась задача исчерпывающе описать вообще всё, поэтому предлагаемые сведения и выбранный мною стиль изложения подразумевают существенный объём самостоятельной работы.


Ресурсы для скачивания


Комментарии

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux, Open source
ArticleID=498011
ArticleTitle=Пример разработки простого многопоточного сетевого сервера: Часть 4. Обзор методик ввода/вывода в применении к сетевым соединениям
publish-date=06242010