КАТЕГОРИИ: Архитектура-(3434)Астрономия-(809)Биология-(7483)Биотехнологии-(1457)Военное дело-(14632)Высокие технологии-(1363)География-(913)Геология-(1438)Государство-(451)Демография-(1065)Дом-(47672)Журналистика и СМИ-(912)Изобретательство-(14524)Иностранные языки-(4268)Информатика-(17799)Искусство-(1338)История-(13644)Компьютеры-(11121)Косметика-(55)Кулинария-(373)Культура-(8427)Лингвистика-(374)Литература-(1642)Маркетинг-(23702)Математика-(16968)Машиностроение-(1700)Медицина-(12668)Менеджмент-(24684)Механика-(15423)Науковедение-(506)Образование-(11852)Охрана труда-(3308)Педагогика-(5571)Полиграфия-(1312)Политика-(7869)Право-(5454)Приборостроение-(1369)Программирование-(2801)Производство-(97182)Промышленность-(8706)Психология-(18388)Религия-(3217)Связь-(10668)Сельское хозяйство-(299)Социология-(6455)Спорт-(42831)Строительство-(4793)Торговля-(5050)Транспорт-(2929)Туризм-(1568)Физика-(3942)Философия-(17015)Финансы-(26596)Химия-(22929)Экология-(12095)Экономика-(9961)Электроника-(8441)Электротехника-(4623)Энергетика-(12629)Юриспруденция-(1492)Ядерная техника-(1748) |
Параллельное обслуживание клиентов
Следующий важный вопрос, который нам предстоит обсудить, - это параллельное обслуживание клиентов. Эта проблема становится актуальной, когда сервер должен обслуживать большое количество запросов. Конечно, на машине с одним процессором настоящей параллельности достичь не удастся. Но даже на одной машине можно добиться существенного выигрыша в производительности. Допустим, сервер отправил какие-то данные клиенту и ждёт подтверждения. Пока оно путешествует по сети, сервер вполне мог бы заняться другими клиентами. Для реализации такого алгоритма обслуживания существует множество способов, но чаще всего применяются два из них. Способ 1 Этот способ подразумевает создание дочернего процесса для обслуживания каждого нового клиента. При этом родительский процесс занимается только прослушиванием порта и приёмом соединений. Чтобы добиться такого поведения, сразу после accept сервер вызывает функцию fork для создания дочернего процесса (я предполагаю, что вам знакома функция fork; если нет, обратитесь к документации). Далее анализируется значение, которое вернула эта функция. В родительском процессе оно содержит идентификатор дочернего, а в дочернем процессе равно нулю. Используя этот признак, мы переходим к очередному вызову accept в родительском процессе, а дочерний процесс обслуживает клиента и завершается (_exit). С использованием этой методики наш эхо-сервер перепишется, как показано в листинге 6. Листинг 6. Эхо-сервер (версия 2, fork) #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> int main() { int sock, listener; struct sockaddr_in addr; char buf[1024]; int bytes_read; listener = socket(AF_INET, SOCK_STREAM, 0); if(listener < 0) { perror("socket"); exit(1); }
addr.sin_family = AF_INET; addr.sin_port = htons(3425); addr.sin_addr.s_addr = INADDR_ANY; if(bind(listener, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(2); } listen(listener, 1);
while(1) { sock = accept(listener, NULL, NULL); if(sock < 0) { perror("accept"); exit(3); }
switch(fork()) { case -1: perror("fork"); break;
case 0: close(listener); while(1) { bytes_read = recv(sock, buf, 1024, 0); if(bytes_read <= 0) break; send(sock, buf, bytes_read, 0); } close(sock); _exit(0);
default: close(sock); } }
close(listener); return 0; } Очевидное преимущество такого подхода состоит в том, что он позволяет писать весьма компактные, понятные программы, в которых код установки соединения отделён от кода обслуживания клиента. К сожалению, у него есть и недостатки. Во-первых, если клиентов очень много, создание нового процесса для обслуживания каждого из них может оказаться слишком дорогостоящей операцией. Во-вторых, такой способ неявно подразумевает, что все клиенты обслуживаются независимо друг от друга. Однако это может быть не так. Если, к примеру, вы пишете чат-сервер, то ваша основная задача - поддерживать взаимодействие всех клиентов, присоединившихся к нему. В этих условиях границы между процессами станут для вас серьёзной помехой. В подобном случае вам следует серьёзно рассмотреть другой способ обслуживания клиентов. Способ 2 Второй способ основан на использовании неблокирующих сокетов (nonblocking sockets) и функции select. Сначала разберёмся, что такое неблокирующие сокеты. Сокеты, которые мы до сих пор использовали, являлись блокирующими (blocking). Это название означает, что на время выполнения операции с таким сокетом ваша программа блокируется. Например, если вы вызвали recv, а данных на вашем конце соединения нет, то в ожидании их прихода ваша программа "засыпает". Аналогичная ситуация наблюдается, когда вы вызываете accept, а очередь запросов на соединение пуста. Это поведение можно изменить, используя функцию fcntl. #include <unistd.h> #include <fcntl.h> . . sockfd = socket(AF_INET, SOCK_STREAM, 0); fcntl(sockfd, F_SETFL, O_NONBLOCK); . . Эта несложная операция превращает сокет в неблокирующий. Вызов любой функции с таким сокетом будет возвращать управление немедленно. Причём если затребованная операция не была выполнена до конца, функция вернёт -1 и запишет в errno значение EWOULDBLOCK. Чтобы дождаться завершения операции, мы можем опрашивать все наши сокеты в цикле, пока какая-то функция не вернёт значение, отличное от EWOULDBLOCK. Как только это произойдёт, мы можем запустить на выполнение следующую операцию с этим сокетом и вернуться к нашему опрашивающему циклу. Такая тактика (называемая в англоязычной литературе polling) работоспособна, но очень неэффективна, поскольку процессорное время тратится впустую на многократные (и безрезультатные) опросы. Чтобы исправить ситуацию, используют функцию select. Эта функция позволяет отслеживать состояние нескольких файловых дескрипторов (а в Unix к ним относятся и сокеты) одновременно. #include <sys/time.h> #include <sys/types.h> #include <unistd.h> int select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); FD_CLR(int fd, fd_set *set); FD_ISSET(int fd, fd_set *set); FD_SET(int fd, fd_set *set); FD_ZERO(int fd); Функция select работает с тремя множествами дескрипторов, каждое из которых имеет тип fd_set. В множество readfds записываются дескрипторы сокетов, из которых нам требуется читать данные (слушающие сокеты добавляются в это же множество). Множество writefds должно содержать дескрипторы сокетов, в которые мы собираемся писать, а exceptfds - дескрипторы сокетов, которые нужно контролировать на возникновение ошибки. Если какое-то множество вас не интересуют, вы можете передать вместо указателя на него NULL. Что касается других параметров, в n нужно записать максимальное значение дескриптора по всем множествам плюс единица, а в timeout - величину таймаута. Структура timeval имеет следующий формат. struct timeval { int tv_sec; // секунды int tv_usec; // микросекунды }; Поле "микросекунды" смотрится впечатляюще. Но на практике вам не добиться такой точности измерения времени при использовании select. Реальная точность окажется в районе 100 миллисекунд. Теперь займёмся множествами дескрипторов. Для работы с ними предусмотрены функции FD_XXX, показанные выше; их использование полностью скрывает от нас детали внутреннего устройства fd_set. Рассмотрим их назначение. FD_ZERO(fd_set *set) - очищает множество set FD_SET(int fd, fd_set *set) - добавляет дескриптор fd в множество set FD_CLR(int fd, fd_set *set) - удаляет дескриптор fd из множества set FD_ISSET(int fd, fd_set *set) - проверяет, содержится ли дескриптор fd в множестве set Если хотя бы один сокет готов к выполнению заданной операции, select возвращает ненулевое значение, а все дескрипторы, которые привели к "срабатыванию" функции, записываются в соответствующие множества. Это позволяет нам проанализировать содержащиеся в множествах дескрипторы и выполнить над ними необходимые действия. Если сработал таймаут, select возвращает ноль, а в случае ошибки -1. Расширенный код записывается в errno. Программы, использующие неблокирующие сокеты вместе с select, получаются весьма запутанными. Если в случае с fork мы строим логику программы, как будто клиент всего один, здесь программа вынуждена отслеживать дескрипторы всех клиентов и работать с ними параллельно. Чтобы проиллюстрировать эту методику, я в очередной раз переписал эхо-сервер с использованием select. Новая версия приведена в листинге 7. Обратите внимание, что эта программа, в отличие от всех остальных, написана на C++ (а не на C). Я воспользовался классом set из библиотеки STL языка C++, чтобы облегчить работу с набором дескрипторов и сделать её более понятной. Листинг 7. Эхо-сервер (версия 3, неблокирующие сокеты и select). #include <sys/types.h> #include <sys/socket.h> #include <sys/time.h> #include <netinet/in.h> #include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <algorithm> #include <set> using namespace std; int main() { int listener; struct sockaddr_in addr; char buf[1024]; int bytes_read; listener = socket(AF_INET, SOCK_STREAM, 0); if(listener < 0) { perror("socket"); exit(1); }
fcntl(listener, F_SETFL, O_NONBLOCK);
addr.sin_family = AF_INET; addr.sin_port = htons(3425); addr.sin_addr.s_addr = INADDR_ANY; if(bind(listener, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(2); } listen(listener, 2);
set<int> clients; clients.clear(); while(1) { // Заполняем множество сокетов fd_set readset; FD_ZERO(&readset); FD_SET(listener, &readset); for(set<int>::iterator it = clients.begin(); it!= clients.end(); it++) FD_SET(*it, &readset); // Задаём таймаут timeval timeout; timeout.tv_sec = 15; timeout.tv_usec = 0; // Ждём события в одном из сокетов int mx = max(listener, *max_element(clients.begin(), clients.end())); if(select(mx+1, &readset, NULL, NULL, &timeout) <= 0) { perror("select"); exit(3); }
// Определяем тип события и выполняем соответствующие действия if(FD_ISSET(listener, &readset)) { // Поступил новый запрос на соединение, используем accept int sock = accept(listener, NULL, NULL); if(sock < 0) { perror("accept"); exit(3); }
fcntl(sock, F_SETFL, O_NONBLOCK); clients.insert(sock); } for(set<int>::iterator it = clients.begin(); it!= clients.end(); it++) { if(FD_ISSET(*it, &readset)) { // Поступили данные от клиента, читаем их bytes_read = recv(*it, buf, 1024, 0); if(bytes_read <= 0) { // Соединение разорвано, удаляем сокет из множества close(*it); clients.erase(*it); continue; } // Отправляем данные обратно клиенту send(*it, buf, bytes_read, 0); } } }
return 0; } Работа по стандартным протоколам Как я уже говорил, сокеты могут использоваться при написании приложений, работающих по протоколам прикладного уровня Internet (HTTP, FTP, SMTP и т. д.). При этом взаимодействие клиента и сервера происходит по той же самой схеме, что и взаимодействие эхо-клиента и эхо-сервера в нашем примере. Разница в том, что данные, которыми обмениваются клиент и сервер, интерпретируются в соответствии с предписаниями соответствующего протокола. Например, веб-сервер может работать по следующему алгоритму. Создаём слушающий сокет и привязываем его к 80-му порту (стандартный порт для HTTP-сервера). Принимаем очередной запрос на соединение. Читаем HTTP-запрос от клиента (он имеет стандартный формат и описан в RFC2616). Обрабатываем запрос и отправляем клиенту ответ, который также имеет стандартный формат. Разрываем соединение. Веб-броузер, который является клиентом по отношению к веб-серверу, может использовать похожий алгоритм. Соединяемся с сервером по заданному адресу. Отправляем ему HTTP-запрос. Получаем и обрабатываем ответ сервера (например, форматируем и выводим на экран полученную HTML-страницу). Разрываем соединение. Как видим, в работе по стандартным протоколам нет ничего сложного или принципиально нового.
Часто в сетевых программах (например в играх) нельзя ждать, пока придет очередное сообщение, а нужно выполнять какие-то другие действия (например, перерисовывать экран). Поэтому имеет смысл воспользоваться функцией select. Она позволяет определить, есть ли у гнезда новые данные, или нет. Кроме того, эта функция умеет ждать прихода сообщения в течение определённого промежутка времени, и если он проходит, а сообщения всё нет, то функция завершается. Эта функция позволяет отслеживать очень маленькие промежутки времени _ с точностью до одной микросекунды. Напишем функцию bool CheckMessage(int Socket, char * Buffer), которая будет проверять, пришло ли сообщение на сокете Socket. Если да, то она возвращает true и сливает 1024 байта из входной трубы сокета. в массив Buffer, а если нет, то просто возвращает false. bool CheckMessage(int Socket, int * Buffer) { fd_set DSet; FD_ZERO(&DSet); FD_SET(Socket, &DSet); timeval tv; tv.tv_sec = 0; tv.tv_usec = 10000; if (select(Socket+1, &DSet, 0, 0, &tv) > 0) { read(Socket, Buffer, 1024); return true; } else return false; } Максимальный интервал ожидания установлен в 10000 микросекунд, т. е. если в течение 1/100 секунды данные не появятся, будет выдано значение false. Использовать функцию CheckMessage надо примерно так: после установки соединения делаем бесконечный цикл, в котором вызываем эту функцию, и если результат положительный, то обрабатываем данные, иначе _ продолжаем цикл. Скажем пару слов о том, что такое fd_set и select. Как уже было сказано, сокет _ это дескриптор потока ввода-вывода. В недрах ОС содержится информация по каждому дескриптору, и разработчики предусмотрели механизм получения этой информации. Структура fd_set описывает битовое множество всех дескрипторов. Существует три типа событий, сигнализирующих о состоянии каждого дескриптора: • Есть данные для чтения • Гнездо готово к записи данных • Произошла ошибка За эти три типа событий отвечают второй, третий и четвёртый параметры функции select соответственно. Чтобы получить информацию о каком-либо дескрипторе, нужно установить в соответствующем битовом множестве бит этого дескриптора и вызвать функцию select. Первый параметр ограничивает максимальный номер интересующего нас дескриптора плюс один. Нас интересует только информация о данных на чтение, поэтому третий и четвёртый параметры установлены в нули. Последний параметр _ это величина таймаута в микросекундах. Функция select будет прослушивать указанные дескрипторы в течение этого времени, и, если в каком-либо из выбранных дескрипторов произошло событие требуемого типа, вернёт положительное число. Чтобы узнать, в каком именно, вообще говоря, нужно проверить битовое множество DSet, но в нашем случае дескриптор всего один, и проверять ничего не нужно. Если бы таковая проверка была бы необходима, её следовало бы выполнять с помощью макроса FD_ISSET. Более подробную информацию о select всегда можно посмотреть в справочной системе Linux.
Дата добавления: 2017-02-01; Просмотров: 66; Нарушение авторских прав?; Мы поможем в написании вашей работы! |