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

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

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

Настройка состоит в том, что у меня есть TCP-сервер C++, который принимает одно клиентское соединение (из другого процесса на одном CPU), а при подключении к клиенту делает 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 значительно медленнее, чем другие. Если я закрою 10 000 вызовов 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 на один байт) без байпаса ядра.

Последний фрагмент данных заключается в том, что когда я настраивал тест ping-pong между двумя процессами на моем компьютере (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 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 с идеей, что это может помешать пакетной обработке (но я понимаю, что эта пакетная обработка происходит между буфером ядра и сетью, а не между пользовательским буфером и буфером ядра),

Вот код сервера для теста ping pong:

// 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 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);
    }
}
4b9b3361

Ответ 1

Здесь есть несколько вопросов.

Чтобы приблизиться к ответу, вам нужно, чтобы ваша клиентская сторона выполняла две вещи: 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 (извините, воспитание) и изменение вашего сервера для записи az по одному байту за раз. Теперь, когда это печатает что-то вроде:

 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,...] был передан устройству, сокет должен создать новый пакет на следующей записи(). Как только устройство будет готово к новому пакету, сокет может его предоставить, но до тех пор пакет будет служить в качестве буфера. В режиме буферизации запись выполняется очень быстро, так как доступны все необходимые структуры данных [как указано в комментариях и других ответах].

Вы можете управлять этой буферизацией, настроив параметры сокета 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 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

Ответ 2

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

Это звучит как алгоритм Нагле или его вариант, контролирующий при отправке TCP-пакетов.

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

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

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

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

Ответ 3

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

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

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

  1. кеш-хиты/промахи

  2. многозадачность

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

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

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

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

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

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

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

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