Эффективное программирование TCP-IP

       

Помните, что TCP - потоковый протокол


| | |

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

Представим, например, что имеется TCP-соединение между приложения на хостах А и В. Приложение на хосте А посылает сообщения хосту В. Допустим, что у хоста А есть два сообщения, для отправки которых он дважды вызывает send - по разу для каждого сообщения. Естественно, эти сообщения передаются от хоста А к хосту В в виде раздельных блоков, каждое в своем пакете, как показано на рис. 2.13.

К сожалению, реальная передача данных вероятнее всего будет происходить, не так. Приложение на хосте А вызывает send, и вроде бы данные сразу же передаются на хост В. На самом деле send обычно просто копирует данные в буфер стека TCP/IP на хосте А и тут же возвращает управление. TCP самостоятельно определяет, сколько данных нужно передать немедленно. В частности, он может вообще отложить передачу до более благоприятного момента. Принятие такого решения зависит от многих факторов, например: окна передачи (объем данных, которые хост В готов принять), окна перегрузки (оценка загруженности сети), максимального размера передаваемого блока вдоль пути (максимально допустимый объем данных для передачи в одном блоке на пути от А к В) и количества данных в выходной очереди соединения. Подробнее это рассматривается в совете 15. На рис. 2.14 показано только четыре возможных способа разбиения двух сообщений по пакетам. Здесь М11 и М12 - первая и вторая части сообщения М1, а М21 и М22 - соответственно части М2. Как видно из рисунка, TCP не всегда посылает все сообщение в одном пакете.

Рис. 2.13. Неправильная модель отправки двух сообщений

Рис.2.14. Четыре возможных способа разбиения двух сообщений по пакетам

А теперь посмотрим на эту ситуацию с точки зрения приложения на хосте В. В общем случае оно не имеет информации относительно количества возвращаемых TCP данных при обращении к системному вызову recv. Например, когда приложение на хосте В в первый раз читает данные, возможны следующие варианты:


Так как количество возвращаемых в результате чтения данных непредсказуемо, вы должны быть готовы к обработке этой ситуации. Часто проблемы вообще не возникает. Допустим, вы пользуетесь для чтения данных стандартной библиотечной функцией fgets. При этом она сама будет разбивать поток байтов на строки (листинг 3.3). Иногда границы сообщений бывают важны, тогда приходится реализовывать их сохранение на прикладном уровне.
Самый простой случай - это сообщения фиксированной длины. Тогда вам нужно прочесть заранее известное число байтов из потока. В соответствии с вышесказанным, для этого недостаточно выполнить простое однократное чтение:
recv( s, msg, sizeof( msg ), 0 );


поскольку при этом можно получить меньше, чем sizеоf ( msg ) байт (рис. 2.14г). Стандартный способ решения этой проблемы показан в листинге 2.12
Листинг 2.12. Функция readn
readn.с
1    int readn( SOCKET fd, char *bp, size_t len)
2    {
3    int cnt;
4    int rc;
5    cnt = len;
6    while ( cnt > 0 )
7    {
8      rc = recv( fd, bp, cnt, 0 );
9      if ( rc < 0 ) /* Ошибка чтения? */
10     {
11      if ( errno == EINTR )  /* Вызов прерван? */
12       continue; /* Повторить чтение. */
13      return -1; /* Вернуть код ошибки. */
14     }
15     if ( rc == 0 ) /* Конец файла? */
16      return len - cnt; /* Вернуть неполный счетчик. */
17     bр += гс;
18     cnt -= rc;
19   }
20   return len;
21   }
Функция readn используется точно так же, как read, только она не возвращает управления, пока не будет прочитано len байт или не получен конец файла или не возникнет ошибка. Ее прототип выглядит следующим образом:
#include «etcp.h»
int readn ( SOCKET s, char *buf, size t len );
Возвращаемое значение: число прочитанных байтов или -1 в случае ошибки.
Неудивительно, что readn использует ту же технику для чтения заданного числа байтов из последовального порта или иного потокового устройства, когда количество данных, доступных в данный момент времени, неизвестно. Обычно readn (с заменой типа SOCKET на int и recv на read) применяется во всех этих ситуациях.


Оператор if
if ( errno == EINTR )
 continue;
в строках 11 и 12 возобновляет выполнение вызова recv, если он прерван сигналом. Некоторые системы возобновляют прерванные системные вызовы автоматически, в таком случае эти две строки не нужны. С другой стороны, они не мешают, так что для обеспечения максимальной переносимости лучше их оставить.
Если приложение должно работать с сообщениями переменной длины то в вашем распоряжении есть два метода. Во-первых, можно разделять записи специальными маркерами. Именно так надо поступить, используя стандартную функцию fgets для разбиения потока на строки. В этом случае естественным разделителем служит символ новой строки. Если маркер конца записи встретится в теле сообщения, то приложение-отправитель должно предварительно найти в сообщении все такие маркеры и экранировать их либо закодировать как-то еще чтобы принимающее приложение не приняло их по ошибке за конец записи. Например если в качестве признака конца записи используется символ-разделитель RS то отправитель сначала должен найти все вхождения этого символа в тело сообщения и экранировать их, например, добавив перед каждым символ \ Это означает, что данные необходимо сдвинуть вправо, чтобы освободить место для символа экранирования. Его, разумеется, тоже необходимо экранировать. Так, если для экранирования используется символ \, то любое его вхождение в тело сообщения следует заменить на \\.

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


Листинг 2.13. Функция для чтения записи переменной длины
1    int readvrec( SOCKET fd, char *bp, size_t len )
2    {
3    u_int32_t reclen;
4    int rc;
5    /* Прочитать длину записи. */
6    rc = readn( fd, ( char * )&reclen, sizeof( u_int32_t ) );
7    if ( rc != sizeof( u_int32_t ) )
8      return rc < 0 ? -1 : 0;
9    reclen = ntohl( reclen );
10   if ( reclen > len )
11   {
12     /*
13     * He хватает места в буфере для•размещения данных
14     * отбросить их и вернуть код ошибки.
15     */
16     while ( reclen > 0 )
17     {
18      rc = readn( fd, bp, len );
19      if ( rc != len )
20       return rc < 0 ? -1 : 0;
21      reclen -= len;
22      if ( reclen < len }
23       len = reclen;
24     }
25     set_errno( EMSGSIZE };
26     return -1;
27   }
28   /* Прочитать саму запись */
29   rc = readn( fd, bp, reclen );
30   if ( rc != reclen )
31     return rc < 0 ? -1 : 0;
32   return rc;
33   }
Чтение длины записи
6-8 Длина записи считывается в переменную reclen. Функция readvrec возвращает 0 (конец файла), если число байтов, прочитанных readn, не точно совпадает с размером целого, или -1 в случае ошибки. 1
9 Размер записи преобразуется из сетевого порядка в машинный. Подробнее об этом рассказывается в совете 28.
Проверка того, поместится ли запись в буфер
10-27 Проверяется, достаточна ли длина буфера, предоставленного вызывающей программой, для размещения в нем всей записи. Если места не хватит, то данные считываются в буфер частями по 1en байт, то есть, по сути, отбрасываются. Изъяв из потока отбрасываемые данные, функции присваивает переменной errno значение EMSGSIZE и возвращает -1.
Считывание записи
29-32 Наконец считывается сама запись, readvrec возвращает-1, 0 или reclen в зависимости от того, вернула ли readn код ошибки, неполный счетчик или нормальное значение.
Поскольку readvrec - функция полезная и ей найдется применение, необходимо записать ее прототип:
#include "etcp.h"


int readvrec( SOCKET s, char *buf, size_t len );
Возвращаемое значение: число прочитанных байтов или -1.
В листинге 2.14 дан пример простого сервера, который читает из ТСР-соединения записи переменной длины с помощью readvrec и записывает их на стандартный вывод.
Листинг 2.14. vrs - сервер, демонстрирующие применение функции readvrec
1    #include "etcp.h"
2    int main( int argc, char **argv )
3    {
4    struct sockaddr_in peer;
5    SOCKET s;
6    SOCKET s1;
7    int peerlen = sizeof( peer );
8    int n;
9    char buf[ 10 ] ;
10   INITO;
11   if ( argc == 2 )
12   s = tcp_server( NULL, argv[ 1 ] );
13   else
14   s = tcp_server( argv[ 1 ], argv[ 2 ] );
15   s1 = accept( s, ( struct sockaddr * )&peer, &peerlen );
16   if ( !isvalidsock( s1 ) )
17   error( 1, errno, "ошибка вызова accept" );
18   for ( ; ; )
19   {
20   n = readvrec( si, buf, sizeof ( buf ) );
21   if ( n < 0 )
22   error( 0, errno, "readvrec вернула код ошибки" );
23   else if ( n == 0 )
24   error( 1, 0, "клиент отключился\п" );
25   else
26   write( 1, buf, n );
27   }
28   EXIT( 0 ); /* Сюда не попадаем. */
29   }
10-17 Инициализируем сервер и принимаем только одно соединение.
20-24 Вызываем readvrec для чтения очередной записи переменной длины. Если произошла ошибка, то печатается диагностическое сообщение и читается следующая запись. Если readvrec возвращает EOF, то печатается сообщение и работа завершается.
26 Выводим записи на stdout.
В листинге 2.15 приведен соответствующий клиент, который читает сообщения из стандартного ввода, добавляет заголовок с длиной сообщения и посылает все это серверу.
Листинг 2.15. vrc - клиент, посылающий записи переменной длины
1    #include "etcp.h"
2    int main( int argc, char **argv )
3    {
4    SOCKET s;
5    int n;
6    struct
7    {
8      u_int32_t reclen;
9      char buf [ 128 ];
10   } packet;
11   INIT();
12   s = tcp_client( argvf 1 ], argv[ 2 ] );


13   while ( fgets( packet.buf, sizeof( packet.buf ), stdin )
14     != NULL )
15   {
16     n = strlen( packet.buf );
1'7    packet .reclen = htonl ( n ) ;
18     if ( send( s, ( char * }&packet,
19      n + sizeof( packet.reclen ), 0 ) < 0 )
20      error ( 1, errno, "ошибка вызова send" );
21   }
22   EXIT( 0 );
23   }
Определение структуры packet
6-10 Определяем структуру packet, в которую будем помещать сообщение и его длину перед вызовом send. Тип данных u_int32_t - это беззнаковое 32-разрядное целое. Поскольку в Windows такого типа нет, в версии заголовочного файла skel.h для Windows приведено соответству­ющее определение типа.
Примечание: В этом примере есть одна потенциальная проблема, о которой следует знать. Предположим, что компилятор упаковывав данные в структуру, не добавляя никаких символов заполнения. Поскольку второй элемент — это массив байтов, в большинстве систем это предположение выполняется, но всегда нужно помнить о возможной недостоверности допущений о способе упаковки данных в структуру компилятором. Об этом будет рассказано в совете 24 при обсуждении способов для отправки нескольких элементов информации одновременно.
Connect, read и send
6-10 Клиент соединяется с сервером, вызывая функцию tcp_client.
13-21 Вызывается f get s для чтения строки из стандартного ввода. Эта строка помещается в пакет сообщения. С помощью функции strlen определяется длина строки. Полученное значение преобразуется в сетевой порядок байтов и помещается в поле reclen пакета. В конце вызывается send для отправки пакета серверу.
Другой способ отправки сообщений, состоящих из нескольких частей, рассматривается в совете 24.
Протестируем эти программы, запустив сервер на машине sparc, а клиент - на машине bsd. Поскольку результаты показаны рядом, видно, что поступает на вход клиенту и что печатает сервер. Чтобы сообщение строки 4 уместилось на стра­нице, оно разбито на две строчки.

bsd: $ vrc spare 8050
123
123456789
1234567890
12
^C
spare: $ vrs 8050
123
123456789
vrs: readvrec  вернула код ошибки:
     Message too long (97)
12
vrs: клиент отключился

Поскольку длина буфера сервера равна 10 байт, функция readvrec возвращает код ошибки, когда отправляется 11байт 1,..., 0,<LF>.

Содержание раздела