сетевое программирование в Unix. Часть 4: копаем глубже

Настойчиво преодолевайте невежество, прививаемое высшим образованием.

пролог

В жизни программиста, пишущего для интерфейса сокетов, через некоторое время неотвратимо наступает переломный момент. Написание приложения, обрабатывающего несколько подключений одновременно, или работающего под серьезной нагрузкой. Или хитрого клиента, который одновременно общается с несколькими серверами.
Существует N-ое количество способов организации нетривиальных серверов и клиентов. Наиболее распространенных из них я и коснусь. Материал будет обильно нашпигован информацией из W. Richard Stevens "Unix. Network Programming. Networking API" и ru.unix.prog FAQ. Поэтому не буду отсылать на конкретные источники, а просто последовательно излагать.

"естественный" и "правильный" способы организации сервера

Чем же руководствоваться при выборе подходящего способа организации сервера? Ответ — здравым смыслом, конечно. Непременно сначала подумать, а не бросаться создавать отстойные приложения.
Первый критерий: подумайте, как и сколько времени обрабатывается 1 соединение.
Если у приложения простой протокол запрос-ответ с минимальной обработкой и задержкой на сервере, простой последовательный сервер будет в самый раз. Клиенты просто не заметят разницы :)

последовательный сервер

Последовательный— это когда все запросы обрабатываются друг за другом последовательно (см сервис daytime из 2 части статьи):

24 for (;;) {
25 c_sock=accept(sock,NULL,NULL);
26 char buf[81];
27 memset(buf,0,81);
28 strncpy(buf,daytime(),80);
29 write(c_sock,buf,strlen(buf));
30 shutdown(c_sock,0);
31 close(c_sock);
32 puts("answer tcp");
33 }

Пока текущий не обработан, следующий запрос на соединение стоит в очереди. Размер очереди указывается в listen(2).
Очень просто и, как ни странно, безумно эффективно. Никаких дополнительных накладных расходов на управление соединениями. Но случись задержка в обработке сервером запроса — и остальные клиенты нервно курят в стороне. Длительные операции — увы — не для него.
Как же маштабируемость? Ответ — нуль. Понапихали 4 процессора в машину? Ничего не улучшится :). Все одно, последовательно пилит на одном, ОС бессильна что-либо изменить.
Итого: эффективен, но для простых вещей.

один процесс == один клиент (простой и prefork)


Мысль начинающего сетевого программиста общается сама с собой по очевидной схеме:
— Ага: надо много клиентов одновременно обрабатывать...
— Давайте каждому соединению создадим новый процесс!
— Да, это круто, пельмень!
Так вылазит первая, самая очевидная схема ("естественная"): сервер использует несколько процессов, каждый из которых обслуживает по одному клиенту.
Преимущества этой модели очевидны:
1) простая как ванильная сушка;
2) хорошо маштабируется с ростом числа процессов;
3) ошибка в 1 процессе не ведет к отказу всей программы.
Но тут есть и отрицательные стороны. Процесс — это достаточно тяжелый объект OC. При большом количестве клиентов мы получаем значительную загрузку системы в целом вплоть до отказа. Переключения контекста и связанная с ними черновая работа ОС вполне могут подсадить на задницу процессор любой мощности (при действительно большом количестве клиентов).

[fork_server.cpp]

1 for (;;) {
2 c_sock=accept(sock,NULL,NULL);
3 if ( !fork() ) {
4 puts("incoming tcp\n");
5 char time_str[81];
6 memset(time_str,0,81);
7 strncpy(time_str,daytime(),80);
8 write(c_sock,time_str,strlen(time_str));
9 puts("answer tcp\n");
10 shutdown(c_sock,0);
11 close(c_sock);
12 exit(0);
13 }
14 }

Как же это работает? После прихода соединения (строка 2), запускается новый процесс через fork(2) (строка 3). Головной поток выполнения снова вернулся к фазе accept(2), новый же процесс обрабатывает соединение и завершает работу. Почему fork(2) в if? Выкурите man 2 fork до полного просветления.
Кстати, приведенный пример — не единственный возможный способ реализации модели «1 процесс == 1 клиент». Существует также улучшенный (и более сложный вариант) организации под названием prefork. Суть его следующая: после фазы listen(2) мы запускаем N процессов. В каждом из них — последовательный сервер (см пункт 4.1.1). Вся толпа из N серверов занимается обслуживанием одного и того же принимающего порта. Механизм прост: если одновременно несколько процессов делают accept(2) на одинаковый порт, то ОС погружает всех в спячку до прихода соединения. После прихода соединения — осуществляется т.н. "побудка" и один из процессов побеждает в соц соревновании за сокет. Остальные снова уходят в спячку. Управление этим режимом полностью осуществляется операционной системой.
Плюсы — все уже запущено, только начать обрабатывать. Минус — при количестве процессов >200 начинаются потери в производительности (проверено на людях). Система начинает терять время на обдумывании, кому из 200 процессов отдать на заклание обработку. Как вариант — можно сделать блокировку accept(2) семафором или блокировкой. Тогда только 1 процесс делает accept(2) и разруливание "побудки" не нужно.

один поток == один клиент


Идея проста: меняешь в предыдущем разделе процессы на потоки и защищаешь общие данные. Эффективность подобного решения — величина неизвестная. В разных версиях различных ОС потоки реализованы по разному. Но в общем случае считается, что поток весит меньше чем процесс, и переключение контекста между потоками менее накладно.

1 for (;;) {
2 pthread_mutex_lock(&mutex_tcp);
3 c_sock=accept(sock,NULL,NULL);
4 pthread_mutex_unlock(&mutex_tcp);
5 pthread_create(&tid,NULL,&deliver_tcp,&c_sock);
6 }

Здесь применена защита accept(2) исключающим семафором (как иллюстрация к идее о prefork, описанной в предыдущем разделе, примененная и к потокам). 2 и 4 строки — блокировка и раз-блокировка мутексом. В строке 5 мы запускаем поток на выполнение функции deliver_tcp с параметром c_sock.

однопроцессная Finite State Machine и мультиплексирование

«Потоки, как и объектно ориентированное программирование — это такая "серебряная пуля". Человеку, которому лень думать над тем, что собственно надлежит сделать, разбиение программы на потоки или использование объектов кажется естественным.
А потом он начинает наступать на не очевидные грабли. Потому что на самом деле это не пуля, а крылатая ракета на жидком водороде. Дальнобойность и убойная сила — поразительные, но обслуживания требует ох какого квалифицированного. И стоит дорого.
Поэтому там, где можно обойтись пулей из автомата (в данном случае — конечного) надо обходиться пулей. А крылатой ракетой стрелять только тогда, когда после анализа других тактических вариантов стало ясно, что здесь ничего другое не поможет.» (Виктор Вагнер, из переписки в ru.unix.рrog.)
В среде специалистов наиболее уважаемым вариантом является реализация c использованием FSM (по русски — на конечном автомате). Конечный автомат — это старая математическая абстракция, описывающая машину с некоторым количеством состояний и переходов между ними.
Ключевая особенность Unix, делающая возможным работу FSM-механизма — средства опроса состояния сокета (через select, poll, kevent/kqueue или epoll).
Сервер опрашивает состояние контрольного сокета и сокетов пришедших соединений. В случае активности на каком-нибудь из них — производит необходимые действия и возвращается в состояние опроса. Если объяснение признано невнятным, вот вам атомный пример.
Однопоточный TCP и UDP echo-server с использованием опроса состояния сокетов через select. Приведен с сокращениями:

...
1 struct sockaddr_in addr;
/* здесь инициализация addr */

2 int sock, c_sock, u_sock;
/* здесь включение tcp-сервера на sock и udp-сервера на u_sock */

/* определение наборов дескрипторов файлов и их максимального размера */
3 fd_set rfds, afds;
4 int nfds=getdtablesize();
5 FD_ZERO(&afds);
6 FD_SET(sock,&afds);
7 FD_SET(u_sock,&afds);

8 for (;;) {
9 memcpy(&rfds, &afds, sizeof(rfds));
10 select(nfds, &rfds, NULL,NULL,NULL);

11 if ( FD_ISSET(sock, &rfds) ) {
12 c_sock=accept(sock,NULL,NULL);
13 puts("incoming tcp");
14 FD_SET(c_sock,&afds);
15 continue;
16 }

17 for (int fd=0; fd<nfds; ++fd)
18 if ( fd == u_sock && FD_ISSET(u_sock,&rfds) ) {
19 struct sockaddr from;
20 unsigned int len=sizeof(from);
21 char buffer[81];
22 memset(buffer,0,81);
23 int size=recvfrom(u_sock,&buffer,80,0,&from,&len);
24 sendto(u_sock,buffer,size,0,&from,len);
25 printf("answer udp:%s",buffer);

26 } else if ( fd != sock && FD_ISSET(fd,&rfds) ) {
27 char buffer[81];
28 memset(buffer,0,81);
29 int len=read(fd,buffer,80);
30 if (len <=0) {
31 puts("connection closed");
32 close(fd);
33 FD_CLR(fd, &afds);
34 continue;
35 }

36 write(fd,buffer,len);
37 printf("answer tcp:%s",buffer);
38 } // if and for
39 } // for



Echo-сервер делает весьма прозаическую вещь: все, что к нему приходит, отсылается обратно отправителю. Как в tcp, так и в udp. Данная реализация работает одновременно с множественными TCP-соединениями и с присылаемыми UDP-пакетами в одном единственном процессе.
Насладимся же разбором исходников! :) C строки 3 по 7 появляется новый персонаж нашего повествования: набор дескрипторов файлов (fd_set). Его зовут на помощь, когда надо оперировать не 1 сокетом за раз, а целой пачкой. Для начала заполним его контрольным TCP- и UDP-сокетом. Это начальное состояние нашей FSM — afds, в котором определены два сокета — sock и u_sock.
10 строка — вот и опрос состояния сокетов. В начале их у нас два — sock и u_sock. По мере выполнения итераций цикла их число может меняться. select(2) изменяет значения rfds — потому в строке 9 мы восстанавливаем его каждый раз из afds.
А вот дальше — черная работа. Проверяем, кто из сокетов дернулся:
— sock? Надо принимать соединение и добавлять новый сокет к afds (строки 11-16);
— u_sock? Вычитать UDP пакет и вернуть его содержимое отправителю (строки 18-25);
— один из клиентских TCP-сокетов? Прочитать новый кусочек данных и вернуть их назад (строки 26-38).
Теперь немножко о select(2), пожилом, но полным сил мастодонте Unix API.
Параметры 2, 3, 4 — указатели на наборы дескрипторов файлов. Каких файлов — неважно (см. 1 часть цикла "Сетевое программирование в Unix"). Можете одновременно опрашивать stdout, socket и pipe — система с удовольствием схавает и отработает. Соответственно наборы обозначают дескрипторы "доступные для чтения", "доступные для записи", "те, в которых случилась ошибка". Вполне могут отличаться друг от друга, если вам надо сотворить нетривиальную вещь. Часть из них может быть NULL.
Последний, 5-й, параметр — это время, после которого select закончит работать, если ничего не произошло с дескрипторами из наборов. Когда время NULL — ждет вечно, до наступления событий.
И 1-й параметр — это число, равное значению максимального дескриптора из набора +1. Такой параметр выполняет простую функцию — ограничивает количество просматриваемых дескрипторов указанным числом. Чтобы не зацепить лишние, не используемые программой открытые файлы (это замедлит работу). Вобщем select(2) смотрит 0,1,2,3,..n дескрипторы в поисках активности на них. Это то слабое место, в которое обоснованно тыкают пальцем фанаты других способов опроса состояния файла.
Что же можно сказать об этой модели в общем?
Наиболее эффективна с точки зрения использования CPU.
При наличии долгоиграющих блокирующих операций может быть затыки с параллельным приемом других соединений. В этом случае медленные операции можно вынести в отдельные потоки/процессы и решить тем самым затык. Или поработать операционной системой — разбить большую операцию на куски и выполнять ее, попутно отвлекаясь на остальную работу.
Как уже стало очевидно, модель с FSM требует очень тщательного программирования.

смешанные модели

Вот мы и изучили все базовые типы серверов. Можно заняться их скрещиванием.
Например устойчива к сбоям модель "многопроцессность+FSM". Или производительна (на Linux) "многопоточность+FSM".
Cнова повторюсь. Нет "самой лучшей модели", есть наиболее подходящая к вашим условиям.

заключение

Вот и закончена самая насыщенная часть повествования. В последней, 5 части "Сетевого программирования для Unix" будут препарироваться самые распространенные ошибки начинающих сетевых программистов при работе с socket API. Ну и, конечно, библиография. Занимаясь художественным попсовым изложением классиков было бы аморально не упомянуть их имена и книги.

mend0za.



Сетевые решения. Статья была опубликована в номере 03 за 2004 год в рубрике программирование

©1999-2024 Сетевые решения