閱讀39 返回首頁    go 阿裏雲 go 技術社區[雲棲]


網絡編程中Nagle算法和Delayed ACK的測試

Nagle算法的立意是良好的,避免網絡中充塞小封包,提高網絡的利用率。但是當Nagle算法遇到delayed ACK悲劇就發生了。Delayed ACK的本意也是為了提高TCP性能,跟應答數據捎帶上ACK,同時避免煳塗窗口綜合症,也可以一個ack確認多個段來節省開銷。
    悲劇發生在這種情況,假設一端發送數據並等待另一端應答,協議上分為頭部和數據,發送的時候不幸地選擇了write-write,然後再read,也就是先發送頭部,再發送數據,最後等待應答。發送端的偽代碼是這樣
write(head);
write(body);
read(response);

接收端的處理代碼類似這樣:
read(request);
process(request);
write(response);

   這裏假設head和body都比較小,當默認啟用nagle算法,並且是第一次發送的時候,根據nagle算法,第一個段head可以立即發送,因為沒有等待確認的段;接收端收到head,但是包不完整,繼續等待body達到並延遲ACK;發送端繼續寫入body,這時候nagle算法起作用了,因為head還沒有被ACK,所以body要延遲發送。這就造成了發送端和接收端都在等待對方發送數據的現象,發送端等待接收端ACK head以便繼續發送body,而接收端在等待發送方發送body並延遲ACK,悲劇的無以言語。這種時候隻有等待一端超時並發送數據才能繼續往下走。

   正因為nagle算法和delayed ack的影響,再加上這種write-write-read的編程方式造成了很多網貼在討論為什麼自己寫的網絡程序性能那麼差。然後很多人會在帖子裏建議禁用Nagle算法吧,設置TCP_NODELAY為true即可禁用nagle算法。但是這真的是解決問題的唯一辦法和最好辦法嗎?

   其實問題不是出在nagle算法身上的,問題是出在write-write-read這種應用編程上。禁用nagle算法可以暫時解決問題,但是禁用nagle算法也帶來很大壞處,網絡中充塞著小封包,網絡的利用率上不去,在極端情況下,大量小封包導致網絡擁塞甚至崩潰。因此,能不禁止還是不禁止的好,後麵我們會說下什麼情況下才需要禁用nagle算法。對大多數應用來說,一般都是連續的請求——應答模型,有請求同時有應答,那麼請求包的ACK其實可以延遲到跟響應一起發送,在這種情況下,其實你隻要避免write-write-read形式的調用就可以避免延遲現象,利用writev做聚集寫或者將head和body一起寫,然後再read,變成write-read-write-read的形式來調用,就無需禁用nagle算法也可以做到不延遲。

   writev是係統調用,在Java裏是用到GatheringByteChannel.write(ByteBuffer[] srcs, int offset, int length)方法來做聚集寫。這裏可能還有一點值的提下,很多同學看java nio框架幾乎都不用這個writev調用,這是有原因的。主要是因為Java的write本身對ByteBuffer有做臨時緩存,而writev沒有做緩存,導致測試來看write反而比writev更高效,因此通常會更推薦用戶將head和body放到同一個Buffer裏來避免調用writev。

   下麵我們將做個實際的代碼測試來結束討論。這個例子很簡單,客戶端發送一行數據到服務器,服務器簡單地將這行數據返回。客戶端發送的時候可以選擇分兩次發,還是一次發送。分兩次發就是write-write-read,一次發就是write-read-write-read,可以看看兩種形式下延遲的差異。注意,在windows上測試下麵的代碼,客戶端和服務器必須分在兩台機器上,似乎winsock對loopback連接的處理不一樣。

    服務器源碼:
package net.fnil.nagle;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;


public class Server {
    
public static void main(String[] args) throws Exception {
        ServerSocket serverSocket 
= new ServerSocket();
        serverSocket.bind(
new InetSocketAddress(8000));
        System.out.println(
"Server startup at 8000");
        
for (;;) {
            Socket socket 
= serverSocket.accept();
            InputStream in 
= socket.getInputStream();
            OutputStream out 
= socket.getOutputStream();

            
while (true) {
                
try {
                    BufferedReader reader 
= new BufferedReader(new InputStreamReader(in));
                    String line 
= reader.readLine();
                    out.write((line 
+ "\r\n").getBytes());
                }
                
catch (Exception e) {
                    
break;
                }
            }
        }
    }
}

服務端綁定到本地8000端口,並監聽連接,連上來的時候就阻塞讀取一行數據,並將數據返回給客戶端。

客戶端代碼:
package net.fnil.nagle;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;


public class Client {

    
public static void main(String[] args) throws Exception {
        
// 是否分開寫head和body
        boolean writeSplit = false;
        String host 
= "localhost";
        
if (args.length >= 1) {
            host 
= args[0];
        }
        
if (args.length >= 2) {
            writeSplit 
= Boolean.valueOf(args[1]);
        }

        System.out.println(
"WriteSplit:" + writeSplit);

        Socket socket 
= new Socket();

        socket.connect(
new InetSocketAddress(host, 8000));
        InputStream in 
= socket.getInputStream();
        OutputStream out 
= socket.getOutputStream();

        BufferedReader reader 
= new BufferedReader(new InputStreamReader(in));

        String head 
= "hello ";
        String body 
= "world\r\n";
        
for (int i = 0; i < 10; i++) {
            
long label = System.currentTimeMillis();
            
if (writeSplit) {
                out.write(head.getBytes());
                out.write(body.getBytes());
            }
            
else {
                out.write((head 
+ body).getBytes());
            }
            String line 
= reader.readLine();
            System.out.println(
"RTT:" + (System.currentTimeMillis() - label) + " ,receive:" + line);
        }
        in.close();
        out.close();
        socket.close();
    }

}


   客戶端通過一個writeSplit變量來控製是否分開寫head和body,如果為true,則先寫head再寫body,否則將head加上body一次寫入。客戶端的邏輯也很簡單,連上服務器,發送一行,等待應答並打印RTT,循環10次最後關閉連接。

   首先,我們將writeSplit設置為true,也就是分兩次寫入一行,在我本機測試的結果,我的機器是ubuntu 11.10:
WriteSplit:true
RTT:
8 ,receive:hello world
RTT:
40 ,receive:hello world
RTT:
40 ,receive:hello world
RTT:
40 ,receive:hello world
RTT:
39 ,receive:hello world
RTT:
40 ,receive:hello world
RTT:
40 ,receive:hello world
RTT:
40 ,receive:hello world
RTT:
40 ,receive:hello world
RTT:
40 ,receive:hello world

    可以看到,每次請求到應答的時間間隔都在40ms,除了第一次。linux的delayed ack是40ms,而不是原來以為的200ms。第一次立即ACK,似乎跟linux的quickack mode有關,這裏我不是特別清楚,有比較清楚的同學請指教。

     接下來,我們還是將writeSplit設置為true,但是客戶端禁用nagle算法,也就是客戶端代碼在connect之前加上一行:
        Socket socket = new Socket();
        socket.setTcpNoDelay(
true);
        socket.connect(
new InetSocketAddress(host, 8000));

    再跑下測試:
WriteSplit:true
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
1 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world

   這時候就正常多了,大部分RTT時間都在1毫秒以下。果然禁用Nagle算法可以解決延遲問題。
   如果我們不禁用nagle算法,而將writeSplit設置為false,也就是將head和body一次寫入,再次運行測試(記的將setTcpNoDelay這行刪除):
WriteSplit:false
RTT:
7 ,receive:hello world
RTT:
1 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world
RTT:
0 ,receive:hello world


   結果跟禁用nagle算法的效果類似。既然這樣,我們還有什麼理由一定要禁用nagle算法呢?通過我在xmemcached的壓測中的測試,啟用nagle算法在小數據的存取上甚至有一定的效率優勢,memcached協議本身就是個連續的請求應答的模型。上麵的測試如果在windows上跑,會發現RTT最大會在200ms以上,可見winsock的delayed ack超時是200ms。

   最後一個問題,什麼情況下才應該禁用nagle算法?當你的應用不是這種連續的請求——應答模型,而是需要實時地單向發送很多小數據的時候或者請求是有間隔的,則應該禁用nagle算法來提高響應性。一個最明顯是例子是telnet應用,你總是希望敲入一行數據後能立即發送給服務器,然後馬上看到應答,而不是說我要連續敲入很多命令或者等待200ms才能看到應答。

   上麵是我對nagle算法和delayed ack的理解和測試,有錯誤的地方請不吝賜教。

文章轉自莊周夢蝶  ,原文發布時間 2011-06-30

最後更新:2017-05-18 18:05:07

  上一篇:go  《Kafka官方文檔》主頁
  下一篇:go  Java NIO編程的技巧和陷阱