Работать в пятницу после обеда первого апреля не хочется — вдруг ещё техника выкинет какую-нибудь шутку. Потому решил о чем-либо написать.
Не так давно на просторах хабра в одной статье огульно охаяли сразу Unix-сокеты, mysql, php и redis. Говорить обо всём в одной статье не будем, остановимся на сокетах и немного на redis.
Итак вопрос: что быстрее Unix- или TCP-сокеты?
Вопрос, который не стоит и выеденного яйца, однако, постоянно муссируемый и писать не стал бы если б не опрос в той самой статье, согласно которому едва-ли не половина респондентов считает, что лучше/надёжнее/стабильнее использовать TCP-сокеты.
Тем, кто и так выбирает AF_UNIX, можно дальше не читать.

Начнём с краткой выжимки из теории.
Сокет — один из интерфейсов межпроцессного взаимодействия, позволяющий разрабатывать клиент-серверные системы для локального или сетевого использования. Так как мы рассматриваем в сравнении (с одной стороны) Unix-сокеты, то в дальнейшем будем говорить об IPC в пределах одной машины.
В отличии от именованных каналов, при использовании сокетов прослеживается отличие между клиентом и сервером. Механизм сокетов позволяет создавать сервер к которому подключается множество клиентов.

Как реализуется взаимодействие со стороны сервера:
— системный вызов socket создаёт сокет, но этот сокет не может использоваться совместно с другими процессами;
— сокет именуется. Для локальных сокетов домена AF_UNIX(AF_LOCAL) адрес будет задан именем файла. Сетевые сокеты AF_INET именуются в соответствии с их ip/портом;
— системный вызов listen(int socket, int backlog) формирует очередь входящих подключений. Второй параметр backlog определяет длину этой очереди;
— эти подключения сервер принимает с помощью вызова accept, который создаёт новый сокет, отличающийся от именованного сокета. Этот новый сокет применяется только для взаимодействия с данным конкретным клиентом.

С точки зрения клиента подключение происходит несколько проще:
— вызывается socket;
— и connect, используя в качестве адреса именованный сокет сервера.

Остановимся внимательнее на вызове int socket(int domain, int type, int protocol) второй параметр которого определяет тип обмена данными используемого с этим сокетом. В нашем сравнении мы будем рассматривать его возможное значение SOCK_STREAM, являющееся надежным, упорядоченным двунаправленным потоком байтов. То есть в рассмотрении участвуют сокеты вида
sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
и
sockfd = socket(AF_INET, SOCK_STREAM, 0);

Структура сокета в домене AF_UNIX проста:
struct sockaddr_un {
        unsigned char   sun_len;        /* sockaddr len including null */
        sa_family_t     sun_family;     /* AF_UNIX */
        char    sun_path[104];          /* path name (gag) */
};

В домене AF_INET несколько сложнее:
struct sockaddr_in {
        uint8_t sin_len;
        sa_family_t     sin_family;
        in_port_t       sin_port;
        struct  in_addr sin_addr;
        char    sin_zero[8];
};

и на её заполнение мы понесём дополнительные расходы. В частности, это могут быть расходы на ресолвинг (gethostbyname) и/или выяснение того с какой стороны разбивать яйца (htons).

Также сокеты в домене AF_INET, несмотря на обращение к localhost, «не знают» того, что они работают на локальной системе. Тем самым они не прилагают никаких усилий, чтобы обойти механизмы сетевого стека для увеличения производительности. Таким образом мы «оплачиваем» усилия на переключения контекста, ACK, TCP управление потоком, маршрутизацию, разбиение больших пакетов и т.п. То есть это «полноценная TCP работа» несмотря на то, что мы работаем на локальном интерфейсе.

В свою очередь сокеты AF_UNIX «осознают», что они работают внутри одной системы. Они избегают усилий на установку ip-заголовков, роутинг, расчёт контрольных сумм и т.д. Кроме того, раз в домене AF_UNIX используется файловая система в качестве адресного пространства, мы получаем бонус в виде возможности использования прав доступа к файлам и управления доступа к ним. Тем самым мы можем без существенных усилий ограничивать процессам доступ к сокетам и, опять же, не несём затрат на этапы обсепечения безопасности.

Проверим теорию на практике.
Мне лень писать серверную часть, потому воспользуюсь тем же redis-server. Его функционал отлично для этого подходит и заодно проверим справедливы ли были обвинения в его адрес. Клиентские части набросаем свои. Будем выполнять простейшую команду INCR со сложностью O(1).
Создание сокетов намеренно помещаем внутри циклов.
TCP-клиент:
AF_INET
#include <stdio.h>
#include <stdio.h>
#include <stdlib.h>

#include <sys/types.h>
#include <sys/socket.h>

#include <netdb.h>
#include <netinet/in.h>

#include <string.h>

int main(int argc, char *argv[]) {
   int sockfd, portno, n;
   struct sockaddr_in serv_addr;
   struct hostent *server;
   
   char buffer[256];
   
   if (argc < 4) {
      fprintf(stderr,"usage %s hostname port count_req\n", argv[0]);
      exit(0);
   }
	
   portno = atoi(argv[2]);
   
   int i=0;
   int ci = atoi(argv[3]);
   for(i; i < ci; i++)
   {
      
      sockfd = socket(AF_INET, SOCK_STREAM, 0);
      
      if (sockfd < 0) {
         perror("ERROR opening socket");
         exit(1);
      }
           
      server = gethostbyname(argv[1]);
      
      if (server == NULL) {
         fprintf(stderr,"ERROR, no such host\n");
         exit(0);
      }
      
      bzero((char *) &serv_addr, sizeof(serv_addr));
      serv_addr.sin_family = AF_INET;
      bcopy((char *)server->h_addr, (char *)&serv_addr.sin_addr.s_addr, server->h_length);
      serv_addr.sin_port = htons(portno);
      
      if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
         perror("ERROR connecting");
         exit(1);
      }
      
      char str[] = "*2\r\n$4\r\nincr\r\n$3\r\nfoo\r\n";
      int len = sizeof(str);
      bzero(buffer, len);
      memcpy ( buffer, str, len );

      n = write(sockfd, buffer, strlen(buffer));
      
      if (n < 0) {
         perror("ERROR writing to socket");
         exit(1);
      }
      
      bzero(buffer,256);
      n = read(sockfd, buffer, 255);
      
      if (n < 0) {
         perror("ERROR reading from socket");
         exit(1);
      }
      
      printf("%s\n",buffer);
      close(sockfd);
   }
   return 0;
}


UNIX-клиент:
AF_UNIX
#include <stdio.h>
#include <stdlib.h>

#include <sys/types.h>
#include <sys/socket.h>

#include <sys/un.h>

#include <string.h>

int main(int argc, char *argv[]) {
   int sockfd, portno, n;
   struct sockaddr_un serv_addr;
   struct hostent *server;
   
   char buffer[256];
   
   if (argc < 1) {
      fprintf(stderr,"usage %s count_req\n", argv[0]);
      exit(0);
   }
	
   int i=0;
   int ci = atoi(argv[1]);
   for(i; i < ci; i++)
   {
      
      sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
      
      if (sockfd < 0) {
         perror("ERROR opening socket");
         exit(1);
      }
      
      bzero((char *) &serv_addr, sizeof(serv_addr));
      serv_addr.sun_family = AF_UNIX;
      strcpy(serv_addr.sun_path, "/tmp/redis.sock");
      
      if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
         perror("ERROR connecting");
         exit(1);
      }
      
      char str[] = "*2\r\n$4\r\nincr\r\n$3\r\nfoo\r\n";
      int len = sizeof(str);
      bzero(buffer, len);
      memcpy ( buffer, str, len );
      
      n = write(sockfd, buffer, strlen(buffer));
      
      if (n < 0) {
         perror("ERROR writing to socket");
         exit(1);
      }
      
      bzero(buffer,256);
      n = read(sockfd, buffer, 255);
      
      if (n < 0) {
         perror("ERROR reading from socket");
         exit(1);
      }
      
      printf("%s\n",buffer);
      close(sockfd);
   }
   return 0;
}


Тестируем с одним клиентом:
# redis-cli set foo 0 ; time ./redistcp 127.0.0.1 6379 1000000 > /dev/null ; redis-cli get foo
OK
2.108u 21.991s 1:13.75 32.6%    9+158k 0+0io 0pf+0w
"1000000"

# redis-cli set foo 0 ; time ./redisunix 1000000 > /dev/null ; redis-cli get foo
OK
0.688u 9.806s 0:36.90 28.4%     4+151k 0+0io 0pf+0w
"1000000"


И теперь для двадцати паралелльных клиентов отправляющих 500000 запросов каждый.
для TCP: 6:12.86
# redis-cli info Commandstats
cmdstat_set:calls=1,usec=5,usec_per_call=5.00
cmdstat_incr:calls=10000000,usec=24684314,usec_per_call=2.47

для UNIX: 4:11.23
# redis-cli info Commandstats
cmdstat_set:calls=1,usec=8,usec_per_call=8.00
cmdstat_incr:calls=10000000,usec=22258069,usec_per_call=2.23


Тем самым, в целом, аргументами в пользу TCP-сокетов может служить лишь мобильность применения и возможность простого масштабирования. Но если же вам требуется работа в пределах одной машины, то выбор, безусловно, в пользу UNIX-сокетов. Поэтому выбор между TCP- и UNIX-сокетами — это, в первую очередь, выбор между переносимостью и производительностью.

На сим предлагаю любить Unix-сокеты, а вопросы тупоконечностей оставить жителям Лилипутии и Блефуску.

Комментарии (35)


  1. galaxy
    01.04.2016 17:23

    Не, вы уж извольте убрать gethostbyname в тесте TCP, а то нечестно как-то


    1. lexore
      01.04.2016 17:25

      Чем нечестно?


      1. galaxy
        01.04.2016 17:30

        Тем, что для unix вы ничего подобного не делаете. Может, для хоста в форме ip адреса разницы практически и не будет, но для чистоты эксперимента...


        1. lexore
          01.04.2016 17:33
          +4

          Нечестность тут заложена в самой разнице между TCP и UNIX сокетами.
          В реальной ситуации в конфигах можно написать и ip, и имя хоста, и при подключении gethostbyname используется.
          Поэтому, я думаю, автор топика просто воссоздал реальную ситуацию, а не синтетическую.


          1. galaxy
            01.04.2016 17:52

            Реальных ситуаций бесконечно много, поэтому в синтетических тестах не надо пытаться их воссоздавать.
            Я бы и connect с close вынес за цикл и гонял бы не десяток байт, а несколько мегабайт


            1. lexore
              01.04.2016 18:13
              +1

              А смысл в отдельном синтетическом тесте?
              Разница по времени, по большей части, и получается изза того, что на установление соединения по TCP тратится больше времени, чем по UNIX.
              Если использовать постоянные соединения, разницу можно уменьшить.


            1. lorc
              01.04.2016 18:14
              +2

              Я боюсь, что десяток мегабайт показал бы ещё и проблемы с TCP window. Хотя, могу ошибаться, конечно.


      1. Tangeman
        02.04.2016 13:35

        Нечестно тем что в нём нет необходимости внутри цикла — значение argv[1] не изменяется, а вероятность изменения адреса хоста сравнительно мала, к тому же, это довольно дорого — в зависимости от реализации, к примеру, он может каждый раз читать /etc/hosts — а это уже ужасно долго.
        В случае же когда argv[1] уже является IP адресом (как в данном случае), вызов gethostbyname() — пустая трата времени.


        1. lexore
          02.04.2016 23:05
          +1

          Вы придираетесь к коду, который специально был написан, чтобы показать "среднюю температуру по больнице"? Дело же не в том, что "можно оптимизировать конкретно этот код". Пример просто наглядно показывает разницу во времени.
          Представьте, что это не один клиент много раз подключается, а много клиентов подключаются один раз. В таком случае нельзя будет убрать gethostbyname из цикла. Просто много параллельных клиентов не смогут показать разницу во времени так явно.


    1. simpleadmin
      01.04.2016 18:34
      -1

      Не, вы уж извольте убрать gethostbyname в тесте TCP, а то нечестно как-то

      1) Это как раз честно. Представьте socket-клиент в реальных условиях. Будет ли он без gethostbyname?
      2) На самом деле вес gethostbyname в массе(да пусть поперхнутся физики) ничтожен. Даже на НЕ-локальных вызовах он окажет куда меньшее влияние чем может оказать, например, размер пакета.


      1. galaxy
        01.04.2016 23:14
        +3

        В реальных условиях, если вы заботитесь о производительности хоть немного, вы не будете на каждые 10 отправленных байт открывать сокет, вызывать gethostbyname и т.д.


    1. robert_ayrapetyan
      02.04.2016 03:11

      Хотел вас поддержать (gethostbyname может легко отъесть много секунд при обращении к реальному днс), но здесь, при передаче 127.0.0.1 он вообще не влияет ни на что (как и все остальные манипуляции по заполнению структур — специально их вынес все за цикл, ничего не поменялось, вообще...)


      1. simpleadmin
        02.04.2016 13:41
        +1

        gethostbyname — дешёвая операция, как в контексте данного теста
        http://www.opennet.ru/cgi-bin/opennet/man.cgi?topic=gethostbyname

        Если name является адресом IPv4 или IPv6, то поиск не производится и gethostbyname() просто копирует name в поле h_name, а его эквивалент для структуры struct in_addr копируется в поле h_addr_list[0] возвращаемой структуры hostent

        так и при реальном разрешении имён, т.к. будет задействован механизм NSCD (если только не делать принудительный invalidate кеша).


        1. robert_ayrapetyan
          02.04.2016 19:00

          Вообще, gethostbyname() уже давно даже не в стандарте POSIX, и нужно использовать getaddrinfo().
          Так вот, в последней с кешированием не все так радужно (например, для имен с несколькими IP-адресами).
          В реальных высоконагруженных проектах, в обработчиках запросов использовать getaddrinfo категорически не рекомендую. Даже если nscd кеширует все очень быстро и правильно, делать каждый раз запрос к nscd-демону для получения информации, которая может храниться в памяти, очень дорого. В реальном проекте пришлось использовать связку c-ares с собстенным велосипедом хранения IP-адресов в структурах памяти процесса.


  1. rotor
    01.04.2016 17:33
    +4

    Если кто пропустил, то в статье речь об этой заметке: https://habrahabr.ru/company/pushall/blog/280218/
    Автору браво за развернутое объяснение.


  1. GoshaZ
    01.04.2016 18:12

    А про UDP будет подобный обзор?


    1. simpleadmin
      01.04.2016 19:08
      +2

      А зачем?
      С чем его сравнивать?
      Говоря о UDP подразумевая SOCK_DGRAM, т.е. на доменах AF_UNIX/AF_INET имеем те же отличия что и при SOCK_STREAM.
      Говоря о UDP подразумевая отличия в одном домене имён между SOCK_DGRAM и SOCK_STREAM имеем негарантированную доставку пакета ограниченного размера.


  1. citius
    01.04.2016 21:29
    +1

    По наличию оверхеда понятно.
    Непонятно другое — часто при больших нагрузках на файловый сокет, тот просто перестает отзываться.

    Т.е. запускаем php-fpm с файловым сокетом, и имеем периодические ошибки в логе нжинкса по причине «11: Resource temporarily unavailable while connecting to upstream».

    Переконфигурирем все на tcp, без каких либо еще изменнений, и тот же самый тазик держит нагрузку в разы больше.

    Не думаю что это баг именно пхп, т.к. аналогичное наблюдение было с кастомным демоном на С, также с fastcgi режиме с нжинксом.


    1. Prototik
      01.04.2016 22:11
      +1

      А у вас случаем не упиралось всё в лимит по дескрипторам? UNIX сокеты работают просто замечательно, но их, как и TCP, надо уметь настраивать.


      1. citius
        01.04.2016 22:13

        Неа. Все что можно выкрутить в sysctl, выкручено на максимум.
        Для tcp сокетов тоже надо с бубном плясать для нормальных нагрузок. Но они таки работают после этого.


        1. sharpyfox
          02.04.2016 13:47

          Могу ошибаться, но у unix сокета кажется просто меньше размер backlog. В случае tcp можно настроить довольно большое значение, а у unix сокета есть ограничение.


      1. AgaFonOff
        02.04.2016 02:10

        Подтверждаю аналогичную проблему со связкой nginx — uwsgi. До некоторых пор все ок, а потом начинается свистопляска (у нас не прокатило более 500 запросов в сек, если память не изменяет...)


  1. brkov
    02.04.2016 02:49

    Всё это конечно прекрасно, но на уровне вопроса: что быстрее в php – echo или print?
    Да unix сокеты "типа" быстрее, однако вся эта быстрота быстро нивелируется отсутствием нормальных инструментов дебага общения через него.
    Да и мне лень смотреть в код, но если не изменяет память, там как-то всё очень плохо с backlog'ом. И бёрсты такая конструкция будет дропать.


    1. sch1z0phr3n1a
      02.04.2016 12:18
      +2

      Есть определённые плюшки от использования unix-сокетов. Например исчезает проблема с time-wait сокетами внутри машины. Текущие механизмы recycle и reuse могут давать всплески до сотен миллисекунд. Конечно эту проблему частично решает keep-alive, но когда локальный бекенд начинает таймаутить и какой-нить nginx начинает переустанавливать сокеты — превед time-wait.
      Кроме того, если достаточно большой rate i/o, то unixsocket экономит процессор по sys time, они банально дешевле для системы.

      Да и мне лень смотреть в код, но если не изменяет память, там как-то всё очень плохо с backlog'ом. И бёрсты такая конструкция будет дропать.

      Если это так, то это не минус, это нюанс. Для некоторых частей системы лучше быстро дропать запросы, чем зря складывать их в беклог. Если эта компонента ненапрямую отвечает пользователю (мы ведь врятли через unixsocket напрямую отвечаем тысячам пользователей?), то это даже огромная плюшка для балансера, который быстрее сможет поретраиться в другой бекенд.


  1. astgtciv
    02.04.2016 11:27

    Немного оффтоп, но меня всегда очень интересовало — почему нет именованных портов у сетевых сокетов? Например "{ip}:web_server"?


    1. lexore
      02.04.2016 11:47
      +4

      Кстати, вообще есть. В unix системах есть файл /etc/services (в нагрузку к /etc/hosts и /etc/protocols).
      Большинство утилит умеют работать с этим файлом и если вы напишете "www", они "разрезолвят" его в 80.


      1. astgtciv
        02.04.2016 12:07

        Спасибо, не знал. Но почему нет первокласнных именованных портов как часть стека? Просто "так вышло", или на это были и есть какие-то причины?


        1. lexore
          02.04.2016 12:28

          Что значит "первоклассный именованый порт"? Вы не про DNS случаем?
          Вообще порт — часть стека TCP/IP. Этот стек был разработан в 1977 и внедрен в 1983.
          А DNS был разработан через несколько лет. Хочу напомнить, чтобы работа глобального DNS обеспечивается нехилой такой прослойкой "управляющая организация + корневые сервера + регистраторы + доменные хостеры + кеширующие серверы + 100500 продавцов и покупателей доменов + куча бумаг и денег на все это дело".
          Вы же не хотите регистрировать свое название порта, с заверением всех документов?
          А вообще при разработке TCP/IP думали о скорости и эффективности для компьютера, а не об удобстве пользователя. Хранить порт в виде числа куда удобнее и практичнее, чем строку.
          Во первых в каком формате хранить строку?
          Это 7 битный ascii, или 8 битный, или 8 битный utf, или 16/32 битный? Ну ладно, тогда ещё не было UTF.
          А какой длинны должна быть строка?
          А где хранить все возможные известные строки?
          Что делать, если запрос придет на порт web_server, а система знает только про www?
          А если и www ещё не изобрели, так как он появился только в 1991 году?
          А как добавить новый порт?
          В текущей системе вы, кстати, можете сами редактировать /etc/services файл и система тут же начнет понимать новые названия портов.
          Если вы сами попробуете реализовать порт в виде строки, то скоро увидите, что разработчики выбрали самую эффективную реализацию:
          Порт в виде числа + текстовый файл с названиями.
          P.S.
          Кстати, вы в своем вопросе про "{ip}:web_server" уже наступили на грабли названий — правильно писать "www".


          1. astgtciv
            02.04.2016 13:23

            Нет, я совсем не имел в виду DNS.
            Про формат хранения строки — да, пришлось бы выбрать формат, скажем 7-bit ASCII, с ограничением длины строки.
            Зачем хранить все возможные строки? Я, наверное, что-то глубинное здесь не понимаю, я представляю себе это так — сейчас скажем сервер слушает на портах 1111 и 2222. Если клиент пытается подсоединиться к server_ip:1111 или server_ip:2222, у него получается. На все остальные порты — не получается, так как там никто не слушает. Я просто говорил о возможности идентифицировать эти порты строками, что сервер мог (вдобавок к нумерическим портам) сказать "я слушаю на порте 'lexore'", а клиент подключался бы к server_ip:lexore.
            Тут и плюсы кстати есть — например, сканировать порты стало бы сильно сложнее.


            1. lexore
              02.04.2016 23:18

              Ну, стоит вспомнить, что порты источника и получателя пишутся в каждый TCP пакет. Сейчас это 16 бит на порт "откуда" и 16 бит на порт "куда", т.е. 32 бита, или 4 байта. А в случае с текстовыми портами, скажем по 8 символов, это уже 16 байт. И 20 байтный пакет вырастает на 60%
              И остается вопрос "зачем?". Зачем увеличивать заголовок на 60%, когда можно всего один раз "разрезолвить" номер порта по имени из файла /etc/services.
              Лично я даже в 2016 году на гигабитных каналах не вижу объективных преимуществ.
              А в 70-80е годы XX века никто даже не думал так делать. Если вы заметите, все делалось на числах. причем самой маленькой длины — порту даже не 4 байта отвели, а 2.


            1. TaHKucT
              02.04.2016 23:22

              формат tcp-заголовка (как и udp) позволяет идентифицировать порт 16 битами. Поменять число на текст — значит существенно раздуть формат заголовка без какого либо профита и выкинуть на свалку все имеющееся сетевое оборудование.


            1. Paramount
              03.04.2016 11:51

              Тогда пришлось бы городить ещё один DNS для резолва портов. Или я что-то не понял?


  1. vsb
    02.04.2016 12:00

    В реальных условиях держится пул соединений и никаких дополнительных затрат на установление соединения нет, оно переиспользуется. Сделайте нормальный тест и он не покажет значимой разницы между стеками UNIX и Loopback. А если учитывать, что в реальном приложении ещё и какая-то работа идёт, то разницы не будет совсем.


  1. Aytuar
    02.04.2016 13:47

    У меня тоже самое бывало с питоновским приложением и использованием сокета в гуникорне для nginx.


  1. amarao
    03.04.2016 12:42

    Линуксы (и другие ос) отлично умеют оптимизировать коннекты на lo. Для них не высчитывается чексуммы и не выполняется маршрутизация. Объем работы с файловыми советами сравним с сетью (vfs тоже не Самая быстрая штука, если что).