为什么在工作交错时TCP写入延迟会变得更糟?

时间:2018-09-03 16:11:50

标签: c performance tcp linux-kernel

我一直在分析TCP延迟(特别是从用户空间到小消息的内核空间的write),以便对write的延迟有一些直观的认识(确认这可以针对特定上下文)。我注意到在我看来相似的测试之间存在很大的不一致,并且我很好奇找出差异的根源。我知道微基准测试可能会有问题,但是我仍然觉得我缺少一些基本的了解(因为延迟差异约为10倍)。

设置是,我有一个C ++ TCP服务器,该服务器接受一个客户端连接(来自同一CPU上的另一个进程),并且在与客户端连接时对套接字进行write的20个系统调用,然后发送一次一个字节。服务器的完整代码复制在这篇文章的末尾。以下是使用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呼叫,则平均每个write为2微秒,而第一个呼叫始终为15个以上的麦克风。为什么会出现这种“热身”现象?

相关地,我进行了一个实验,其中在每个write调用之间,我进行了一些阻塞的CPU工作(计算大质数)。这会导致{strong>全部 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需要多长时间将缓冲区从用户空间复制到内核空间的概念。如果有某种凝聚作用,当我连续进行10,000次操作时,平均write只需要2麦克风,那么得出write延迟是2麦克风的结论是不公平的乐观。我的直觉似乎是每个write都需要20微秒。对于没有内核绕过的情况(对于一个字节的原始write调用)而言,它似乎具有最低的延迟,这似乎出奇地慢。

最后一条数据是,当我在计算机上的两个进程(TCP服务器和TCP客户端)之间设置乒乓测试时,每次往返平均6麦克风(其中包括{{1} },read,以及在本地网络中移动)。这似乎与上面看到的单次写入的20个麦克风延迟不符。

TCP服务器的完整代码:

write

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]);

}

我尝试了带有和不带有标志// 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_NODELAYTCP_NOPUSHSO_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);
}

3 个答案:

答案 0 :(得分:4)

(不是一个完整的答案,但是需要比注释更多的空间...)

这听起来像Nagle's algorithm或它的一种变体,用于控制TCP数据包的实际发送时间。

对于第一次写入,当“管道”中没有未经确认的数据时,将立即发送该数据,这需要一些时间。对于此后不久的后续写入,管道中仍将有未确认的数据,因此可以在发送缓冲区中排队少量数据,这更快。

传输中断后,当所有发送都有机会赶上时,管道将准备好立即再次发送。

您可以使用Wireshark之​​类的东西查看实际的TCP数据包来确认这一点-这将显示write()请求是如何分组在一起的。

说句公道话,我希望TCP_NODELAY标志绕过它-导致您所说的时间更加均匀。如果可以检查TCP数据包,也值得一看它们是否显示了PSH标志集,以强制立即发送。

答案 1 :(得分:3)

这里有一些问题。

要接近答案,您需要让客户端执行以下两项操作: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上;并且由于存在信任问题,我使用rdtsc()将程序更改为C ++。

/* 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

答案 2 :(得分:1)

(不确定这是否有帮助,但我没有足够的声誉来发表评论)

微基准测试是棘手的,尤其是在OS调用时-根据我的经验,在最终得出数字之前,几乎不需要考虑,滤除或衡量因素。

其中一些因素是:

  1. 缓存命中/未命中

  2. 多任务抢占

  3. OS在API调用的某些时刻分配内存(内存分配很容易导致微秒级的延迟)

  4. 延迟加载(在connect调用过程中,某些API可能不会做很多事情,直到输入真实数据为止)

  5. 当前CPU的实际时钟速度(动态时钟缩放,一直发生)

  6. 此内核或相邻内核上最近执行的命令(例如,繁重的AVX512指令可能会将CPU切换到L2(许可证2)模式,这会降低时钟速度以避免过热)。

  7. 具有虚拟化功能,其他任何东西都可以在同一物理CPU上运行。

您可以尝试通过循环重复运行同一命令来减轻因素1、2、6和7的影响。但是,根据您的情况,这可能意味着您需要一次打开多个套接字,并在一个周期中测量对每个套接字的第一次写入。这样,您进入内核的缓存将在第一个调用时被预热,以后的调用将拥有“更干净”的时间。您可以将其取平均值。

要获得5的帮助,您可以尝试“预热” CPU时钟-在测试之前和测试循环内运行一个较长的阻塞周期,但是在该周期中不要做任何花招以避免过热-最安全的方法是在该周期内调用__asm("nop")

起初,我没有注意到您仅发送1个字节,并认为这可能是由于TCP slow start引起的。但是您的第2个质数测试也不支持此功能。因此,这听起来更像是我列表中的因子1、5或6。