Наши партнеры

UnixForum





Библиотека сайта rus-linux.net

Сервер TCP/IP ... много серверов хороших и разных

Олег И.Цилюрик

Ремейк 2.08 от 30.04.2012 г. статьи, опубликованной в 2003 году.

Оглавление

  • История вопроса
  • Постановка задачи
  • Клиент
  • Библиотека средств хронометража и диагностики
  • Варианты серверов
  • Простой последовательный сервер
  • Классический параллельный сервер
  • Сервер с предварительным созданием копий процесса
  • Сервер активируемый суперсервером (xinetd)
  • Параллельный сервер, создающий потоки по запросам
  • Сервер с предварительным созданием потоков
  • Оптимальное управление потоками (пул)
  • Последовательный сервер с очередью обслуживания
  • В качестве итогов...
  • История вопроса

    Первоначально (июнь 2003г.) эта публикация была отработана в операционной системе QNX (версия 6.1) и подготовлена для публикации в журнале «СТА» [1], позже (июнь 2004г.) она вошла в книгу [2]. За прошедшие 10 лет ко мне неоднократно обращались с вопросами, замечаниями и за советом в связи с этим текстом. Оказалось, что и операционная система QNX несколько утратила свою актуальность, и многие публикации того времени выглядят не интересными, а вопрос систематизации техник программирования серверов не утратил своей актуальности. Но, когда возникла возможность заново опубликовать эту статью уже относительно Linux, оказалось, что в Linux достаточно многие вещи нужно поменять (хоть и по мелочам). Из-за этого, предлагается не прежняя версия текста, а новая, переписанная «по мотивам», поэтому и названная «ремейк». Ещё более поменялись примеры иллюстрирующих кодов. Я беру за основу для переписывания исходный (старый) текст как он есть, пусть даже текст при том может выглядеть, местами, достаточно несуразно... Но исходной установкой было: осовременить существующий текст: сравнить как было и как стало (через 10 лет).

    Постановка задачи

    Зачастую, если это не приходится делать вам очень часто (не является основной спецификой работы), при необходимости написания TCP/IP сервера «по-быстренькому» используются одна из двух классических технологий: последовательный сервер, или параллельный сервер на основе fork() (Windows-программисты в этом случае пишут сервер на основе потоков). Хотя реально можно предложить гораздо больше принципиально различных серверов, которые будут существенно отличаться своей сложностью, временем реакции на запрос клиента и другими показателями. Ниже описано несколько таких способов с результатами их поверхностной оценки.

    Мы напишем специальный тестовый TCP/IP клиент, который посылает требуемое число раз запрос к серверу (а сервера будут выполнять функции простого ретранслятора), принимает от него ответ, и тут же разрывает соединение (по такой примерно схеме обрабатываются запросы в HTTP протоколе). Серия запросов от клиента делается для усреднения результата и для того (как будет видно далее), чтобы исключить (или учесть, выделить) эффекты кэширования памяти. Клиент измеряет время (точнее – число тактов процессора, это высокая точность наносекундного диапазона) между отправкой запроса серверу и приходом ответа от него. Все показанные программы – предложены в упрощённых вариантах: не везде сделана полная обработка ошибочных ситуаций (что, вообще-то говоря, крайне необходимо), и сознательно не включена обработка сигнала SIGCHLD, которая должна препятствовать появлению «зомби» процессов. Все приводимые коды программ – работающие и апробированные: весь результирующий вывод скопирован непосредственно с консоли задачи. Весь приводимый программный код транслировался компилятором GCC в нотации языка C++ (хотя специфические объектные особенности С++, за исключением потокового ввода-вывода С++ и не использованы — всё то же легко выписать на классическом С, но оно выглядит несколько более объёмным, и это одна из причин выбора языка иллюстраций).

    Клиент

    Код клиента оказывается самым объёмным примером в нашем рассмотрении, ну, хотя бы просто потому, что серверов у нас много, а клиент — один, и все тонкие детали, какие возможно, отнесены из кодов серверов в код клиента. Сам код клиента (см. прилагаемый архив) размещён в файле cli.cpp, но он, совместно с сервером, использует общие файлы common.h и common.cc, эти файлы с краткими комментариями приведены ниже:

    Файл common.h – общие определения:

    #define __COMMON_H 
    #include <iostream&qt; 
    #include <iomanip&qt; 
    using namespace std; 
    #include <stdlib.h&qt; 
    #include <stdio.h&qt; 
    #include <errno.h&qt; 
    #include <string.h&qt; 
    #include <netdb.h&qt; 
    #include "libdiag.h" 
    #define EOK 0 
    const int PORT = 51000,   // 9000 сменил на 51000, 9000 в Linux заняты 
              SINGLE_PORT = PORT, 
              FORK_PORT = PORT + 1, 
              FORK_LARGE_PORT = PORT + 2, 
              PREFORK_PORT = PORT + 3, 
              XINETD_PORT = PORT + 4, // 51004 
              THREAD_PORT = PORT + 5, 
              THREAD_POOL_PORT = PORT + 6, 
              PRETHREAD_PORT = PORT + 7, 
              QUEUE_PORT = PORT + 8; 
    const int MAXLINE = 40;  // критическая ошибка ... 
    void errx( const char *msg, int err = EOK );  // ретранслятор тестовых пакетов TCP 
    void retrans( int sc );  // создание и подготовка прослушивающего сокета 
    int getsocket( in_port_t );  
    extern int debug; 	 // уровень отладочного вывода сервера 
    			 // параметры строки запуска сервера 
    void setv( int argc, char *argv[] ); 
    #endif 

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

    Файл common.cc — реализация тех рутинных процедур, которые описаны в common.h:

    #include "common.h" 
    
    // диагностика ошибки ... 
    void errx( const char *msg, int err ) { 
        perror( msg ); 
        if( err != EOK ) errno = err; 
        exit( EXIT_FAILURE ); 
    }; 
    
    // ретранслятор тестовых пакетов TCP 
    static char data[ MAXLINE ]; 
    void retrans( int sc ) { 
       int rc = read( sc, data, MAXLINE ); 
       if( rc > 0 ) { 
          rc = write( sc, data, strlen( data ) + 1 ); 
          if ( rc < 0 ) perror( "write data failed" ); 
       } 
       else if( rc < 0 ) { perror( "read data failed" ); return; } 
       else if( rc == 0 ) { cout < "client closed connection" < endl; return; }; 
       return; 
    }; 
    
    // создание и подготовка прослушивающего сокета 
    static struct sockaddr_in addr; 
    int getsocket(  in_port_t p ) { 
       int rc = 1, ls; 
       if( -1 == ( ls = socket( AF_INET, SOCK_STREAM, 0 ) ) )
          errx( "create stream socket failed" ); 
       if( setsockopt( ls, SOL_SOCKET, SO_REUSEADDR, &rc, sizeof( rc ) ) != 0 )
          errx( "set socket option failed" ); 
       memset( &addr, 0, sizeof( addr ) ); 
       addr.sin_family = AF_INET; 
       addr.sin_port = htons( p ); 
       addr.sin_addr.s_addr = htonl( INADDR_ANY ); 
       if( bind( ls, (struct sockaddr*)&addr, sizeof( sockaddr ) ) != 0 )
          errx( "bind socket address failed" ); 
       if( listen( ls, 25 ) != 0 ) errx( "put socket in listen state failed" ); 
       cout < "waiting on port " < p < " ..." < endl; 
       return ls; 
    }; 
    
    // уровень отладочного вывода сервера 
    int debug = 0; 
    void setv( int argc, char *argv[] ) { 
       debug = ( argc > 1 && 0 == strcmp( argv[ 1 ], "-v" ) ) ? 1 : 0; 
       if( debug ) cout < "verbose mode" < endl; 
    } 

    Теперь мы можем рассмотреть и код используемого клиента:

    #include "common.h" 
    #include <arpa/inet.h> 
    
    // установка параметров клиентов: адрес, порт и число повторений 
    static void setkey( int argc, char *argv[], char *adr, in_port_t* port, int* num ) { 
        int opt, val; 
        while ( ( opt = getopt( argc, argv, "a:p:n:") ) != -1 ) { 
            switch( opt ) { 
                case 'a' : 
                    strcpy( adr, optarg ); 
                    break; 
                case 'p' : 
                    if( sscanf( optarg, "%i", &val ) != 1 )
                       errx( "parse command line failed", EINVAL ); 
                    *port = (in_port_t)val; 
                    break; 
                case 'n' : 
                    if( ( sscanf( optarg, "%i", &val ) != 1 ) || ( val <= 0 ) )
                       errx( "parse command line failed", EINVAL ); 
                    *num = val; 
                    break; 
                default : 
                    errx( "parse command line failed", EINVAL ); 
                    break; 
            } 
        }; 
    }; 
    
    // клиент - источник потока тестовых пакетов TCP 
    int main( int argc, char *argv[] ) { 
       in_port_t listen_port = SINGLE_PORT; 
       int num = 10; 
       char data[ MAXLINE ], echo[ MAXLINE ], sadr[ MAXLINE ] = "localhost"; 
       setkey( argc, argv, &sadr[ 0 ], &listen_port, &num ); 
       cout << "wait ..." << flush; 
       uint64_t cps = proc_hz(); // cycles per sec. 
       cout << '\r' << "host: " << sadr << ", TCP port = " << listen_port 
            << ", number of echoes = " << num << endl << "time of reply - Cycles [usec.] :"
            << endl; 
       for( int i = 0; i < num; i++ ) { 
          int rc, ls; 
          if( ( ls = socket( AF_INET, SOCK_STREAM, 0 ) ) < 0 )
             errx( "create stream socket failed" ); 
          struct sockaddr_in addr; 
          memset( &addr, 0, sizeof( addr ) ); 
          addr.sin_family = AF_INET; 
          addr.sin_port = htons( listen_port ); 
          inet_aton( sadr, &addr.sin_addr ); 
          if( ( rc = connect( ls, (struct sockaddr*)&addr, sizeof( sockaddr ) ) ) < 0 )
             errx( "connect failed" ); 
          sprintf( data, "%d", rand() ); 
          uint64_t cycle = rdtsc(); 
          if( ( rc = write( ls, data, strlen( data ) + 1 ) ) <= 0 ) {
             perror( "write data failed" ); break;
          }; 
          rc = read( ls, echo, MAXLINE ); 
          cycle = rdtsc() - cycle;      
          if( rc < 0 ) { perror( "read data failed" ); break; }; 
          if( rc == 0 ) { cout << "server closed connection" << endl; break; }; 
          if( strcmp( data, echo ) != 0 ) { cout << "wrong data" << endl; break; }; 
          cout << cycle << "[" << cycle * 1000000 / cps << "]";   // sec. / 10E6 
          if( i % 5 == 4 ) cout << endl; else cout << '\t'; cout << flush; 
          close( ls ); 
          delay( 100 ); // пауза 100 usec. 
       }; 
       if( num % 5 != 0 ) cout << endl; 
       exit( EXIT_SUCCESS ); 
    }; 

    С клиентом, из текста кода, всё должно быть относительно понятно: после запуска клиент анализирует ключи запуска. Предусмотрены значения: -a — адрес сервера (по умолчанию это localhost), -p — значение TCP порта подключения (по умолчанию это 51000, что соответствует простому последовательному серверу) и -n — число запросов к серверу в серии (по умолчанию 10). Каждый запрос представляет собой случайное число, генерируемое клиентом, в символьной форме. Ретранслированный сервером ответ сверяется с запросом для дополнительного контроля.

    По каждому TCP запросу клиент выводит число машинных тактов, ушедших на ожидание ответа от сервера, а в скобках – для справки – время в микросекундах, соответствующее этому числу тактов. Отметим здесь же, что и число тактов, и абсолютное значение задержки, в этих экспериментах носят очень условные характер: клиент фиксирует число тактов своего процессора (у меня это >3Ghz), в то время, как значительная часть задержки обеспечивается процессором, на котором работает сервер (у меня это 1.66Ghz).

    Далее мы рассмотрим работу такого клиента с различными серверами.

    Библиотека средств хронометража и диагностики

    Поскольку нам нужна разрешающая способность (наносекундного диапазона) хронометража, намного превышающая разрешающую способность системного таймера Linux (миллисекундного диапазона), то для изменения таких временных интервалов используются измерения на основе счётчика тактов частоты процессора, прошедших с момента загрузки системы (команда RDTSC процессоров x86). Для осуществления таких измерений привлечена библиотека (в виде исходных кодов) из другого проекта, а все вызовы оформлены, в данном случае, как статическая библиотека libdiag.a, компонуемая с программой клиента. Детально с ней можно познакомиться в архиве проекта, а здесь мы покажем, в качестве основы, только два основных вызова:

    • считывание текущего значения счётчика тактов процессора:
      unsigned long long rdtsc( void ) { 
         unsigned long long int x; 
         asm volatile ( "rdtsc" : "=A" (x) ); 
         return x; 
      } 
    • вычисление частоты процессора, на котором выполняется вызов (для перевода безразмерного числа прошедших тактов в единицы измерения реального времени):
      uint64_t proc_hz( void ) { 
         time_t t1, t2; 
         uint64_t cf, cs; 
         time( &t1 ); 
         while( t1 == time( &t2 ) ) cf  = rdtsc(); 
         while( t2 == time( &t1 ) ) cs  = rdtsc(); 
         return (unsigned long)( cs - cf - calibr( 1000 ) ); 
      } 

    Тактовая частота процессора (не путать с рабочей частотой процессора), даваемая функцией proc_hz(), могла бы быть определена в Linux и статически:

    $ cat /proc/cpuinfo | grep 'model name' 
    model name	: Genuine Intel(R) CPU           T2300  @ 1.66GHz 
    model name	: Genuine Intel(R) CPU           T2300  @ 1.66GHz 
    

    - это то значение (1.66*10E9), которое мы видим последним полем этой строки. Но мы предпочли динамически определять это значение (число тактов, насчитанное на протяжении секундного интервала).

    Варианты серверов

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

    Это самая простая и понятная форма сервера: приняв запрос он его обрабатывает, и до завершения обработки недоступен для новых запросов. Такой сервер интересует нас как эталон для сравнения: он имеет минимальное время реакции, так как не затрачивается время на порождение каких-либо механизмов параллелизма. С другой стороны, такой сервер на практике может быть просто неинтересен, так как не позволяет обслуживать других клиентов до завершения текущего обслуживания. Смотрим код (файл ech0.cc) такого простейшего сервера (код ретранслирующей функции retrans(), единой для всех серверов, уже был показан в составе common.cc):

    #include "common.h" 
    // последовательный ретранслятор тестовых пакетов TCP 
    int main( int argc, char *argv[] ) { 
       int ls = getsocket( SINGLE_PORT ), rs; 
       setv( argc, argv ); 
       while( true ) { 
          if( ( rs = accept( ls, NULL, NULL ) ) < 0 ) errx( "accept error" ); 
          retrans( rs ); 
          close( rs ); 
          if( debug ) cout < "*" < flush; 
       }; 
       exit( EXIT_SUCCESS ); 
    }; 
    

    Для оценки результатов будем выполнять все сервера на хосте локальной сети 192.168.1.5, а тестирующий клиент будем запускать с хоста 192.168.1.5. Что мы имеем в итоге? Вот результаты выполнения клиента с этим сервером:

    - запуск сервера:

    $ sudo nice -n19 ./ech0 
    waiting on port 51000 ... 
    

    - выполнение клиента:

    $ ./cli -a 192.168.1.5 -p 51000 -n 20 
    host: 192.168.1.5, TCP port = 51000, number of echoes = 20 
    time of reply - Cycles [usec.] : 
    181581378[59157]   479803[156] 465566[151] 464991[151] 425580[138] 
    459931[149]	443785[144]	444532[144]	467360[152]	446407[145]
    453376[147]	508208[165]	451375[147]	462139[150]	442612[144]
    477986[155]	456573[148]	470822[153]	443624[144]	465233[151]
    

    Хорошо видно выраженный эффект кэширования (первый запрос), причём выраженный как на стороне клиента, так и на стороне сервера. А в остальном — достаточно устойчивые значения с умеренно низкой дисперсией. В архиве проекта есть ещё один (echo01.cc), чуть модифицированный и более громоздкий, вариант этого же сервера, который дополнен хронометражем собственных затрат (от запроса до ответа):

    #include "common.h" 
    // последовательный ретранслятор тестовых пакетов TCP 
    int main( int argc, char *argv[] ) { 
       int ls, rs, i = 0, cl = calibr( 10000 ); 
       cout < "wait ..." < flush; 
       uint64_t cps = proc_hz(); // cycles per sec. 
       cout < '\r'; 
       ls = getsocket( SINGLE_PORT ); 
       setv( argc, argv ); 
       while( true ) { 
          if( ( rs = accept( ls, NULL, NULL ) ) < 0 ) errx( "accept error" ); 
          uint64_t cycle = rdtsc(); 
          retrans( rs ); 
          cycle = rdtsc() - cycle; 
          cycle -= cl; 
          close( rs ); 
          cout < cycle < "[" < cycle * 1000000 / cps < "]";   // sec. / 10E6 
          if( i++ % 5 == 4 ) cout < endl; else cout < '\t'; cout < flush; 
       }; 
       exit( EXIT_SUCCESS ); 
    }; 

    Вот как это происходит:

    $ sudo nice -n19 ./ech01 
    waiting on port 51000 ... 
    94779209[57010]	47509[28]	30759[18]	39479[23]	37919[22]
    36869[22]	35249[21]	29879[17]	29099[17]	29409[17]
    28059[16]	38139[22]	38909[23]	28749[17]	27439[16]
    28359[17]	28059[16]	28229[16]	27609[16]	38399[23]
    $ ./cli -a 192.168.1.5 -p 51000 -n 20 
    host: 192.168.1.5, TCP port = 51000, number of echoes = 20 
    time of reply - Cycles [usec.] : 
    175292660[57109]	458045[149]	471339[153]	412701[134]	415955[135]
    388539[126]	397957[129]	453100[147]	453066[147]	518639[168]
    445648[145]	454365[148]	409400[133]	461564[150]	444257[144]
    451605[147]	427305[139]	454503[148]	453847[147]	404167[131]
    

    В данном случае сравнение чисел процессорных тактов бессмысленно, потому как они относятся к разным процессорам (клиента и сервера), а вот значения в микросекундах позволяют убедиться, что собственно на ретрансляцию в последовательном сервере затрачивается не более 10% общего времени прохождения запроса и ответа. Здесь это всё наглядно и просто, но такой вариант сервера позволяет анализировать выполнение клиента и сервера на едином хосте через петлевой интерфейс (127.0.0.1), когда клиент и сервер разделяют общее время процессора, и анализ происходящего становится много сложнее.

    Классический параллельный сервер

    Ниже показан (файл ech1.cc) код такого сервера, который несколько десятилетий считался классической реализацией параллельного сервера: когда по поступлению запроса (accept()) порождается (вызовом fork()) новый обслуживающий процесс (клон родительского процесса). Родительский процесс при этом возвращается в режим прослушивания следующих поступающих запросов:

    #include "common.h" 
    // ретранслятор c fork 
    int main( int argc, char *argv[] ) { 
       int ls = getsocket( FORK_PORT ), rs; 
       setv( argc, argv ); 
       while( true ) { 
          if( ( rs = accept( ls, NULL, NULL ) ) < 0 ) errx( "accept error" ); 
          pid_t pid = fork(); 
          if( pid < 0 ) errx( "fork error" ); 
          if( pid == 0 ) { 
             close( ls ); 
             retrans( rs ); 
             close( rs ); 
             if( debug ) cout < "*" < flush; 
             exit( EXIT_SUCCESS ); 
          } 
          else close( rs ); 
       }; 
       exit( EXIT_SUCCESS ); 
    }; 
    

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

    $ sudo nice -n19 ./ech1 
    waiting on port 51001 ... 
    $ ./cli -a 192.168.1.5 -p 51001 -n 20 
    host: 192.168.1.5, TCP port = 51001, number of echoes = 20 
    time of reply - Cycles [usec.] : 
    1535986[500]   1223094[398]    1594498[519]    2174443[708]       1590898[518] 
    1193516[388]    865628[282]    1203671[392]     805564[262]       1141375[371] 
     895183[291]    857498[279]     816719[266]     949854[309]        852196[277] 
    1137442[370]   1131899[368]     988333[321]    1106507[360]       1125275[366] 
    

    Что произошло? Время реакции сервера по сравнению с простым последовательным возросло вдвое или чуть больше. Это плата за fork(), предшествующий началу обработки. Но не будем забывать, что при этом сервер уже во время обработки текущего готов обрабатывать всё новые и новые приходящие запросы!

    Добавим в код сервера одну строку – перед точкой main опишем достаточно большую статическую область данных (файл ech10.cc, и изменён порт):

    static long MEM[ 2500000 ];
    ...
    $ sudo nice -n19 ./ech10 
    waiting on port 51002 ... 
    $ ./cli -a 192.168.1.5 -p 51002 -n 20 
    host: 192.168.1.5, TCP port = 51002, number of echoes = 20 
    time of reply - Cycles [usec.] : 
    1521530[495]   1698090[553]  3454336[1125]	1847440[601]       1657460[539] 
    1082966[352]   1264862[412]    762312[248]	1121848[365]       1152530[375] 
     937986[305]   1149793[374]    864869[281]       838316[273]        830622[270] 
     903727[294]    924036[301]   1116133[363]      1144215[372]       1003777[327] 
    

    Цифры практически не изменились! Это говорит о многом:

    • >при выполнении fork(), в порождённом процессе должна быть создана копия адресного пространства памяти родительского, в том числе, и статической области в 10Mb...
    • и копирование производится, кроме того, не простейшими функциями типа memcpy(), а копированием между различными защищёнными адресными пространствами;
    • но в данном случае работает техника COW («copy on write»), при которой реально страницы памяти будут дублированы и копироваться только при внесении в них изменений.

    Для сравнения: в операционной системе реального времени QNX, которая не имеет права на отсроченные операции типа «copy on write» (требование детерминированности поведения) добавление такой строки в код увеличивало время реакции в 50 раз! Но не нужно упускать из виду и то, что такие временные затраты будут затребованы и в Linux (с COW) при первейших операциях записи в такие области данных, только произойдёт это в непредсказуемые моменты времени в будущем (в этом и есть не детерминированность).

    Сервер с предварительным созданием копий процесса

    Так что же получается: для серверов, работающих на высоко интенсивных потоках запросов, с традиционным fork-методом всё так плохо? Отнюдь! Нужно только поменять вызовы fork() и accept() местами – создать заранее некоторый пул обслуживающих процессов, каждый из которых до прихода клиентского запроса будет заблокирован на accept() (кстати, все accept() на одном и том же прослушиваемом сокете, что не предусмотрено спецификацией, но работает!). А после отработки клиентского запроса заблаговременно создать новый обслуживающий процесс. Эта техника известна как «предварительный fork» или pre-fork. Меняем текст сервера (файл ech11.cc):

    #include "common.h" 
    #include <sys/wait.h> 
    const int NUMPROC = 3; 
    // ретранслятор c предварительным fork (pefork) 
    int main( int argc, char *argv[] ) { 
       int ls = getsocket( PREFORK_PORT ), rs; 
       setv( argc, argv ); 
       for( int i = 0; i < NUMPROC; i++ ) { 
          if( fork() == 0 ) { 
             int rs; 
             while( true ) { 
                if( ( rs = accept( ls, NULL, NULL ) ) < 0 ) errx( "accept error" ); 
                retrans( rs ); 
                close( rs ); 
                if( debug ) cout << i << flush; 
                delay( 250 ); // пауза 250 usec. 
             }; 
          }; 
       }; 
       for( int i = 0; i < NUMPROC; i++ ) wait( NULL ); 
       exit( EXIT_SUCCESS ); 
    }; 

    Результаты:

    $ sudo nice -n19 ./ech11 -v 
    waiting on port 51003 ... 
    verbose mode 
    00210210210210210210
    
    $ ./cli -a 192.168.1.5 -p 51003 -n 20 
    host: 192.168.1.5, TCP port = 51003, number of echoes = 20 
    time of reply - Cycles [usec.] : 
    555370[180]	542984[176]	539994[175]	451536[147]	456090[148]
    446096[145]	470856[153]	448753[146]	451605[147]	473512[154]
    491579[160]	463611[151]	523503[170]	459356[149]	476031[155]
    470672[153]	464738[151]	447155[145]	460655[150]	445211[145]
    

    Очень неплохо! Время реакции приближается к последовательному серверу, но сохраняется параллельность, привносимая fork().

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

    $ ps -A | grep ech 
    10783 pts/15   00:00:00 ech11 
    10784 pts/15   00:00:00 ech11 
    10785 pts/15   00:00:00 ech11 
    10786 pts/15   00:00:00 ech11 
    

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

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

    Сервер активируемый суперсервером (xinetd)

    Прежде, чем переходить к потоковым реализациям, рассмотрим ещё один вариант этого класса, но в котором создание процесса fork() выполняется неявно: использование суперсервера xinetd. При этом весь сервис по запуску процессов-копий нашего приложения, и перенаправлению его стандартных потоков ввода-вывода в сетевой сокет возьмёт на себя xinetd. Вот полный текст ретранслирующего сервера для этого случая (файл ech3.cс):

    #include "common.h"
    static char data[ MAXLINE ];
    
    int main( int argc, char *argv[] ) { 
       write( STDOUT_FILENO, data, read( STDIN_FILENO, data, MAXLINE ) ); 
       exit( EXIT_SUCCESS ); 
    }; 
    

    Просто? Мне кажется, что – очень. Но в расплату за простоту кода нам придётся повозиться с конфигурациями xinetd:

    • Дописываем в конфигурационный файл /etc/services строку, определяющую порт, через который будет вызываться приложение этого сервера:
      ech3            51004/tcp
    • В каталоге конфигураций /etc/xinetd.d создадим файл конфигурации для этого сервера (имя файла значения не имеет, но для однозначности называем его ech3), заполняем его содержимое:
      $ cat /etc/xinetd.d/ech3 
      service ech3 
      { 
          disable = no 
          protocol = tcp 
          wait = no 
          user = olej 
          server = /home/olej/ech3 
      } 

      Здесь: server — абсолютное путевое имя файла сервера, куда мы его поместили; user — имя пользователя, от имени которого будет выполняться сервер после старта.

    • Суперсервер выполняется как сервис Linux. Запускаем или перезапускаем xinetd:
      # /etc/init.d/xinetd restart
      Restarting xinetd (via systemctl):  [ OK ]
      # ps -A | grep xinetd
      12507 ?  00:00:00 xinetd
    • Если после очередной правки конфигураций (показанных выше) нам нужно заставить xinetd перечитать новые конфигурации, то делаем это так:
      # kill -SIGHUP 12507
    • Проверяем правильность конфигураций и запуск по запросу нашего сервера ech3, например, локальным выполнением:
      $ telnet 127.0.0.1 51004 
      Trying 127.0.0.1... 
      Connected to 127.0.0.1. 
      Escape character is '^]'. 
      123 
      123 
      Connection closed by foreign host. 
      

      Или так:

      $  netstat -a | grep ech 
      tcp        0      0 *:echo                     *:*                         LISTEN      
      tcp        0      0 *:ech3                     *:*                         LISTEN      
      udp        0      0 *:echo                     *:*                                     
      

    Теперь всё готово для проверки характеристик сервера:

    $ ./cli -a 192.168.1.5 -p 51004 -n 20 
    host: 192.168.1.5, TCP port = 51004, number of echoes = 20 
    time of reply - Cycles [usec.] : 
    12308646[4010] 11768571[3834]  11953054[3894]  13832637[4506]  12359648[4026] 
    11718109[3817] 13003061[4236]  14025745[4569]  12321905[4014]  11671661[3802] 
    11614506[3783] 11515571[3751]  11477414[3739]  11517457[3752]  15932227[5190] 
    11998065[3908]  7625133[2484]   7460694[2430]   7524047[2451]   8133041[2649] 
    

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

    Параллельный сервер, создающий потоки по запросам

    Строим параллельный сервер (файл ech2.cc), но вместо параллельных клонов процессов теперь будем порождать параллельные потоки в том же адресном пространстве:

    #include <pthread.h> 
    #include "common.h" 
    void* echo( void* ps ) { 
        int sc = *(int*)ps; 
        sched_yield(); 
        retrans( sc ); 
        close( sc ); 
        if( debug ) cout < "*" < flush; 
        return NULL; 
    } 
    // ретранслятор c pthread_create по запросу 
    int main( int argc, char *argv[] ) { 
       int ls = getsocket( THREAD_PORT ), rs; 
       setv( argc, argv ); 
       while( true ) { 
          pthread_t tid; 
          if( ( rs = accept( ls, NULL, NULL ) ) < 0 ) errx( "accept error" ); 
          if( pthread_create( &tid, NULL, &echo, &rs ) != EOK ) errx( "thread create error" ); 
          sched_yield(); 
       }; 
       exit( EXIT_SUCCESS ); 
    };

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

    Результаты выполнения такого варианта программы:

    $ sudo nice -n19 ./ech2 
    waiting on port 51005 ... 
    
    $ ./cli -a 192.168.1.5 -p 51005 -n 20 
    host: 192.168.1.5, TCP port = 51005, number of echoes = 20 
    time of reply - Cycles [usec.] : 
    196535150[64030]		742613[241]	676556[220]	647013[210]	563259[183]
    658019[214]	657996[214]	642321[209]	551816[179]	653361[212]
    661595[215]	649739[211]	644609[210]	711723[231]	637951[207]
    651682[212]	647036[210]	663274[216]	726743[236]	625404[203]
    

    Это хорошие устойчивые результаты, промежуточные между последовательным сервером и параллельным сервером с fork(), что и следовало ожидать. Чрезвычайно сильно выражен эффект кэширования – вся обработка последовательности запросов производится на едином (многократно используемом) пространстве адресов.

    Сервер с предварительным созданием потоков

    В точности так же, как мы это делали с предварительным созданием клона процесса (ech11.cc), мы можем создать сервер (файл ech22.cc), который будет предварительно создавать новый поток, который будет заблокирован на accept() в ожидании запроса на обслуживание. Мы, фактически, поменяли местами вызовы pthread_create() и accept() в предыдущей схеме. Текст такого сервера:

    #include <pthread.h> 
    #include "common.h" 
    static int ntr = 3; 
    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; 
    static pthread_cond_t condvar = PTHREAD_COND_INITIALIZER; 
    void* echo( void* ps ) { 
        int sc = *(int*)ps, rs; 
        sched_yield(); 
        if( ( rs = accept( sc, NULL, NULL ) ) < 0 ) errx( "accept error" ); 
        retrans( rs ); 
        close( rs ); 
        pthread_mutex_lock( &mutex ); 
        ntr++; 
        pthread_cond_signal( &condvar ); 
        pthread_mutex_unlock( &mutex ); 
        if( debug ) cout < pthread_self() < '.' < flush; 
        delay( 250 );  // пауза 250 usec. 
        return NULL; 
    } 
    // ретранслятор c предварительным pthread_create() 
    int main( int argc, char *argv[] ) { 
       int ls = getsocket( PRETHREAD_PORT ), rs; 
       setv( argc, argv ); 
       while( true ) { 
          pthread_t tid; 
          if( pthread_create( &tid, NULL, &echo, &ls ) != EOK ) errx( "thread create error" ); 
          sched_yield(); 
          pthread_mutex_lock( &mutex ); 
          ntr--; 
          while( ntr <= 0 ) pthread_cond_wait( &condvar, &mutex ); 
          pthread_mutex_unlock( &mutex ); 
       }; 
       exit( EXIT_SUCCESS ); 
    }; 
    

    Здесь accept() перенесен в обрабатывающий поток (все созданные потоки блокированы в accept() на единственном прослушивающем сокете). Какой конкретно из потоков будет разблокирован для обработки при получении запроса — непредсказуемо! Для синхронизации я использую условную переменную, но могут применяться любые из синхронизирующих примитивов. Испытываем полученную программу:

    $ sudo nice -n19 ./ech22 -v 
    waiting on port 51007 ... 
    verbose mode 
    3074657136.3066264432.3057871728.3049479024.3041086320.3032693616.3024300912.
    3015908208.3007515504.2999122800.2990730096.2982337392.2973944688.2965551984.
    2957159280.2948766576.2940373872.2931981168.2923588464.2915195760.
    
    $ ./cli -a 192.168.1.5 -p 51007 -n 20 
    host: 192.168.1.5, TCP port = 51007, number of echoes = 20 
    time of reply - Cycles [usec.] : 
    2558382[833]	432722[140]	485587[158]	461599[150]	483759[157]
    457838[149]	466141[151]	458367[149]	462622[150]	541121[176]
    461081[150]	570228[185]	457619[149]	450570[146]	458977[149]
    440369[143]	461898[150]	467740[152]	474179[154]	462726[150]
    

    Время реакции очень близко к последовательному серверу (к минимально достижимой потенциально асимптоте, в смысле времени получения ответа!). Потоки обработчики на сервере идентифицируют себя своим pthread_t: 3074657136.3066264432... Хорошо видно как последовательно порождаются новые потоки для обработки каждого запроса клиента. Так же сильно (но не настолько, как и в предыдущем случае) выражены эффекты кэширования.

    Оптимальное управление потоками (пул)

    Различные модели серверов отрабатываются в расчёте на обслуживание интенсивного потока (десятки и сотни одновременно) обслуживания со сложным, непредсказуемым характером изменения интенсивности по времени, часто с взрывным характером. Это тот класс задач, который часто называют: сервера массового обслуживания. Если сервер должен обслуживать достаточно равномерный по интенсивности поток, пусть это даже будет и весьма высокая интенсивность, то здесь вполне достаточным решением был бы классический параллельный сервер (на fork(), или на pthread_create()). Но если рассчитывать на взрывообразные всплески интенсивности запросов, то непосредственное создание обрабатывающего потока (процесса) либо вслед за accept(), либо перед ним — не будет оптимальным. Оптимальным было бы создание заблаговременно некоторого «запаса» обрабатывающих ветвей (процессов, потоков), которые готовы были бы немедленно включаться в обработку при поступлении запроса. Такие конструкции хорошо известны и называются «пул», причём имеют место быть несколько их видов:

    1. Фиксированный пул — когда можно прогнозировать максимальное число единовременно поступающих запросов, и под это число создаётся равное фиксированное число ветвей обработки. При поступлении запросов обрабатывающие ветви буду черпаться из этого пула циклически, а после завершения обработчик будет возвращаться в пул. Пример такого (фиксированного пула процессов) у нас уже встречался (файл ecH21.cc — 3 процесса в пуле), но там мы не стали акцентировать на этом внимания, поскольку речь шла о другом.
    2. Динамический пул — когда ветви обработчика динамически создаются в пул (и уничтожаются из него) так, чтобы в пуле всегда сохранялось некоторое рациональное число обработчиков, готовых немедленно вступить в обработку. В API операционной системы реального времени QNX 6.X, например, реализована встроенная модель такой конструкции, заимствованная из рекомендаций одного из расширений реального времени стандарта POSIX: типы данных thread_pool_attr_t и THREAD_POOL_PARAM_T, функциональные вызовы thread_pool_create() и thread_pool_start() (для наглядности — как это выглядит — файл работающего примера ecH31.cc оставлен в архиве, но это не работает в Linux). Алгоритмика такого пула достаточно остроумна:
      • обработчики создаются и уничтожаются в пул за один раз не поодиночке, а пачками, для определённости по M (например: 3, 5) штук;
      • устанавливаются параметры пула: L - «нижняя ватерлиния» (например: 5) и H — верхняя ватерлиния (например: 11);
      • первоначально в пул создаётся (по M штук) такое число обработчиков (L%M+1), чтобы оно минимально превосходило L (здесь % - операция целочисленного деления);
      • если при массированном поступлении запросов число свободных обработчиков в пуле становится меньше L, то создаётся дополнительно (пачками по M) некоторое их число, так, чтобы в итоге, число свободных превышало L;
      • при завершении обработки текущий обработчик не уничтожается, а возвращается в пул ... до тех пор, пока число свободных обработчиков в пуле не превысит H;
      • в последнем случае обработчики начинают ликвидироваться (пачками по M), так, чтобы число свободных стало меньше H;
      • таким образом постоянно (за вычетом редких переходных процессов) поддерживается число свободных обработчиков в пуле превышающее L, но не превосходящее H.

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

    Последовательный сервер с очередью обслуживания

    Уже после опубликования первоначальной статьи, читатели неоднократно указывали мне в письмах, что при акцентировании на параллельных вариантах серверов из рассмотрения опущен другой класс, который при определённых условиях может оказаться оптимальнее параллельных. Это: последовательные сервера с очередью обслуживания. Рассмотрим коротко и их (здесь нам существенно придёт на помощь то, что мы реализуем примеры в C++ и сможем не возиться с ручными реализациями очередей с C, а использовать шаблоны STL). Первый пример (ech4.cc) в высшей степени неэффективный, но прозрачен и прост в понимании идеи:

    #include "common.h" 
    #include <pthread.h> 
    #include <queue> 
    static queue<int> events; 
    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; 
    void* reply( void* ) { 
       int rsq; 
       while( true ) { 
          if( events.empty() ) continue; 
          pthread_mutex_lock( &mutex ); 
          rsq = events.front(); 
          events.pop(); 
          if( debug ) cout << '-' << rsq << '.' << flush; 
          pthread_mutex_unlock( &mutex ); 
          retrans( rsq ); 
          close( rsq ); 
       }; 
    } 
    // последовательный ретранслятор c очередью обслуживания 
    int main( int argc, char *argv[] ) { 
       int ls = getsocket( QUEUE_PORT ); 
       setv( argc, argv ); 
       pthread_t tid; 
       if( pthread_create( &tid, NULL, &reply, NULL ) != EOK ) 
          errx( "thread create error" ); 
       while( true ) { 
          int rs = accept( ls, NULL, NULL ); 
          if( rs < 0 ) errx( "accept error" ); 
          pthread_mutex_lock( &mutex ); 
          events.push( rs ); 
          if( debug ) cout << '+' << rs << '.' << flush; 
          pthread_mutex_unlock( &mutex ); 
       }; 
       exit( EXIT_SUCCESS ); 
    }; 

    Здесь два потока:

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

    Вот как это работает:

    $ sudo nice -n19 ./ech4 
    waiting on port 51008 ... 
    
    $ ./cli -a 192.168.1.5 -p 51008 -n 20 
    host: 192.168.1.5, TCP port = 51008, number of echoes = 20 
    time of reply - Cycles [usec.] : 
    727605[237]     384249[125]    389930[127]     367517[119]     365907[119] 
    4467486[1455]   448396[146]    388366[126]     363228[118]     357719[116] 
    410907[133]     383594[124]    459195[149]     383444[124]     369783[120] 
    358478[116]     367218[119]    437609[142]     382846[124]     359065[116] 
    

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

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

    #include "common.h" 
    #include <pthread.h> 
    #include <queue> 
    static queue<int> events; 
    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; 
    static pthread_cond_t condvar = PTHREAD_COND_INITIALIZER; 
     void* reply( void* ) { 
       int rsq; 
       while( true ) { 
          if( events.empty() ) 
             pthread_cond_wait( &condvar, &mutex ); 
          rsq = events.front(); 
          events.pop(); 
          if( debug ) cout < '-' < rsq < '.' < flush; 
          pthread_mutex_unlock( &mutex ); 
          retrans( rsq ); 
          close( rsq ); 
       }; 
    } 
    // последовательный ретранслятор c очередью обслуживания 
    int main( int argc, char *argv[] ) { 
       int ls = getsocket( QUEUE_PORT ); 
       setv( argc, argv ); 
       pthread_t tid; 
       if( pthread_create( &tid, NULL, &reply, NULL ) != EOK ) 
          errx( "thread create error" ); 
       sched_yield();  // дать выбирающему потоку заблокироваться   
       while( true ) { 
          int rs = accept( ls, NULL, NULL ); 
          if( rs < 0 ) errx( "accept error" ); 
          pthread_mutex_lock( &mutex ); 
          events.push( rs ); 
          pthread_cond_signal( &condvar ); // сообщать о наличии работы 
          if( debug ) cout < '+' < rs < '.' < flush; 
          pthread_mutex_unlock( &mutex ); 
       }; 
       exit( EXIT_SUCCESS ); 
    };   

    И его выполнение:

    $ sudo nice -n19 ./ech41 
    waiting on port 51008 ... 
    
    $ ./cli -a 192.168.1.5 -p 51008 -n 20 
    host: 192.168.1.5, TCP port = 51008, number of echoes = 20 
    time of reply - Cycles [usec.] : 
    607372[197]     550517[179]    578301[188]     540316[176]     502585[163] 
    506989[165]     507829[165]    445947[145]     454687[148]     603485[196] 
    558521[181]     528724[172]    523089[170]     538304[175]     555197[180] 
    524572[170]     535394[174]    534911[174]     569871[185]     506437[164] 
    

    Теперь разброс задержки ответа существенно снизился, что и следовало ожидать.

    В качестве итогов...

    Выше было показано семь видов различных альтернативных технологий построения сервера TCP/IP, а с учётом обсуждаемых вариаций и высказанных соображений — и того больше. Приведены сравнительные характеристики по задержке времени получения ответа (время реакции). Но вот вопрос: могут ли эти или подобные им результаты служить критерием разграничения: вот эта техника сервера хороша, а вот та — плоха? Ни в коем случае! Потому, что работу (и возможности) сервера нужно оценивать по нескольким параметрам, которые независимы (или слабо коррелированы между собой):

    • время реакции;
    • максимальное число обслуженных запросов в секунду (интенсивность);
    • число отказов обслуживания, и на какой интенсивности они начинают возникать;

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

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

    [1]. Журнал «Современные Технологии Автоматизации», Москва, http://www.cta.ru/online/online_progr-nets.htm

    [2]. Д.Алексеев, А.Волков, Е.Горошко, М.Горчак, Р.Жавнис, Д.Сошин, О.Цилюрик, А.Чиликин, «Практика работы с QNX», М.: «КомБук», 2004, 432 стр., ISBN 5-94740-009-X.


    P.S. Все упоминаемые в тексте фрагменты программного кода, сценарий сборки Makefile и всё необходимое сборки примеров — содержатся в составе прилагаемого архива: xservers.5.tgz.

    Замечания по тексту статьи вы можете высказать на форуме нашего сайта.