nginx之tcp_nodelay、tcp_nopush与sendfile
2019-09-08 16:07:01 阿炯

从Nginx模块的文档来看,这两个模块有如下的属性:

语法: tcp_nodelay on | off;
默认值: tcp_nodelay on;
上下文: http, server, location
开启或关闭nginx使用TCP_NODELAY选项的功能。 这个选项仅在将连接转变为长连接的时候才被启用,在upstream发送响应到客户端时也会启用。

语法: tcp_nopush on | off;
默认值: tcp_nopush off;
上下文: http, server, location
开启或者关闭nginx在FreeBSD上使用TCP_NOPUSH套接字选项,在Linux上使用TCP_CORK套接字选项。该选项仅在使用sendfile的时候才开启。此选项允许在Linux和FreeBSD 4.*上将响应头和正文的开始部分一起发送,一次性发送整个文件。

从模块指令的解释中带来几个问题:
(1)、tcp_nodelay的功能是什么,为什么只有在长连接(keep-alive)的时候才启用?

(2)、tcp_nopush在unix上影响TCP_NOPUSH,在linux上影响TCP_CORK,估计这只是不同操作系统上的命名区别,但作用是什么,为什么只在sendfile中才启用?


1、TCP_NODELAY

如何强制 socket 在它的缓冲区里发送数据?一个解决方案是设定 TCP 堆栈的 TCP_NODELAY选项,这样就可以使缓冲区中的数据立即发送出去。

Nginx的 TCP_NODELAY 选项使得在打开一个新的 socket 时增加了TCP_NODELAY选项。但这时会造成一种情况:终端应用程序每产生一次操作就会发送一个包,而典型情况下一个包会拥有一个字节的数据以及40个字节长的包头,于是产生4000%的过载,很轻易地就能令网络发生拥塞。为了避免这种情况,TCP堆栈实现了等待数据0.2秒钟,因此操作后它不会发送一个数据包,而是将这段时间内的数据打成一个大的包。这一机制是由Nagle算法保证。也即当TCP传输小于MSS的包时不会立即发生,会缓冲一小段时间,当之前发生的包被ack后才继续发生缓冲中的小包。

Nagle和DelayedAcknowledgment的延迟问题

Nagle:

在网络拥塞控制领域,我们知道有一个非常有名的算法叫做Nagle算法(Nagle algorithm),这是使用它的发明人John Nagle的名字来命名的,John Nagle在1984年首次用这个算法来尝试解决福特汽车公司的网络拥塞问题(RFC 896),该问题的具体描述是:如果我们的应用程序一次产生1个字节的数据,而这个1个字节数据又以网络数据包的形式发送到远端服务器,那么就很容易导致网络由于太多的数据包而过载。比如,当用户使用Telnet连接到远程服务器时,每一次击键操作就会产生1个字节数据,进而发送出去一个数据包,所以,在典型情况下,传送一个只拥有1个字节有效数据的数据包,却要发费40个字节长包头(即ip头20字节+tcp头20字节)的额外开销,这种有效载荷(payload)利用率极其低下的情况被统称之为愚蠢窗口症候群(Silly Window Syndrome)。可以看到,这种情况对于轻负载的网络来说,可能还可以接受,但是对于重负载的网络而言,就极有可能承载不了而轻易的发生拥塞瘫痪。

Nagle化后来成了一种标准并且立即在因特网上得以实现。它现在已经成为默认配置了,但有些场合下把这一选项关掉也是合乎需要的。现在假设某个应用程序发出了一个请求,希望发送小块数据。我们可以选择立即发送数据或者等待产生更多的数据然后再一次发送两种策略。如果我们马上发送数据,那么交互性的以及客户/服务器型的应用程序将极大地受益。如果请求立即发出那么响应时间也会快一些。以上操作可以通过设置套接字的 TCP_NODELAY = on 选项来完成,这样就禁用了Nagle算法(即不需要等待0.2s)。

假如需要频繁的发送一些小包数据,比如说1个字节,以IPv4为例的话,则每个包都要附带40字节的头,也就是说,总计41个字节的数据里,其中只有1个字节是我们需要的数据。为了解决这个问题,出现了上述的Nagle算法。它规定:如果包的大小满足MSS,那么可以立即发送,否则数据会被放到缓冲区,等到已经发送的包被确认了之后才能继续发送。通过这样的规定,可以降低网络里小包的数量,从而提升网络性能。

DelayedAcknowledgment:

假如需要单独确认每一个包的话,那么网络中将会充斥着无数的ACK,从而降低了网络性能。为了解决这个问题,DelayedAcknowledgment规定:不再针对单个包发送ACK,而是一次确认两个包,或者在发送响应数据的同时捎带着发送ACK,又或者触发超时时间后再发送ACK。通过这样的规定,可以降低网络里ACK的数量,从而提升网络性能。

Nagle和DelayedAcknowledgment是如何影响性能的

Nagle和DelayedAcknowledgment虽然都是好心,但是它们在一起的时候却会办坏事。

如果一个 TCP 连接的一端启用了 Nagle's Algorithm,而另一端启用了 TCP Delayed Ack,而发送的数据包又比较小,则可能会出现这样的情况:发送端在等待接收端对上一个packet 的 Ack 才发送当前的 packet,而接收端则正好延迟了此 Ack 的发送,那么这个正要被发送的 packet 就会同样被延迟。当然 Delayed Ack 是有个超时机制的,而默认的超时正好就是 40ms。

现代的 TCP/IP 协议栈实现,默认几乎都启用了这两个功能,你可能会想,按我上面的说法,当协议报文很小的时候,岂不每次都会触发这个延迟问题?

事实不是那样的,仅当协议的交互是发送端连续发送两个 packet,然后立刻 read 的 时候才会出现问题。

现在让我们假设某个应用程序发出了一个请求,希望发送小块数据。我们可以选择立即发送数据或者等待产生更多的数据然后再一次发送两种策略。

如果我们马上发送数据,那么交互性的以及客户/服务器型的应用程序将极大地受益。例如,当我们正在发送一个较短的请求并且等候较大的响应时,相关过载与传输的数据总量相比就会比较低,而且,如果请求立即发出那么响应时间也会快一些。

以上操作可以通过设置套接字的TCP_NODELAY选项来完成,这样就禁用了Nagle 算法。

另外一种情况则需要我们等到数据量达到最大时才通过网络一次发送全部数据,这种数据传输方式有益于大量数据的通信性能,典型的应用就是文件服务器。使用Nagle算法在这种情况下就会产生问题。但是如果你正在发送大量数据,你可以设置TCP_CORK选项禁用Nagle化,其方式正好同 TCP_NODELAY相反(TCP_CORK 和 TCP_NODELAY 是互相排斥的)。

假设客户端的请求发生需要等待服务端的应答后才能继续发生下一包,即串行执行,好比在用ab性能测试时只有一个并发做10k的压力测试,测试地址返回的内容只有Hello world;ab发出的request需要等待服务器返回response时,才能发生下一个request;此时ab只会发生一个get请求,请求的相关内容包含在header中;而服务器需要返回两个数据,一个是response头,另一个是html body;

服务器发送端发送的第一个 write 是不会被缓冲起来,而是立刻发送的(response header),这时ab接收端收到对应的数据,但它还期待更多数据(html)才进行处理,所以不会往回发送数据,因此也没机会把 Ack 给带回去,根据Delayed Ack 机制, 这个 Ack 会被 Hold 住。

这时服务器发送端发送第二个包,而队列里还有未确认的数据包(response header),这个 packet(html) 会被缓冲起来。此时,服务器发送端在等待ab接收端的 Ack;ab接收端则在 Delay 这个 Ack,所以都在等待,直到ab接收端 Deplayed Ack 超时(40ms),此 Ack 被发送回去,发送端缓冲的这个 packet(html) 才会被真正送到接收端,此时ab才接受到完整的数据,进行对应的应用层处理,处理完成后才继续发生下一个request,因此服务器端才会在read时出现40ms的阻塞。


TCP_CORK模式

TCP_CORK选项的功能类似于在发送数据管道出口处插入一个"塞子",使得发送数据全部被阻塞,直到取消TCP_CORK选项(即拔去塞子)或被阻塞数据长度已超过MSS才将其发送出去。

选项TCP_NODELAY是禁用Nagle算法,即数据包立即发送出去,而选项TCP_CORK与此相反,可以认为它是Nagle算法的进一步增强,即阻塞数据包发送,具体点说就是:TCP_CORK选项的功能类似于在发送数据管道出口处插入一个"塞子",使得发送数据全部被阻塞,直到取消TCP_CORK选项(即拔去塞子)或被阻塞数据长度已超过MSS才将其发送出去。

举个对比示例,比如收到接收端的ACK确认后,Nagle算法可以让当前待发送数据包发送出去,即便它的当前长度仍然不够一个MSS,但选项TCP_CORK则会要求继续等待,这在前面的tcp_nagle_check()函数分析时已提到这一点,即如果包数据长度小于当前MSS &&((加塞 || …)|| …),那么缓存数据而不立即发送:

在TCP_NODELAY模式下,假设有3个小包要发送,第一个小包发出后,接下来的小包需要等待之前的小包被ack,在这期间小包会合并,直到接收到之前包的ack后才会发生;

而在TCP_CORK模式下,第一个小包都不会发生成功,因为包太小,发生管道被阻塞,同一目的地的小包彼此合并后组成一个大于mss的包后,才会被发送。

 
TCP_CORK选项"堵塞"特性的最终目的无法是为了提高网络利用率,既然反正是要发一个数据包(零窗口探测包),如果有实际数据等待发送,那么干脆就直接发送一个负载等待发送数据的数据包岂不是更好?

我们已经知道,TCP_CORK选项的作用主要是阻塞小数据发送,所以在nginx内的用处就在对响应头的发送处理上。一般而言,处理一个客户端请求之后的响应数据包括有响应头和响应体两部分,那么利用TCP_CORK选项就能让这两部分数据一起发送:

假设我们需要等到数据量达到最大时才通过网络一次发送全部数据,这种数据传输方式有益于大量数据的通信性能,典型的应用就是文件服务器。应用Nagle算法在这种情况下就会产生问题。因为TCP_NODELAY在发生小包时不再等待之前的包有没有ack,网络中会存在较多的小包,但这会影响网络的传输能力;但如果正在发送大量数据,你可以设置TCP_CORK选项禁用Nagle化,其方式正好同 TCP_NODELAY相反(TCP_CORK 和 TCP_NODELAY 是互相排斥的)。

下面就让我们仔细分析下其工作原理

假设应用程序使用sendfile()函数来转移大量数据。应用协议通常要求发送某些信息来预先解释数据,这些信息其实就是报头内容。典型情况下报头很小,而且套接字上设置了TCP_NODELAY。有报头的包将被立即传输,在某些情况下(取决于内部的包计数器),因为这个包成功地被对方收到后需要请求对方确认。这样,大量数据的传输就会被推迟而且产生了不必要的网络流量交换。

但是,如果我们在套接字上设置了TCP_CORK(可以比喻为在管道上插入"塞子")选项,具有报头的包就会填补大量的数据,所有的数据都根据大小自动地通过包传输出去。当数据传输完成时,最好取消TCP_CORK 选项设置给连接"拔去塞子"以便任一部分的帧都能发送出去。这同"塞住"网络连接同等重要。

总而言之,如果你肯定能一起发送多个数据集合(例如HTTP响应的头和正文),那么建议设置TCP_CORK选项,这样在这些数据之间不存在延迟。能极大地有益于WWW、FTP以及文件服务器的性能,同时也简化了开发工作。


tcp_nodelay为什么只在keep-alive才启作用

TCP中的Nagle算法默认是启用的,但是它并不是适合任何情况,对于telnet或rlogin这样的远程登录应用的确比较适合(原本就是为此而设计),但是在某些应用场景下我们却又需要关闭它。

在Apache对HTTP持久连接(Keep-Alive,Prsistent-Connection)处理时凸现的奇数包&结束小包问题(The Odd/Short-Final-Segment Problem),这是一个并的关系,即问题是由于已有奇数个包发出,并且还有一个结束小包(在这里,结束小包并不是指带FIN旗标的包,而是指一个HTTP请求或响应的结束包)等待发出而导致的。

我们来看看具体的问题详情,以3个包+1个结束小包为例,可能发生的发包情况:
服务器向客户端发出两个大包;客户端在接受到两个大包时,必须回复ack;
接着服务器向客户端发送一个中包或小包,但服务器由于Delayed Acknowledgment并没有马上ack;
由于发生队列中有未被ack的包,因此最后一个结束的小包被阻塞等待。



最后一个小包包含了整个响应数据的最后一些数据,所以它是结束小包,如果当前HTTP是非持久连接,那么在连接关闭时,最后这个小包会立即发送出去,这不会出现问题;

但如果当前HTTP是持久连接(非pipelining处理,pipelining仅HTTP 1.1支持,nginx目前对pipelining的支持很弱,它必须是前一个请求完全处理完后才能处理后一个请求),即进行连续的Request/Response、Request/Response、…,处理,那么由于最后这个小包受到Nagle算法影响无法及时的发送出去(具体是由于客户端在未结束上一个请求前不会发出新的request数据,导致无法携带ACK而延迟确认,进而导致服务器没收到客户端对上一个小包的的确认导致最后一个小包无法发送出来),导致第n次请求/响应未能结束,从而客户端第n+1次的Request请求数据无法发出。

在http长连接中,服务器的发生类似于:Write-Write-Read,即返回response header、返回html、读取下一个request。而在http短连接中,服务器的发生类似于:write-read-write-read,即返回处理结果后,就主动关闭连接,短连接中的close之前的小包会立即发生,不会阻塞。

我的理解是这样的:因为第一个 write 不会被缓冲,会立刻到达接收端,如果是 write-read-write-read 模式,此时接收端应该已经得到所有需要的数据以进行下一步处理。接收端此时处理完后发送结果,同时也就可以把上一个packet 的 Ack 可以和数据一起发送回去,不需要 delay,从而不会导致任何问题。

我做了一个简单的试验,注释掉了 HTTP Body 的发送,仅仅发送 Headers, Content-Length 指定为 0。这样就不会有第二个 write,变成了 write-read-write-read 模式。此时再用 ab 测试,果然没有 40ms 的延迟了。

因此在短连接中并不存在小包阻塞的问题,而在长连接中需要做tcp_nodelay开启。


2、TCP_NOPUSH
在 nginx 中,tcp_nopush 配置和 tcp_nodelay 是"互斥"的。它可以配置一次发送数据的包大小,也就是说,它不是按时间累计 0.2 秒后发送包,而是当包累计到一定大小后就发送。

注:在 nginx 中,tcp_nopush 必须和 sendfile 搭配使用。

3、sendfile

现在流行的web服务器里面都提供sendfile选项用来提高服务器性能,那到底sendfile是什么,怎么影响性能的呢?sendfile实际上是 Linux 2.0+以后的推出的一个系统调用,web服务器可以通过调整自身的配置来决定是否利用sendfile这个系统调用。先来看一下不用 sendfile的传统网络传输过程:
read(file,tmp_buf, len);
write(socket,tmp_buf, len);


硬盘 >> kernel buffer >> user buffer>> kernel socket buffer >>tcp/ip协议栈

从技术角度来看,sendfile()是磁盘和传输控制协议(TCP)之间的一种系统呼叫,但是sendfile()还能够用来在两个文件夹之间移动数据。在各种不同的操作系统上实现sendfile()都会有所不同,当然这种不同只是极为细微的差别。通常来说,我们会假定所使用的操作系统是Linux核心2.4版本。

系统呼叫的原型有如下几种:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count)
in_fd 是一种用来读文件的文件描述符。
out_fd 是一种用来写文件的描述符。
Offset 是一种指向被输入文件变量位置的指针,sendfile()将会从它所指向的位置开始数据的读取。
Count 表示的是两个文件描述符之间数据拷贝的字节数。



sendfile()的威力在于为大家提供了一种访问当前不断膨胀的Linux网络堆栈的机制。这种机制叫做"零拷贝(zero-copy)",这种机制可以把"传输控制协议(TCP)"框架直接的从主机存储器中传送到网卡的缓存块(network card buffers)中去。

为了更好的理解"零拷贝(zero-copy)"以及sendfile(),让我们整理以前在传送文件时所需要执行的那些步骤。

首先,一块在用户机器存储器内用于数据缓冲的位置先被确定了下来。然后,我们必须使用read()这条系统呼叫来把数据从文件中拷贝到前边已经准备好的那个缓冲区中去。(在通常的情况下,这个操做会把数据从磁盘上拷贝到操作系统的高速缓冲存储器中去,然后才会把数据从高速缓冲存储器中拷贝至用户空间中去,这种过程就是所谓的"上下文切换"。)

在完成了上述的那些步骤之后,我们得使用write()系统呼叫来将缓冲区中的内容发送到网络上去,程序段如下所示:
intout_fd, intin_fd;
char buffer[BUFLEN];

/* unsubstantial code skipped for clarity */

read(in_fd, buffer, BUFLEN); /* syscall, make context switch */
write(out_fd, buffer, BUFLEN); /* syscall, make context switch */


操作系统核心不得不把所有的数据至少都拷贝两次:先是从核心空间到用户空间的拷贝,然后还得再从用户空间拷贝回核心空间。每一次操作都需要上下文切换(context-switch)的这个步骤,其中包含了许多复杂的高度占用CPU的操作。系统自带的工具vmstat能够用来在绝大多数UNIX以及与其类似的操作系统上显示当前的"上下文切换(context-switch)"速率。请看叫做"CS"的那一栏,有相当一部分的上下文切换是发生在取样期间的。用不同类型的方式进行装载可以让使用者清楚的看到使用这些参数进行装载时的不同效果。

在有了sendfile()零拷贝(zero-copy)之后,如果可能的话,通过使用直接存储器访问(Direct Memory Access)的硬件设备,数据从磁盘读取到操作系统高速缓冲存储器中会变得非常之迅速。而TLB高速缓冲存储器则被完整无缺的放在那里,没有充斥任何有关数据传输的文件。

应用软件在使用sendfile() primitive的时候会有很高的性能表现,这是因为系统呼叫没有直接的指向存储器,因而就提高了传输数据的性能。通常来说,要被传输的数据都是从系统缓冲存储器中直接读取的,其间并没有进行上下文切换的操作,也没有垃圾数据占据高速缓冲存储器。因此,在服务器应用程序中使用sendfile()能够显著的减少对CPU的占用。

TCP/IP网络的数据传输通常建立在数据块的基础之上。从程序员的观点来看,发送数据意味着发出(或者提交)一系列"发送数据块"的请求。在系统层级上看,发送单个数据块可以通过调用系统函数write() 或者sendfile() 来完成。因为在网络连接中是由程序员来选择最适当的应用协议,所以网络包的长度和顺序都在程序员的控制之下。同样的,程序员还必须选择这个协议在软件中得以实现的方式。

TCP/IP协议自身已经有了多种可互操作的实现,所以在双方通信时,每一方都有它自身的低级行为,这也是程序员所应该知道的情况。尽管有许多TCP选项可供程序员操作,而我们却最关注如何处置其中的两个选项,它们是TCP_NODELAY和TCP_CORK,这两个选项都对网络连接的行为具有重要的作用。许多UNIX系统都实现了TCP_NODELAY选项,但是TCP_CORK则是Linux系统所独有的而且相对较新;它首先在内核版本2.4上得以实现。此外,其他UNIX系统版本也有功能类似的选项,值得注意的是,在某种由BSD派生的系统上的TCP_NOPUSH选项其实就是TCP_CORK的一部分具体实现。


1)、一般来说一个网络应用是通过读硬盘数据,然后写数据到socket 来完成网络传输的。上面2行用代码解释了这一点,不过上面2行简单的代码掩盖了底层的很多操作。来看看底层是怎么执行上面2行代码的:
    系统调用 read()产生一个上下文切换:从 user mode 切换到 kernel mode,然后 DMA 执行拷贝,把文件数据从硬盘读到一个 kernel buffer 里。
    数据从 kernel buffer拷贝到 user buffer,然后系统调用 read() 返回,这时又产生一个上下文切换:从kernel mode 切换到 user mode。
    系统调用write()产生一个上下文切换:从 user mode切换到 kernel mode,然后把步骤2读到 user buffer的数据拷贝到 kernel buffer(数据第2次拷贝到 kernel buffer),不过这次是个不同的 kernel buffer,这个 buffer和 socket相关联。
    系统调用 write()返回,产生一个上下文切换:从 kernel mode 切换到 user mode(第4次切换了),然后 DMA 从 kernel buffer拷贝数据到协议栈(第4次拷贝了)。


上面4个步骤有4次上下文切换,有4次拷贝,如果能减少切换次数和拷贝次数将会有效提升性能。在kernel 2.0+ 版本中,系统调用sendfile() 就是用来简化上面步骤提升性能的。sendfile() 不但能减少切换次数而且还能减少拷贝次数。

2)、再来看一下用 sendfile()来进行网络传输的过程:
sendfile(socket,file, len);

硬盘 >> kernel buffer (快速拷贝到kernel socket buffer) >>协议栈

系统调用sendfile()通过DMA把硬盘数据拷贝到 kernel buffer,然后数据被 kernel直接拷贝到另外一个与 socket相关的 kernel buffer。这里没有 user mode和 kernel mode之间的切换,在 kernel中直接完成了从一个 buffer到另一个 buffer的拷贝。

DMA 把数据从 kernelbuffer 直接拷贝给协议栈,没有切换,也不需要数据从 user mode 拷贝到 kernel mode,因为数据就在 kernel 里。这样步骤减少了、切换减少了、拷贝减少了,自然性能就提升了。这就是为什么说在Nginx 配置文件里打开 sendfile on 选项能提高 web server性能的原因。

数据传输并不需要总是准确地遵守某一选项或者其它选择。在不同的情况下,你可能想要采取更为灵活的措施来控制网络连接:
在发送一系列当作单一消息的数据之前设置TCP_CORK,而且在发送应立即发出的短消息之前设置TCP_NODELAY。如果需要提供网络的传输效率,应该减少小包的传输,使用TCP_CORK来做汇总传输,再利用sendfile来提高效率;但如果是交互性强的业务,那应该让任意小包可以快速传输,关闭Nagle算法,提高包的传输效率。

TCP_CORK优化了传输的bits效率,tcp_nodelay优化了传输的packet效率。

综上,这三个参数都应该配置成on:sendfile on; tcp_nopush on; tcp_nodelay on;


DMA


直接内存访问(Direct Memory Access,DMA)是计算机科学中的一种内存访问技术。它允许某些计算机内部的硬件子系统(计算机外设),可以独立地直接读写系统内存,而不需中央处理器(CPU)介入处理 。在同等程度的处理器负担下,DMA是一种快速的数据传送方式。很多硬件的系统会使用DMA,包含硬盘控制器、绘图显卡、网卡和声卡。


参考来源

TCP_NODELAY 和 TCP_NOPUSH的解释