神秘的40毫秒延迟与 TCP_NODELAY

最近的业余时间几乎所有献给 breeze 这个多年前挖 下的大坑—— 一个异步 HTTP Server。努力没有白费,项目已经逐渐成型了, 基本的框架已经有了,一个静态 文件模块也已经实现了。 css

写 HTTP Server,不可免俗地必定要用 ab 跑一下性能,结果一跑不打紧,出现了一个困扰了我好几天的问题:神秘的 40ms 延迟。 html

1 现象

现象是这样的,首先看我用 ab 不加 -k 选项的结果: node

[~/dev/personal/breeze]$ /usr/sbin/ab  -c 1 -n 10 http://127.0.0.1:8000/styles/shThemeRDark.css
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient).....done


Server Software:        breeze/0.1.0
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /styles/shThemeRDark.css
Document Length:        127 bytes

Concurrency Level:      1
Time taken for tests:   0.001 seconds
Complete requests:      10
Failed requests:        0
Write errors:           0
Total transferred:      2700 bytes
HTML transferred:       1270 bytes
Requests per second:    9578.54 [#/sec] (mean)
Time per request:       0.104 [ms] (mean)
Time per request:       0.104 [ms] (mean, across all concurrent requests)
Transfer rate:          2525.59 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       0
Processing:     0    0   0.0      0       0
Waiting:        0    0   0.0      0       0
Total:          0    0   0.1      0       0

Percentage of the requests served within a certain time (ms)
  50%      0
  66%      0
  75%      0
  80%      0
  90%      0
  95%      0
  98%      0
  99%      0
 100%      0 (longest request)

很好,不超过 1ms 的响应时间。但一旦我加上了 -k 选项启用 HTTP Keep-Alive,结果就变成了这样: nginx

[~/dev/personal/breeze]$ /usr/sbin/ab -k  -c 1 -n 10 http://127.0.0.1:8000/styles/shThemeRDark.css
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient).....done

Server Software:        breeze/0.1.0
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /styles/shThemeRDark.css
Document Length:        127 bytes

Concurrency Level:      1
Time taken for tests:   0.360 seconds
Complete requests:      10
Failed requests:        0
Write errors:           0
Keep-Alive requests:    10
Total transferred:      2750 bytes
HTML transferred:       1270 bytes
Requests per second:    27.75 [#/sec] (mean)
Time per request:       36.041 [ms] (mean)
Time per request:       36.041 [ms] (mean, across all concurrent requests)
Transfer rate:          7.45 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.0      0       0
Processing:     1   36  12.4     40      40
Waiting:        0    0   0.2      0       1
Total:          1   36  12.4     40      40

Percentage of the requests served within a certain time (ms)
  50%     40
  66%     40
  75%     40
  80%     40
  90%     40
  95%     40
  98%     40
  99%     40
 100%     40 (longest request)

40ms 啊!这但是访问本机上的 Server 啊,才 1 个链接啊!太奇怪了吧!祭出 神器 strace,看看究竟是什么状况: git

15:37:47.493170 epoll_wait(3, {}, 1024, 0) = 0
15:37:47.493210 readv(5, [{"GET /styles/shThemeRDark.css HTT"..., 10111}, {"GET /styles/shThemeRDark.css HTT"..., 129}], 2) = 129
15:37:47.493244 epoll_wait(3, {}, 1024, 0) = 0
15:37:47.493279 write(5, "HTTP/1.0 200 OK\r\nContent-Type: t"..., 148) = 148
15:37:47.493320 write(5, "<html><head><title>Hello world</"..., 127) = 127
15:37:47.493347 epoll_wait(3, {}, 1024, 0) = 0
15:37:47.493370 readv(5, 0x7fff196a6740, 2) = -1 EAGAIN (Resource temporarily unavailable)
15:37:47.493394 epoll_ctl(3, EPOLL_CTL_MOD, 5, {...}) = 0
15:37:47.493417 epoll_wait(3, {?} 0x7fff196a67a0, 1024, 100) = 1
15:37:47.532898 readv(5, [{"GET /styles/shThemeRDark.css HTT"..., 9982}, {"GET /styles/shThemeRDark.css HTT"..., 258}], 2) = 129
15:37:47.533029 epoll_ctl(3, EPOLL_CTL_MOD, 5, {...}) = 0
15:37:47.533116 write(5, "HTTP/1.0 200 OK\r\nContent-Type: t"..., 148) = 148
15:37:47.533194 write(5, "<html><head><title>Hello world</"..., 127) = 127

发现是读下一个请求以前的那个epoll_wait花了 40ms 才返回。这意味着要 么是 client 等了 40ms 才给我发请求,要么是我上面write写入的数据过 了 40ms 才到达 client。前者的可能性几乎没有,ab 做为一个压力测试工具, 是不可能这样作的,那么问题只有多是以前写入的 response 过了 40ms 才到 达 client。 github

2 背后的缘由

为何延迟不高不低正好 40ms 呢?果断 Google 一下找到了答+案。原来这是 TCP 协议中的 Nagle‘s AlgorithmTCP Delayed Acknoledgement 共同起做 用所形成的结果。 算法

Nagle’s Algorithm 是为了提升带宽利用率设计的算法,其作法是合并小的TCP 包为一个,避免了过多的小报文的 TCP 头所浪费的带宽。若是开启了这个算法 (默认),则协议栈会累积数据直到如下两个条件之一知足的时候才真正发送出 去: apache

  1. 积累的数据量到达最大的 TCP Segment Size
  2. 收到了一个 Ack

TCP Delayed Acknoledgement 也是为了相似的目的被设计出来的,它的做用就 是延迟 Ack 包的发送,使得协议栈有机会合并多个 Ack,提升网络性能。 编程

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

现代的 TCP/IP 协议栈实现,默认几乎都启用了这两个功能,你可能会想,按我 上面的说法,当协议报文很小的时候,岂不每次都会触发这个延迟问题?事实不 是那样的。仅当协议的交互是发送端连续发送两个 packet,而后马上 read 的 时候才会出现问题。

3 为何只有 Write-Write-Read 时才会出问题

维基百科上的有一段伪代码来介绍 Nagle’s Algorithm:

if there is new data to send
  if the window size >= MSS and available data is >= MSS
    send complete MSS segment now
  else
    if there is unconfirmed data still in the pipe
      enqueue data in the buffer until an acknowledge is received
    else
      send data immediately
    end if
  end if
end if

能够看到,当待发送的数据比 MSS 小的时候(外层的 else 分支),还要再判断 时候还有未确认的数据。只有当管道里还有未确认数据的时候才会进入缓冲区, 等待 Ack。

因此发送端发送的第一个 write 是不会被缓冲起来,而是马上发送的(进入内层 的else 分支),这时接收端收到对应的数据,但它还期待更多数据才进行处理, 因此不会往回发送数据,所以也没机会把 Ack 给带回去,根据Delayed Ack 机制, 这个 Ack 会被 Hold 住。这时发送端发送第二个包,而队列里还有未确认的数据 包,因此进入了内层 if 的 then 分支,这个 packet 会被缓冲起来。此时,发 送端在等待接收端的 Ack;接收端则在 Delay 这个 Ack,因此都在等待,直到接 收端 Deplayed Ack 超时(40ms),此 Ack 被发送回去,发送端缓冲的这个 packet 才会被真正送到接收端,从而继续下去。

再看我上面的 strace 记录也能发现端倪,由于设计的一些不足,我没能作到把 短小的 HTTP Body 连同 HTTP Headers 一块儿发送出去,而是分开成两次调用实 现的,以后进入epoll_wait等待下一个 Request 被发送过来(至关于阻塞模 型里直接 read)。正好是 write-write-read 的模式。

那么 write-read-write-read 会不会出问题呢?维基百科上的解释是不会:

“The user-level solution is to avoid write-write-read sequences on sockets. write-read-write-read is fine. write-write-write is fine. But write-write-read is a killer. So, if you can, buffer up your little writes to TCP and send them all at once. Using the standard UNIX I/O package and flushing write before each read usually works.”

个人理解是这样的:由于第一个 write 不会被缓冲,会马上到达接收端,若是是 write-read-write-read 模式,此时接收端应该已经获得全部须要的数据以进行 下一步处理。接收端此时处理完后发送结果,同时也就能够把上一个packet 的 Ack 能够和数据一块儿发送回去,不须要 delay,从而不会致使任何问题。

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

说完了问题,该说解决方案了。

4 解决方案

4.1 优化协议

连续 write 小数据包,而后 read 实际上是一个很差的网络编程模式,这样的连 续 write 其实应该在应用层合并成一次 write。

惋惜的是,个人程序貌似不太好作这样的优化,须要打破一些设计,等我有时间 了再好好调整,至于如今嘛,就很屌丝地用下一个解决方法了。

4.2 开启TCP_NODELAY

简单地说,这个选项的做用就是禁用 Nagle’s Algorithm,禁止后固然就不会有 它引发的一系列问题了。在 UNIX C 里使用setsockopt能够作到:

static void _set_tcp_nodelay(int fd) {
    int enable = 1;
    setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, (void*)&enable, sizeof(enable));
}

在 Java 里就更简单了,Socket 对象上有一个setTcpNoDelay的方法,直接设 置成true便可。

据我所知,Nginx 默认是开启了这个选项的,这也给了我一点安慰:既然 Nginx 都这么干了,我就先不忙为了这个问题打破设计了,也默认开启TCP_NODELAY吧……