如果在多个系统调用中完成,为啥 TCP 套接字会变慢?

Posted

技术标签:

【中文标题】如果在多个系统调用中完成,为啥 TCP 套接字会变慢?【英文标题】:Why does TCP socket slow down if done in multiple system calls?如果在多个系统调用中完成,为什么 TCP 套接字会变慢? 【发布时间】:2015-08-28 15:43:54 【问题描述】:

为什么下面的代码很慢?我所说的慢是指慢 100 倍到 1000 倍。它只是直接在 TCP 套接字上重复执行读/写。奇怪的是,只有当我使用两个函数调用来读取和写入时,它仍然很慢,如下所示。如果我更改服务器或客户端代码以使用单个函数调用(如在 cmets 中),它会变得超级快。

代码sn-p:

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;

编辑:此版本与其他 sn-p 版本略有不同,因为它在循环中读取/写入。所以在这个版本中,两个单独的写入会自动导致两个单独的read() 调用,即使readLoop 只被调用一次。但除此之外,问题仍然存在。

【问题讨论】:

【参考方案1】:

有趣。您是Nagle's algorithm 和TCP delayed acknowledgements 的受害者。

Nagle 算法是 TCP 中使用的一种机制,用于延迟小段的传输,直到积累了足够的数据以使其值得构建并通过网络发送段。来自***文章:

Nagle 的算法通过结合一些小的传出 消息,并一次发送所有消息。具体来说,只要有 是发送方未收到确认的已发送数据包, 发送者应该继续缓冲它的输出,直到它有一个完整的 数据包的输出,因此可以一次发送所有输出。

但是,TCP 通常采用称为 TCP 延迟确认的技术,这是一种将一批 ACK 回复累积在一起的技术(因为 TCP 使用累积 ACKS)来减少网络流量。

那篇***文章进一步提到了这一点:

启用这两种算法后,执行两个连续的应用程序 写入 TCP 连接,然后读取不会被 直到第二次写入的数据到达 目的地,经历长达 500 毫秒的持续延迟, “ACK 延迟”

(强调我的)

在您的具体情况下,由于服务器在读取回复之前没有发送更多数据,因此客户端导致延迟:如果客户端写入 两次,the second write will be delayed。

如果发送方正在使用 Nagle 算法,数据将被 由发送方排队,直到收到 ACK。如果发件人不 发送足够的数据来填充最大段大小(例如,如果它 执行两次小写入,然后是阻塞读取)然后 传输将暂停到 ACK 延迟超时。

所以,当客户端进行 2 次写入调用时,会发生以下情况:

    客户端发出第一次写入。 服务器接收到一些数据。它不承认它,希望有更多的数据到达(因此它可以在一个 ACK​​ 中批量处理一堆 ACK)。 客户端发出第二次写入。之前的写入尚未得到确认,因此 Nagle 的算法会延迟传输,直到有更多数据到达(直到收集到足够的数据来组成一个段)或之前的写入被 ACK。 服务器厌倦了等待,并在 500 毫秒后确认该段。 客户端终于完成第二次写入。

1 次写入,会发生以下情况:

    客户端发出第一次写入。 服务器接收到一些数据。它不承认它,希望有更多的数据到达(因此它可以在一个 ACK​​ 中批量处理一堆 ACK)。 服务器写入套接字。 ACK 是 TCP 标头的一部分,因此如果您正在编写,您不妨免费确认前一个段。去做吧。 同时,客户端写入了一次,因此它已经在等待下一次读取 - 没有第二次写入等待服务器的 ACK

如果您想在客户端继续写入两次,则需要禁用 Nagle 算法。这是算法作者自己提出的解决方案:

用户级别的解决方案是避免在 插座。写-读-写-读很好。写写写很好。但 写-写-读是一个杀手。所以,如果可以的话,缓冲你的小 写入 TCP 并一次发送它们。使用标准 UNIX I/O 在每次读取之前打包和刷新写入通常有效。

(See the citation on Wikipedia)

As mentioned by David Schwartz in the comments,由于各种原因,这可能不是最好的想法,但它说明了这一点并表明这确实导致了延迟。

要禁用它,您需要在带有setsockopt(2) 的套接字上设置TCP_NODELAY 选项。

这可以在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);
    

【讨论】:

@Samee 我仍在对此进行一些调查,但您说得对,它不取决于特定的大小。在服务器上添加 4 次 10 字节读取和 2 次 20 字节写入客户端使其再次变慢(禁用TCP_NODELAY)。我会尽快更新我的答案并提供更多细节。 从我的脑后:Nagle 允许实现延迟,直到它可以发送一个完整大小的数据包(即最大段大小,因此通常为 536,但可能取决于接口)或直到发生超时.所以触发发送所需的神奇字节数可能 >= 536。 禁用 Nagle 是个坏建议。您应该只更改代码以大块编写。无论如何,您都必须更改为使用大块,因为无论是否禁用 Nagle,您仍然需要写入大块以获得有效的网络利用率。并且通过禁用 Nagle,您会在异常情况下失去 Nagle 的好处。 @DavidSchwartz:不幸的是,重写代码并不总是一种选择。我正在为各种加密协议编写此代码,有时,数据流本质上只是交互的。据我所知,这里没有任何 Nagle 对我真正有用的情况。如果您可以建议 Nagle 帮助的常见工作负载,我可以检查我是否拥有/将拥有它们。 第二个是当您的应用程序生成数据块的频率高于到远程的往返时间,并且数据速度与远程使用数据的速度相当或更大,并且在平均速率不超过可用带宽的几个数量级;如果没有 Nagle,这可能会导致“愚蠢的窗口综合症”,其中广告的接收窗口会缩小到很小的大小,从而导致每个数据包的有效负载非常小。数据包标头开销开始主导有效负载,导致带宽使用效率非常低。 (续)

以上是关于如果在多个系统调用中完成,为啥 TCP 套接字会变慢?的主要内容,如果未能解决你的问题,请参考以下文章

连续性多个 WSASend() io 完成端口

在c ++中使用multipe recv()和send()调用

套接字之sendto系统调用

在 TCP 套接字程序中,客户端发送一些数据,但服务器需要多次读取。为啥?

什么叫套接字。Socket?

套接字之recvfrom系统调用