Почему сокет TCP замедляется, если выполняется при нескольких системных вызовах?

Почему следующий код медленный? И медленным я имею в виду 100x-1000x медленно. Он просто многократно выполняет чтение/запись непосредственно в сокете TCP. Любопытная часть заключается в том, что она остается медленной, только если я использую два вызова функций для чтения и записи, как показано ниже. Если я изменю либо сервер, либо клиентский код, чтобы использовать один вызов функции (как в комментариях), он становится супер быстрым.

Фрагмент кода:

int main(...) {
  int sock = ...; // open TCP socket
  int i;
  char buf[100000];
  for(i=0;i<2000;++i)
  { if(amServer)
    { write(sock,buf,10);
      // read(sock,buf,20);
      read(sock,buf,10);
      read(sock,buf,10);
    }else
    { read(sock,buf,10);
      // write(sock,buf,20);
      write(sock,buf,10);
      write(sock,buf,10);
    }
  }
  close(sock);
}

Мы наткнулись на это в более крупной программе, которая фактически использовала буферизацию stdio. Это загадочно стало вялым момент, когда размер полезной нагрузки превысил размер буфера с небольшим отрывом. Затем я сделал кое-что с strace и, наконец, сварил проблему до этого. Я могу решить это, обманывая стратегию буферизации, но мне очень хотелось бы знать, что здесь происходит. На моей машине это происходит от 0,030 с до более минуты на моей машине (протестировано как на локальном, так и на удаленном компьютере), когда я меняю два прочитанных вызова на один вызов.

Эти тесты проводились на разных дистрибутивах Linux и различных версиях ядра. Тот же результат.

Полностью исполняемый код с сетевым шаблоном:

#include <netdb.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <netinet/ip.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>

static int getsockaddr(const char* name,const char* port, struct sockaddr* res)
{
    struct addrinfo* list;
    if(getaddrinfo(name,port,NULL,&list) < 0) return -1;
    for(;list!=NULL && list->ai_family!=AF_INET;list=list->ai_next);
    if(!list) return -1;
    memcpy(res,list->ai_addr,list->ai_addrlen);
    freeaddrinfo(list);
    return 0;
}
// used as sock=tcpConnect(...); ...; close(sock);
static int tcpConnect(struct sockaddr_in* sa)
{
    int outsock;
    if((outsock=socket(AF_INET,SOCK_STREAM,0))<0) return -1;
    if(connect(outsock,(struct sockaddr*)sa,sizeof(*sa))<0) return -1;
    return outsock;
}
int tcpConnectTo(const char* server, const char* port)
{
    struct sockaddr_in sa;
    if(getsockaddr(server,port,(struct sockaddr*)&sa)<0) return -1;
    int sock=tcpConnect(&sa); if(sock<0) return -1;
    return sock;
}

int tcpListenAny(const char* portn)
{
    in_port_t port;
    int outsock;
    if(sscanf(portn,"%hu",&port)<1) return -1;
    if((outsock=socket(AF_INET,SOCK_STREAM,0))<0) return -1;
    int reuse = 1;
    if(setsockopt(outsock,SOL_SOCKET,SO_REUSEADDR,
              (const char*)&reuse,sizeof(reuse))<0) return fprintf(stderr,"setsockopt() failed\n"),-1;
    struct sockaddr_in sa = { .sin_family=AF_INET, .sin_port=htons(port)
                  , .sin_addr={INADDR_ANY} };
    if(bind(outsock,(struct sockaddr*)&sa,sizeof(sa))<0) return fprintf(stderr,"Bind failed\n"),-1;
    if(listen(outsock,SOMAXCONN)<0) return fprintf(stderr,"Listen failed\n"),-1;
    return outsock;
}

int tcpAccept(const char* port)
{
    int listenSock, sock;
    listenSock = tcpListenAny(port);
    if((sock=accept(listenSock,0,0))<0) return fprintf(stderr,"Accept failed\n"),-1;
    close(listenSock);
    return sock;
}

void writeLoop(int fd,const char* buf,size_t n)
{
    // Don't even bother incrementing buffer pointer
    while(n) n-=write(fd,buf,n);
}
void readLoop(int fd,char* buf,size_t n)
{
    while(n) n-=read(fd,buf,n);
}
int main(int argc,char* argv[])
{
    if(argc<3)
    { fprintf(stderr,"Usage: round {server_addr|--} port\n");
        return -1;
    }
    bool amServer = (strcmp("--",argv[1])==0);
    int sock;
    if(amServer) sock=tcpAccept(argv[2]);
    else sock=tcpConnectTo(argv[1],argv[2]);
    if(sock<0) { fprintf(stderr,"Connection failed\n"); return -1; }

    int i;
    char buf[100000] = { 0 };
    for(i=0;i<4000;++i)
    {
        if(amServer)
        { writeLoop(sock,buf,10);
            readLoop(sock,buf,20);
            //readLoop(sock,buf,10);
            //readLoop(sock,buf,10);
        }else
        { readLoop(sock,buf,10);
            writeLoop(sock,buf,20);
            //writeLoop(sock,buf,10);
            //writeLoop(sock,buf,10);
        }
    }

    close(sock);
    return 0;
}

EDIT: эта версия немного отличается от другого фрагмента тем, что он читает/записывает в цикле. Таким образом, в этой версии две отдельные записи автоматически вызывают два отдельных вызова read(), даже если readLoop вызывается только один раз. Но в остальном проблема остается.

Ответ 1

Интересно. Вы становитесь жертвой алгоритм Nagle вместе с Отложенные подтверждения TCP.

Алгоритм Nagle - это механизм, используемый в TCP для отсрочки передачи небольших сегментов до тех пор, пока не будет накоплено достаточное количество данных, что заставляет его строить и отправлять сегмент по сети. Из статьи в википедии:

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

Однако TCP обычно использует что-то известное как TCP delayed подтверждения, которое представляет собой метод, который состоит из аккумулирования совокупности ответов ACK (поскольку TCP использует кумулятивный ACKS), чтобы уменьшить сетевой трафик.

В этой статье в Википедии упоминается следующее:

При использовании обоих алгоритмов приложения, выполняющие два последовательных записывает в TCP-соединение, а затем читает, что не будет выполняется до тех пор, пока данные второй записи не достигнут пункт назначения испытывает постоянную задержку до 500 миллисекунд, "Задержка ACK" .

(Акцент мой)

В вашем конкретном случае, поскольку сервер не отправляет больше данных перед чтением ответа, клиент вызывает задержку: если клиент дважды записывает, вторая запись будет отложена.

Если алгоритм Nagle используется стороной-отправителем, данные будут в очереди отправителя до получения ACK. Если отправитель не отправьте достаточно данных, чтобы заполнить максимальный размер сегмента (например, если он выполняет две небольшие записи, за которыми следует чтение блокировки), затем передача приостанавливается до тайм-аута задержки ACK.

Итак, когда клиент делает 2 вызова записи, это происходит:

  • Клиент выдает первую запись.
  • Сервер получает некоторые данные. Он не признает этого в надежде на то, что поступит больше данных (чтобы он мог объединить кучу ACK в одном ACK).
  • Клиент выдает вторую запись. Предыдущая запись не была подтверждена, поэтому алгоритм Nagle отменяет передачу до тех пор, пока не поступит больше данных (пока не будет собрано достаточное количество данных для создания сегмента), или предыдущая запись ACKed.
  • Сервер устал ждать и через 500 мс признает сегмент.
  • Клиент, наконец, завершает вторую запись.

С записью 1 это происходит:

  • Клиент выдает первую запись.
  • Сервер получает некоторые данные. Он не признает этого в надежде на то, что поступит больше данных (чтобы он мог объединить кучу ACK в одном ACK).
  • Сервер записывает в сокет. ACK является частью заголовка TCP, поэтому, если вы пишете, вы можете также признать предыдущий сегмент без каких-либо дополнительных затрат. Сделайте это.
  • Между тем, клиент написал один раз, так что он уже ожидал следующего чтения - не было второй записи, ожидающей сервер ACK.

Если вы хотите продолжать писать дважды на стороне клиента, вам нужно отключить алгоритм Nagle. Это решение, предложенное самим автором алгоритма:

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

(См. цитату в Википедии)

Как упоминалось в комментариях Дэвидом Шварцем, это может быть не самая лучшая идея по разным причинам, но она иллюстрирует суть и показывает, что это действительно вызывает задержка.

Чтобы отключить его, вам нужно установить опцию TCP_NODELAY в сокетах с помощью setsockopt(2).

Это можно сделать в tcpConnectTo() для клиента:

int tcpConnectTo(const char* server, const char* port)
{
    struct sockaddr_in sa;
    if(getsockaddr(server,port,(struct sockaddr*)&sa)<0) return -1;
    int sock=tcpConnect(&sa); if(sock<0) return -1;

    int val = 1;
    if (setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &val, sizeof(val)) < 0)
        perror("setsockopt(2) error");

    return sock;
}

И в tcpAccept() для сервера:

int tcpAccept(const char* port)
{
    int listenSock, sock;
    listenSock = tcpListenAny(port);
    if((sock=accept(listenSock,0,0))<0) return fprintf(stderr,"Accept failed\n"),-1;
    close(listenSock);

    int val = 1;
    if (setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &val, sizeof(val)) < 0)
        perror("setsockopt(2) error");

    return sock;
}

Интересно видеть огромную разницу в этом.

Если вы предпочитаете не вмешиваться в параметры сокета, достаточно убедиться, что клиент пишет один раз - и только один раз - перед следующим чтением. Вы все еще можете прочитать сервер дважды:

for(i=0;i<4000;++i)
{
    if(amServer)
    { writeLoop(sock,buf,10);
        //readLoop(sock,buf,20);
        readLoop(sock,buf,10);
        readLoop(sock,buf,10);
    }else
    { readLoop(sock,buf,10);
        writeLoop(sock,buf,20);
        //writeLoop(sock,buf,10);
        //writeLoop(sock,buf,10);
    }
}