神祕的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吧……

相關文章
相關標籤/搜索