Nagle 算法与 TCP socket 选项 TCP_CORK

Nagle 算法

  Nagle算法由John Nagle在1984年提出,这个算法可以减少网络中小的packet的数量,从而降低网络的拥塞程度。一个常见的例子就是Telnet程序,用户在控制台的每次击键都会发送一个packet,这个packet通常包含41个字节,然而只有一个字节是payload,其余40个字节都是header,如果每次击键都发送一个packet,那就会造成了巨大的开销。
  为了减小这种开销,Nagle算法指出,当TCP发送了一个小的segment(小于MSS),它必须等到接收了对方的ACK之后,才能继续发送另一个小的segment。那么在等待的过程中(一个RTT时间),TCP就能尽量多地将要发送的数据收集在一起,从而减少要发送的segment的数量。
  默认情况下,TCP开启了Nagle算法,然而Nagle算法并不是灵丹妙药,它会增加TCP发送数据的延迟。在一些要求低延迟的应用程序中(例如即时通讯应用),则需要禁用Nagle算法:

1
2
int optval = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &optval, sizeof(optval));

Delayed ACK

  TCP的 Delayed ACK 与Nagle算法有异曲同工之妙,Delayed ACK很好理解,当TCP接收到数据时,并不会立即发送ACK给对方,相反,它会等待应用层产生数据,以便将ACK和数据一起发送(在Linux最多等待40ms)。
  我们知道,Nagle算法会增加TCP发送数据的延迟,然而,在某些情况下,Delayed ACK会放大这种延迟。一个常见的例子客户端HTTP POST协议,首先看看客户端的代码:

1
2
3
4
write(http_request_header);
write(http_request_body);
// get response from server ...

  客户端调用两次write()来发送HTTP POST请求,服务端则需要调用两次read()读取客户端的HTTP请求:

1
2
3
4
http_request_header = read(...);
http_request_body = read(...);
// write response to client ...

  通常来说,HTTP请求的header和body都是小的segment,但由于TCP默认开启Nagle算法,因此客户端在发送请求的header之后,如果还未收到ACK,则不能发送body。




  Server在收到请求的header之后,由于还没有收到请求的body,因此无法立即产生HTTP响应给客户端,这就导致了http_request_header的ACK大概会延迟40ms才发送。
  为避免这种延迟的出现,需要做两件事:

  • 设置TCP_NODELAY选项。
  • 将客户端的两次write()合并成一个,避免服务端的Delayed ACK。

Scatter-Gather I/O

  write()函数负责将应用程序缓冲区的数据写入内核缓冲区中,那么合并两次write()操作,一种常见的做法是先分配一块比较大的缓冲区,接着将两块小的缓冲区的数据依次拷贝到这块大的缓冲区中,最后调用write()一次性写入这块大的缓冲区的数据。然而这种做法带来不小的开销,一次内存分配操作和两次memcpy()操作。幸运的是,Linux提供了writev()函数,它可以将几块不连续的缓冲区的数据写入内核中。

1
2
3
4
5
6
struct iovec iov[2];
iov[0].iov_base = http_request_header;
iov[0].iov_len = sizeof(http_request_header);
iov[1].iov_base = http_request_body;
iov[1].iov_len = sizeof(http_request_body);
ssize_t nwritten = writev(fd, iov, 2);

TCP_CORK

  大多数Web Server为了提高性能,在发送数据是并不会直接使用write(),一个典型的例子就是,Web Server响应客户端请求的时候,它需要先发送HTTP响应header,接着发送网页的内容,而网页的内容存在于磁盘中,为了减少数据的拷贝开销,通常是使用sendfile()去发送页面内容的,这种情况下,应用程序就不需要在用户态分配内存来存储页面内容了。

1
2
3
4
5
const char *filename = "index.html";
fd = open(filename, O_RDONLY)
write(http_resp_header);
sendfile(sockfd, fd, &off, len);

  为了发送HTTP响应,Server调用了一次write()和一次sendfile(),在开启TCP_NODELAY的情况下,这会导致至少两个TCP segment发送出去。但更多时候页面的数据是很少的,在这种情况下,write()会发送一个segment,sendfile()也会发送一个segment,那么有没有办法让这两个segment合并在一起再发送出去呢?
  为解决这个问题,Linux提供了TCP_CORK选项,如果在某个TCP socket上开启了这个选项,那就相当于在这个socket的出口堵上了塞子,往这个socket写入的数据都会聚集起来。虽然堵上了塞子,但是segment总得发送,不然数据会塞满整个TCP发送缓冲区的,那么什么时候塞子会打开呢?下面几种情况都会导致这个塞子打开,这样TCP就能继续发送segment出来了。

  • 程序取消设置TCP_CORK这个选项。
  • socket聚集的数据大于一个MSS的大小。
  • 自从堵上塞子写入第一个字节开始,已经经过200ms。
  • socket被关闭了。

  一旦满足上面的任何一个条件,TCP就会将数据发送出去。对于Server来说,发送HTTP响应既要发送尽量少的segment,同时又要保证低延迟,那么需要在写完数据后显式取消设置TCP_CORK选项,让数据立即发送出去:

1
2
3
4
5
6
7
8
int state = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_CORK, &state, sizeof(state));
write(http_resp_header);
sendfile(sockfd, fd, &off, len);
state = 0;
setsockopt(sockfd, IPPROTO_TCP, TCP_CORK, &state, sizeof(state));

参考资料