TCP_NODELAY 之延遲



最近的業餘時間幾乎全部獻給 breeze 這個多年前挖 下的大坑—— 一個異步 HTTP Server。努力沒有白費,項目已經逐漸成型了, 基本的框架已經有了,一個靜態 文件模塊也已經實現了。

寫 HTTP Server,不可免俗地一定要用 ab 跑一下性能,結果一跑不打緊,出現了一個困擾了我好幾天的問題:神祕的 40ms 延遲。

1 現象

現象是這樣的,首先看我用 ab 不加 -k 選項的結果:

[~/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,結果就變成了這樣:

[~/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,看看到底是什麼情況:

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。

2 背後的原因

爲什麼延遲不高不低正好 40ms 呢?果斷 Google 一下找到了答案。原來這是 TCP 協議中的 Nagle‘s Algorithm TCP Delayed Acknoledgement 共同起作 用所造成的結果。

Nagle’s Algorithm 是爲了提高帶寬利用率設計的算法,其做法是合併小的TCP 包爲一個,避免了過多的小報文的 TCP 頭所浪費的帶寬。如果開啓了這個算法 (默認),則協議棧會累積數據直到以下兩個條件之一滿足的時候才真正發送出 去:

  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 吧……

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章