Почему задержка записи TCP хуже, когда работа чередуется? - PullRequest
0 голосов
/ 03 сентября 2018

Я профилировал задержку TCP (в частности, write из пространства пользователя в пространство ядра небольшого сообщения), чтобы получить некоторую интуицию для задержки write (признавая, что это может быть контекстом конкретный). Я заметил существенное несоответствие между тестами, которые мне кажутся похожими, и мне очень любопытно выяснить, откуда взялась разница. Я понимаю, что микробенчмарки могут быть проблематичными, но я все еще чувствую, что мне не хватает некоторого фундаментального понимания (так как различия в задержке составляют ~ 10x).

Настройка состоит в том, что у меня есть C ++ TCP-сервер, который принимает одно клиентское соединение (от другого процесса на том же процессоре), и при соединении с клиентом делает 20 системных вызовов к write сокету, отправляя один байт вовремя. Полный код сервера скопирован в конце этого поста. Вот вывод, который каждый раз write, используя boost/timer (который добавляет шум ~ 1 микрофон):

$ clang++ -std=c++11 -stdlib=libc++ tcpServerStove.cpp -O3; ./a.out
18 mics
3 mics
3 mics
4 mics
3 mics
3 mics
4 mics
3 mics
5 mics
3 mics
...

Я достоверно обнаружил, что первый write значительно медленнее, чем остальные. Если я оберну 10000 write вызовов в таймере, среднее значение составит 2 микросекунды на write, но первый вызов всегда будет 15+ микрофонов. Почему это явление "разогрева"?

Соответственно, я провел эксперимент, в котором между каждым вызовом write я выполняю некоторую блокирующую работу ЦП (вычисляя большое простое число). Это заставляет все write звонки быть медленными:

$ clang++ -std=c++11 -stdlib=libc++ tcpServerStove.cpp -O3; ./a.out
20 mics
23 mics
23 mics
30 mics
23 mics
21 mics
21 mics
22 mics
22 mics
...

Учитывая эти результаты, мне интересно, есть ли какая-то пакетная обработка, которая происходит в процессе копирования байтов из пользовательского буфера в буфер ядра. Если несколько вызовов write происходят в быстрой последовательности, объединяются ли они в одно прерывание ядра?

В частности, я ищу представление о том, сколько времени write занимает копирование буферов из пространства пользователя в пространство ядра. Если есть некоторый коалесцирующий эффект, который позволяет среднему write брать только 2 микрофона, когда я делаю 10 000 подряд, то было бы неоправданно оптимистично полагать, что задержка write составляет 2 микрофона; кажется, что моя интуиция должна заключаться в том, что каждый write занимает 20 микросекунд. Это кажется удивительно медленным для самой низкой задержки, которую вы можете получить (необработанный write вызов на один байт) без обхода ядра.

Последний фрагмент данных: когда я настраиваю тест на пинг-понг между двумя процессами на моем компьютере (TCP-сервер и TCP-клиент), я в среднем получаю 6 микрофонов за круговую передачу (включая read, write, а также перемещение по локальной сети). Кажется, это расходится с задержками в 20 микрофонов для одной записи, показанной выше.

Полный код для TCP-сервера:

// Server side C/C++ program to demonstrate Socket programming
// #include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <boost/timer.hpp>
#include <unistd.h>

// Set up some blocking work.
bool isPrime(int n) {
    if (n < 2) {
        return false;
    }

    for (int i = 2; i < n; i++) {
        if (n % i == 0) {
            return false;
        }
    }

    return true;
}

// Compute the nth largest prime. Takes ~1 sec for n = 10,000
int getPrime(int n) {
    int numPrimes = 0;
    int i = 0;
    while (true) {
        if (isPrime(i)) {
            numPrimes++;
            if (numPrimes >= n) {
                return i;
            }
        }
        i++;
    }
}

int main(int argc, char const *argv[])
{
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);

    // Create socket for TCP server
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    // Prevent writes from being batched
    setsockopt(server_fd, SOL_SOCKET, TCP_NODELAY, &opt, sizeof(opt));
    setsockopt(server_fd, SOL_SOCKET, TCP_NOPUSH, &opt, sizeof(opt));
    setsockopt(server_fd, SOL_SOCKET, SO_SNDBUF, &opt, sizeof(opt));
    setsockopt(server_fd, SOL_SOCKET, SO_SNDLOWAT, &opt, sizeof(opt));

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    bind(server_fd, (struct sockaddr *)&address, sizeof(address));

    listen(server_fd, 3);

    // Accept one client connection
    new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);

    char sendBuffer[1] = {0};
    int primes[20] = {0};
    // Make 20 sequential writes to kernel buffer.
    for (int i = 0; i < 20; i++) {
        sendBuffer[0] = i;
        boost::timer t;
        write(new_socket, sendBuffer, 1);
        printf("%d mics\n", int(1e6 * t.elapsed()));

        // For some reason, doing some blocking work between the writes
        // The following work slows down the writes by a factor of 10.
        // primes[i] = getPrime(10000 + i);
    }

    // Print a prime to make sure the compiler doesn't optimize
    // away the computations.
    printf("prime: %d\n", primes[8]);

}

Код клиента TCP:

// Server side C/C++ program to demonstrate Socket programming
// #include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char const *argv[])
{
    int sock, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);

    // We'll be passing uint32's back and forth
    unsigned char recv_buffer[1024] = {0};

    // Create socket for TCP server
    sock = socket(AF_INET, SOCK_STREAM, 0);

    setsockopt(sock, SOL_SOCKET, TCP_NODELAY, &opt, sizeof(opt));

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    // Accept one client connection
    if (connect(sock, (struct sockaddr *)&address, (socklen_t)addrlen) != 0) {
        throw("connect failed");
    }

    read(sock, buffer_pointer, num_left);

    for (int i = 0; i < 10; i++) {
        printf("%d\n", recv_buffer[i]);
    }
}

Я пробовал с и без флагов TCP_NODELAY, TCP_NOPUSH, SO_SNDBUF и SO_SNDLOWAT, с идеей, что это может предотвратить пакетирование (но я понимаю, что этот пакет происходит между буфером ядра и сетью , не между пользовательским буфером и буфером ядра).

Вот код сервера для теста пинг-понга:

// Server side C/C++ program to demonstrate Socket programming
// #include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <boost/timer.hpp>
#include <unistd.h>

 __inline__ uint64_t rdtsc(void)
   {
uint32_t lo, hi;
__asm__ __volatile__ (
        "xorl %%eax,%%eax \n        cpuid"
        ::: "%rax", "%rbx", "%rcx", "%rdx");
__asm__ __volatile__ ("rdtsc" : "=a" (lo), "=d" (hi));
return (uint64_t)hi << 32 | lo;
 }

// Big Endian (network order)
unsigned int fromBytes(unsigned char b[4]) {
    return b[3] | b[2]<<8 | b[1]<<16 | b[0]<<24;
}

void toBytes(unsigned int x, unsigned char (&b)[4]) {
    b[3] = x;
    b[2] = x>>8;
    b[1] = x>>16;
    b[0] = x>>24;
}

int main(int argc, char const *argv[])
{
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    unsigned char recv_buffer[4] = {0};
    unsigned char send_buffer[4] = {0};

    // Create socket for TCP server
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    bind(server_fd, (struct sockaddr *)&address, sizeof(address));

    listen(server_fd, 3);

    // Accept one client connection
    new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
    printf("Connected with client!\n");

    int counter = 0;
    unsigned int x = 0;
    auto start = rdtsc();
    boost::timer t;

    int n = 10000;
    while (counter < n) {
        valread = read(new_socket, recv_buffer, 4);
        x = fromBytes(recv_buffer);
        toBytes(x+1, send_buffer);
        write(new_socket, send_buffer, 4);
        ++counter;
    }

    printf("%f clock cycles per round trip (rdtsc)\n",  (rdtsc() - start) / double(n));
    printf("%f mics per round trip (boost timer)\n", 1e6 * t.elapsed() / n);
}

Вот код клиента для теста пинг-понга:

// #include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <boost/timer.hpp>
#include <unistd.h>

// Big Endian (network order)
unsigned int fromBytes(unsigned char b[4]) {
    return b[3] | b[2]<<8 | b[1]<<16 | b[0]<<24;
}

void toBytes(unsigned int x, unsigned char (&b)[4]) {
    b[3] = x;
    b[2] = x>>8;
    b[1] = x>>16;
    b[0] = x>>24;
}

int main(int argc, char const *argv[])
{
    int sock, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);

    // We'll be passing uint32's back and forth
    unsigned char recv_buffer[4] = {0};
    unsigned char send_buffer[4] = {0};

    // Create socket for TCP server
    sock = socket(AF_INET, SOCK_STREAM, 0);

    // Set TCP_NODELAY so that writes won't be batched
    setsockopt(sock, SOL_SOCKET, TCP_NODELAY, &opt, sizeof(opt));

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    // Accept one client connection
    if (connect(sock, (struct sockaddr *)&address, (socklen_t)addrlen) != 0) {
        throw("connect failed");
    }

    unsigned int lastReceived = 0;
    while (true) {
        toBytes(++lastReceived, send_buffer);
        write(sock, send_buffer, 4);
        valread = read(sock, recv_buffer, 4);
        lastReceived = fromBytes(recv_buffer);
    }
}

Ответы [ 3 ]

0 голосов
/ 11 сентября 2018

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

Микробенчмаркинг сложен, особенно с вызовами ОС - по моему опыту, нужно учесть несколько факторов, отфильтровать или измерить их, прежде чем принимать окончательные цифры.

Некоторые из этих факторов:

  1. попаданий в кэш / пропусков

  2. приоритет многозадачности

  3. ОС, выделяющая память в определенные моменты вызовов API (выделение памяти может легко привести к микросекундам задержек)

  4. отложенная загрузка (некоторые API могут мало что делать во время connect вызова, например, до тех пор, пока не поступят реальные данные)

  5. фактическая тактовая частота процессора в данный момент (динамическое масштабирование тактовой частоты происходит постоянно)

  6. недавно выполненные команды на этом или соседних ядрах (например, тяжелые инструкции AVX512 могут переключать процессор в режим L2 (лицензия 2), который замедляет тактовую частоту во избежание перегрева).

  7. с виртуализацией, все остальное может работать на том же физическом процессоре.

Вы можете попытаться смягчить влияние факторов 1, 2, 6 и 7, выполнив одну и ту же команду в цикле. Но в вашем случае это может означать, что вам нужно открыть несколько сокетов одновременно и измерить 1-ую запись для каждого из них в цикле. Таким образом, ваш кеш для загрузки в ядро ​​будет предварительно разогреваться при первом вызове, а последующие вызовы будут "чище". Вы можете усреднить это.

Чтобы помочь с 5, вы можете попытаться «разогреть» тактовую частоту процессора - запустите длинный цикл блокировки прямо перед тестом и внутри цикла тестирования, но не делайте ничего сложного в этом цикле, чтобы избежать перегрева - самый безопасный позвонить __asm("nop") внутри этого цикла.

Сначала я не заметил, что вы отправляете только 1 байт, и подумал, что это может быть связано с медленным запуском TCP . Но и ваш второй тест с простым числом не поддерживает это. Итак, это больше похоже на факторы 1, 5 или 6 из моего списка.

0 голосов
/ 11 сентября 2018

Здесь происходит несколько проблем.

Чтобы приблизиться к ответу, вам нужно, чтобы ваша клиентская сторона сделала две вещи: 1. получила все данные. 2. отслеживать, насколько большим было каждое чтение. Я сделал это:

  int loc[N+1];
int nloc, curloc;
for (nloc = curloc = 0; curloc < N; nloc++) {
    int n = read(sock, recv_buffer + curloc, sizeof recv_buffer-curloc);
    if (n <= 0) {
            break;
    }
    curloc += n;
    loc[nloc] = curloc;
}
int last = 0;
for (int i = 0; i < nloc; i++) {
    printf("%*.*s ", loc[i] - last, loc[i] - last, recv_buffer + last);
    last = loc[i];
}
printf("\n");

и определение от N до 20 (извините, воспитание) и изменение вашего сервера для записи a-z по одному байту за раз. Теперь, когда это печатает что-то вроде:

 a b c d e f g h i j k l m n o p q r s 

мы знаем, что сервер отправляет 1-байтовые пакеты; Однако, когда он печатает что-то вроде:

 a bcde fghi jklm nop qrs 

мы подозреваем, что сервер отправляет в основном 4-байтовые пакеты.

Основная проблема в том, что TCP_NODELAY не делает то, что вы подозреваете. Алгоритм Nagle, накапливает выходные данные при наличии неподтвержденного отправленного пакета; TCP_NODELAY контролирует, применяется ли это.

Независимо от TCP_NODELAY, вы по-прежнему STREAM_SOCKET, что означает, что N-записи могут быть объединены в одну. Розетка подает питание на устройство, но одновременно вы подаете питание на розетку. Как только пакет [mbuf, skbuff, ...] был зафиксирован на устройстве, сокету необходимо создать новый пакет при следующих операциях write (). Как только устройство готово к новому пакету, сокет может предоставить его, но до этого пакет будет служить буфером. В режиме буферизации запись выполняется очень быстро, поскольку доступны все необходимые структуры данных [как указано в комментариях и других ответах].

Вы можете управлять этой буферизацией, регулируя параметры сокетов SO_SNDBUF и SO_SNDLOWAT. Обратите внимание, однако, что буфер, возвращаемый методом accept, не наследует размеры буфера предоставленного сокета. Уменьшая SNDBUF до 1

Вывод ниже:

abcdefghijklmnopqrst 
a bcdefgh ijkl mno pqrst 
a b cdefg hij klm nop qrst 
a b c d e f g h i j k l m n o p q r s t 

соответствует запускается по умолчанию, затем последовательно добавляет: TCP_NODELAY, TCP_NOPUSH, SO_SNDBUF (= 1), SO_SNDLOWAT (= 1) на стороне сервера при последующих подключениях. Каждая итерация имеет меньшую дельту времени, чем предыдущая.

Ваш пробег, вероятно, будет отличаться, это было на MacOS 10.12; и я изменил ваши программы на C ++ с помощью rdtsc (), потому что у меня проблемы с доверием.

/* srv.c */
// Server side C/C++ program to demonstrate Socket programming
// #include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdbool.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <unistd.h>

#ifndef N
#define N 20
#endif
int nap = 0;
int step = 0;
extern long rdtsc(void);

void xerror(char *f) {
    perror(f);
    exit(1);
}
#define Z(x)   if ((x) == -1) { xerror(#x); }

void sopt(int fd, int opt, int val) {
    Z(setsockopt(fd, SOL_SOCKET, opt, &val, sizeof(val)));
}
int gopt(int fd, int opt) {
    int val;
    socklen_t r = sizeof(val);
    Z(getsockopt(fd, SOL_SOCKET, opt, &val, &r));
    return val;
}

#define POPT(fd, x)  printf("%s %d ", #x, gopt(fd, x))
void popts(char *tag, int fd) {
    printf("%s: ", tag);
    POPT(fd, SO_SNDBUF);
    POPT(fd, SO_SNDLOWAT);
    POPT(fd, TCP_NODELAY);
    POPT(fd, TCP_NOPUSH);
    printf("\n");
}

void stepsock(int fd) {
     switch (step++) {
     case 7:
    step = 2;
     case 6:
         sopt(fd, SO_SNDLOWAT, 1);
     case 5:
         sopt(fd, SO_SNDBUF, 1);
     case 4:
         sopt(fd, TCP_NOPUSH, 1);
     case 3:
         sopt(fd, TCP_NODELAY, 1);
     case 2:
     break;
     }
}

int main(int argc, char const *argv[])
{
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);



    // Create socket for TCP server
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    popts("original", server_fd);
    // Set TCP_NODELAY so that writes won't be batched
    while ((opt = getopt(argc, argv, "sn:o:")) != -1) {
    switch (opt) {
    case 's': step = ! step; break;
    case 'n': nap = strtol(optarg, NULL, 0); break;
    case 'o':
        for (int i = 0; optarg[i]; i++) {
            switch (optarg[i]) {
            case 't': sopt(server_fd, TCP_NODELAY, 1); break;
            case 'p': sopt(server_fd, TCP_NOPUSH, 0); break;
            case 's': sopt(server_fd, SO_SNDBUF, 1); break;
            case 'l': sopt(server_fd, SO_SNDLOWAT, 1); break;
            default:
                exit(1);
            }
        }
    }
    }
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) == -1) {
    xerror("bind");
    }
    popts("ready", server_fd);
    while (1) {
        if (listen(server_fd, 3) == -1) {
        xerror("listen");
        }

        // Accept one client connection
        new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
        if (new_socket == -1) {
        xerror("accept");
        }
            popts("accepted: ", new_socket);
        sopt(new_socket, SO_SNDBUF, gopt(server_fd, SO_SNDBUF));
        sopt(new_socket, SO_SNDLOWAT, gopt(server_fd, SO_SNDLOWAT));
        if (step) {
                stepsock(new_socket);
            }
        long tick[21];
        tick[0] = rdtsc();
        // Make N sequential writes to kernel buffer.
        for (int i = 0; i < N; i++) {
                char ch = 'a' + i;

        write(new_socket, &ch, 1);
        tick[i+1] = rdtsc();

        // For some reason, doing some blocking work between the writes
        // The following work slows down the writes by a factor of 10.
        if (nap) {
           sleep(nap);
        }
        }
        for (int i = 1; i < N+1; i++) {
        printf("%ld\n", tick[i] - tick[i-1]);
        }
        printf("_\n");

        // Print a prime to make sure the compiler doesn't optimize
        // away the computations.
        close(new_socket);
    }
}

clnt.c:

#include <stdio.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <unistd.h>

#ifndef N
#define N 20
#endif
int nap = 0;

int main(int argc, char const *argv[])
{
    int sock, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);

    // We'll be passing uint32's back and forth
    unsigned char recv_buffer[1024] = {0};

    // Create socket for TCP server
    sock = socket(AF_INET, SOCK_STREAM, 0);

    // Set TCP_NODELAY so that writes won't be batched
    setsockopt(sock, SOL_SOCKET, TCP_NODELAY, &opt, sizeof(opt));

    while ((opt = getopt(argc,argv,"n:")) != -1) {
        switch (opt) {
        case 'n': nap = strtol(optarg, NULL, 0); break;
        default:
            exit(1);
        }
    }
    opt = 1;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    // Accept one client connection
    if (connect(sock, (struct sockaddr *)&address, (socklen_t)addrlen) != 0) {
        perror("connect failed");
    exit(1);
    }
    if (nap) {
    sleep(nap);
    }
    int loc[N+1];
    int nloc, curloc; 
    for (nloc = curloc = 0; curloc < N; nloc++) {
    int n = read(sock, recv_buffer + curloc, sizeof recv_buffer-curloc);
        if (n <= 0) {
        perror("read");
        break;
    }
    curloc += n;
    loc[nloc] = curloc;
    }
    int last = 0;
    for (int i = 0; i < nloc; i++) {
    int t = loc[i] - last;
    printf("%*.*s ", t, t, recv_buffer + last);
    last = loc[i];
    }
    printf("\n");
    return 0;
}

rdtsc.s:

.globl _rdtsc
_rdtsc:
    rdtsc
    shl $32, %rdx
    or  %rdx,%rax
    ret
0 голосов
/ 08 сентября 2018

(Не совсем ответ, но нужно немного больше места, чем комментарий ...)

Это похоже на алгоритм Нэгла или его вариант, контролирующий, когда пакеты TCP фактически отправляются.

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

После перерыва в передачах, когда все посылки имеют шанс наверстать упущенное, канал будет готов к немедленной повторной отправке.

Вы можете подтвердить это, используя что-то вроде Wireshark, чтобы посмотреть фактические TCP-пакеты - это покажет, как write() запросы группируются вместе.

Честно говоря, я ожидал бы, что флаг TCP_NODELAY обойдёт это, что приведет к более равномерному распределению времени, как вы говорите. Если вы можете проверить TCP-пакеты, также стоит посмотреть, показывают ли они установленный флаг PSH, чтобы вызвать немедленную отправку.

...