Гнезда Internet

Одним из важных применений интерфейса «гнезд» является, пожалуй, построение приложений Internet, что мы и рассмотрим в данной лекции.

Создание гнезда Internet

Для создания гнезда Internet используется уже упоминавшаяся функция socket, первым аргументом которой передается константа (определение препроцессора) AF_INET6 или AF_INET.

Использование двух констант связано с тем, что в современном Internet параллельно используются две версии протокола сетевого уровня IP и, следовательно, два вида адресов:

Следует отметить, что в зависимости от системы и ее настроек может быть возможно использовать гнезда AF_INET6 для работы как с новой, так и со старой версиями протокола. В этом случае традиционные адреса IPv4 отображаются в адресное пространство IPv6 следующим образом: 192.0.2.34::ffff:192.0.2.34 (= 0:0:0:0:0:ffff:c000:0222.)

Вторым аргументом функции указывается тип предоставляемого сервиса. Двумя наиболее важными значениями являются следующие.

SOCK_STREAM

«Октетный поток» — этот сервис во многом подобен ранее рассмотренным «файловым» средствам межпроцессного взаимодействия — каналам и (в большей степени) псевдотерминалам: приложение получает двунаправленный надежный упорядоченный октет-ориентированный канал связи с удаленным процессом.

Обязательства по делению потока на подходящие для передачи по сети фрагменты (пакеты) берет на себя система; при потере данных — она же повторно их пересылает. Если данные приходят с нарушением порядка — порядок также восстанавливает система.

SOCK_DGRAM

«Дейтаграммы» — в некотором смысле, полная противоположность SOCK_STREAM: приложение вновь получает двунаправленный канал связи, но обязанности по делению данных на пакеты, по обнаружению потерь и повторной пересылке, по отслеживанию нарушения порядка (если необходимо) — остаются за приложением.

Основной недостаток SOCK_STREAM — неизбежные задержки: при потере данных система будет повторять попытку отправки до тех пор, пока их получение не будет подтверждено получателем; новые данные при этом передаваться не будут. Такое поведение недопустимо для некоторых приложений; например, при передачи голоса (VoIP) или видео в реальном времени лучше допустить искажение сигнала, нежели задержку. (Едва ли при телефонном разговоре будет удобно получить качественную передачу фразы собеседника — задержанную минут на десять.)

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

Так, например, для сообщений протокола HTTP/1.1 предусмотрено использование или поля Content-Length: — длина тела сообщения, или «кусочной» передачи (англ. chunked encoding), при которой тело, длина которого заведомо неизвестна, делится на фрагменты, перед каждым из которых тоже указывается его длина.

Наконец, третьим аргументом socket может быть 0 — в случае чего транспортный протокол, подходящий для запрошенного типа сервиса, будет выбран системой автоматически. На практике это означает, что для SOCK_STREAM будет выбран протокол TCP; для SOCK_DGRAMUDP.

Пример: создание гнезда Internet (IPv6) функцией socket.
   int so
     = socket (AF_INET6, SOCK_STREAM, 0);
   assert (so >= 0);

Подключение к серверу

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

При использовании дейтаграммных протоколов функция connect устанавливает адрес для отправки данных по умолчанию. Приложение может отправлять данные на другие адреса, указывая их явно аргументом функции sendto.

Прежде чем использовать эту функцию, однако, как правило нужно найти транспортный адрес удаленного процесса. Обычно исходной информацией для подключения является имя хоста (например: example.com) и имя сетевого сервиса (приложения; например: http.) Найти необходимую для вызова connect информацию по этим данным позволяет функция getaddrinfo.

Отметим, что функция getaddrinfo возвращает, в числе прочего, и значения аргументов функции socket, которые можно использовать для создания подходящего для установления соединения с сервером гнезда.

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

Поскольку после успешного завершения connect становится возможно использовать для передачи данных обычные функции read, write, здесь же рассмотрим отправку запроса и получение ответа сервера.

Пример: установление соединения с http://example.com/, отправка запроса и получение ответа.
#include <assert.h>
#include <netdb.h>              /* for getaddrinfo */
#include <stdio.h>              /* for fwrite */
#include <sys/socket.h>
#include <unistd.h>             /* for read, write */

#ifndef HTTP_HOST
#define HTTP_HOST   "example.com"
#endif

int
main ()
{
  /** Определяем способ подключения к серверу */
  struct addrinfo *ai;
  {
    int err
      = getaddrinfo (HTTP_HOST, "http", 0, &ai);
    assert (err == 0);
  }

  /** FIXME: строго говоря, ниже должен быть цикл, переходящий от ai к ai->next в случае, если попытка соединения — не удалась. */

  /** Создаем гнездо */
  int so
    = socket (ai->ai_family, ai->ai_socktype, ai->ai_protocol);
  assert (so >= 0);

  /** Устанавливаем соединение */
  {
    int r
      = connect (so, ai->ai_addr, ai->ai_addrlen);
    assert (r >= 0);
  }

  /** Отправляем запрос */
  {
    const char req[]
      =  ("HEAD / HTTP/1.1\r\n"
          "Host: " HTTP_HOST "\r\n"
          "Connection: close\r\n"
          "\r\n");
    const char *tail;
    size_t left;
    for (tail = req, left = sizeof (req); left > 0; ) {
      ssize_t wr
        = write (so, tail, left);
      assert (wr > 0);
      assert (wr <= left);
      tail
        += wr;
      left
        -= wr;
    }
  }

  /** Получаем ответ и выводим его на стандартный вывод */
  {
    char buf[4096];
    while (1) {
      /** Читаем данные */
      ssize_t rd
        = read (so, buf, sizeof (buf));
      assert (rd >= 0);
      if (rd <= 0) {
        break;
      }

      /** Выводим (без изменения) */
      ssize_t wr
        = fwrite (buf, 1, rd, stdout);
      assert (wr == rd);
    }
  }

  /* . */
  return 0;
}
Пример: вывод программы — ответ сервера.
HTTP/1.1 200 OK^M
Accept-Ranges: bytes^M
Age: 548335^M
Cache-Control: max-age=604800^M
Content-Type: text/html; charset=UTF-8^M
Date: Thu, 09 Apr 2020 07:05:50 GMT^M
Etag: "3147526947+ident"^M
Expires: Thu, 16 Apr 2020 07:05:50 GMT^M
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT^M
Server: ECS (phd/FD6D)^M
X-Cache: HIT^M
Content-Length: 1256^M
Connection: close^M
^M

Пример клиента: curl

Изучим последовательность действий, выполняемую мульти­про­то­кольным клиентом curl для выполнения рассмотренного выше запроса HTTP/1.1, запустив программу под уже знакомым нам отладчиком strace.

Пример: фрагменты вывода отладчика strace при выполнении запроса curl -iI -- http://example.com/.
socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP) = 3
connect(3, {sa_family=AF_INET6, sin6_port=htons(80), inet_pton(AF_INET6, "2606:2800:220:1:248:1893:25c8:1946", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = -1 EINPROGRESS
sendto(3, "HEAD / HTTP/1.1\r\nHost: example.c", 76, MSG_NOSIGNAL, NULL, 0) = 76
recvfrom(3, "HTTP/1.1 200 OK\r\nAccept-Ranges: ", 102400, 0, NULL, NULL) = 334
write(1, "HTTP/1.1 200 OK\r\nAccept-Ranges: ", 334) = 334
close(3)                 = 0

Как видно, отличия простейшего клиента выше сводятся лишь к следующему:

Пример сервера: busybox httpd

Рассмотрим теперь таким же образом работу HTTP-сервера busybox httpd.

  1. socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 3
    

    Как обычно, мы начинаем с создания гнезда функцией socket.

  2. bind(3, {sa_family=AF_INET6, sin6_port=htons(4480), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
    listen(3, 9)             = 0
    

    Вместо инициирования соединения (connect), однако, сервер:

    1. привязывает (bind) гнездо к некоторому известному номеру порта; (мы не используем здесь стандартный порт 80, поскольку его занятие является привилегированной операцией);
    2. создает очередь (listen) ожидания соединения.
  3. accept(3, {sa_family=AF_INET6, sin6_port=htons(55362), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [28]) = 4
    

    Сервер выполняет функцию accept — принять следующий запрос на установление соединения из очереди. В случае, если в очереди нет ни одного запроса, функция ожидает появление оного. (Ясно, что время выполнения данной функции в этом случае может быть любым.)

  4. clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f9b2c7b4660) = 16934
    

    В простейшем случае, который и реализует данный сервер, для независимого обслуживания каждого нового клиента просто создается отдельный процесс. (Альтернативой является реализация конечного автомата на основе poll или select, или же использование нитей.) Как и в ранее рассмотренном случае для этого используется системный вызов clone (а не fork.)

  5. close(3)                 = 0
    dup2(4, 0)               = 0
    close(4)                 = 0
    dup2(0, 1)               = 1
    

    Дочерний процесс закрывает не нужное ему ожидающее («серверное») гнездо и замещает стандартный ввод-вывод гнездом, соответствующим установленному с клиентом соединению.

  6. read(0, "HEAD /hello.text HTTP/1.1\r\nHost:", 1024) = 93
    stat("hello.text", {st_mode=S_IFREG|0644, st_size=15, }) = 0
    openat(AT_FDCWD, "hello.text", O_RDONLY) = 3
    write(1, "HTTP/1.0 200 OK\r\nDate: Thu, 09 A", 163) = 163
    

    Дочерний процесс принимает запрос и отправляет ответ клиенту.

  7. sendfile(1, 3, [18446744073709551615], 9223372036854710272) = -1 EINVAL (Invalid argument)
    read(3, "Hello, world!\r\n", 8192) = 15
    write(1, "Hello, world!\r\n", 15) = 15
    

    Здесь в коде сервера допущена очевидная ошибка: в теле ответа HTTP клиенту отправляется копия запрошенного файла. Однако, сделанный клиентом запрос HEAD предполагает отправку исключительно заголовков, без какого-либо тела.

  8. read(3, "", 8192)        = 0
    shutdown(1, SHUT_WR)     = 0
    exit_group(0)            = ?
    +++ exited with 0 +++
    

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

Домашнее задание

Для проверки владения материалом по теме «гнезда Беркли» на обычном месте будет размещен тест. Вклад в оценку за семестр — до 5 баллов; контрольный срок сдачи — .

Кроме того, для закрепления рекомендуется выполнить лабораторную работу Internet, в частности — раздел по использованию программы socat.