前幾天我在新浪微博上出了兩道有關 TCP 的思考題,引發了一場討論 http://weibo.com/1701018393/eCuxDrta0Nn 。
第一道初級題目是:
有一台機器,它有 一個 IP,上面運行了一個 TCP 服務程序,程序只偵聽一個端口,問:從理論上講(只考慮 TCP/IP 這 一層面,不考慮IPv6)這個服務程序可以支持多少並發 TCP 連接?答 65536 上下的直接刷掉。
具體來說,這個問題等價於:有一個 TCP 服務程序的地址是 1.2.3.4:8765,問它從理論上能 接受多少個並發連接?
第二道進階題目是:
一台被測機器 A,功能同上 ,同一交換機上還接有一台機器 B,如果允許 B 的程序直接收發以太網 frame,問:讓 A 承擔 10 萬 個並發 TCP 連接需要用多少 B 的資源?100萬個呢?
從討論的結果看,很多人做出了第一道題 ,而第二道題幾乎無人問津。
這裡先不公布答案(第一題答案見文末),讓我們繼續思考一個 本質的問題:一個 TCP 連接要占用多少系統資源。
在現在的 Linux 操作系統上,如果用 socket()/connect() 或 accept() 來創建 TCP 連接,那麼每個連接至少要占用一個文件描述符(file descriptor)。為什麼說“至少”?因為文件描述符可以復制,比如 dup();也可以被繼承,比如 fork();這樣可能出現系統裡邊同一個 TCP 連接有多個文件描述符與之對應。據此,很多人給出的第 一題答案是:並發連接數受限於系統能同時打開的文件數目的最大值。這個答案在實踐中是正確的,卻 不符合原題意。
如果拋開操作系統層面,只考慮 TCP/IP 層面,建立一個 TCP 連接有哪些開銷 ?理論上最小的開銷是多少?考慮兩個場景:
1. 假設有一個 TCP 服務程序,向這個程序成功 發起連接需要做哪些事情?換句話說,如何才能讓這個 TCP 服務程序認為有客戶連接到了它(讓它的 accept() 調用正常返回)?
2. 假設有一個 TCP 客戶端程序,讓這個程序成功建立到服務器的 連接需要做哪些事情?換句話說,如何才能讓這個 TCP 客戶端程序認為它自己已經連接到服務器了( 讓它的 connect() 調用正常返回)?
以上這兩個問題問的不是如何編程,如何調用 Sockets API,而是問如何讓操作系統的 TCP/IP 協議棧認為任務已經成功完成,連接已經成功建立。
學 過 TCP/IP 協議,理解三路握手的同學明白,TCP 連接是虛擬的連接,不是電路連接,維持 TCP 連接 理論上不占用網絡資源(會占用兩頭程序的系統資源)。只要連接的雙方認為 TCP 連接存在,並且可 以互相發送 IP packet,那麼 TCP 連接就一直存在。
對於問題 1,向一個 TCP 服務程序發起 一個連接,客戶端(為明白起見,以下稱為 faketcp 客戶端)只需要做三件事情(三路握手):
1a. 向 TCP 服務程序發一個 IP packet,包含 SYN 的 TCP segment
1b. 等待對方返 回一個包含 SYN 和 ACK 的 TCP segment
1c. 向對方發送一個包含 ACK 的 segment
在 做完這三件事情之後,TCP 服務器程序會認為連接已建立。而做這三件事情並不占用客戶端的資源(? ),如果faketcp 客戶端程序可以繞開操作系統的 TCP/IP 協議棧,自己直接發送並接收 IP packet 或 Ethernet frame 的話。換句話說,faketcp 客戶端可以一直重復做這三件事件,每次用一個不同的 IP:PORT,在服務端創建不計其數的 TCP 連接,而 faketcp 客戶端自己毫發無損。很快我們將看到如 何用程序來實現這一點。
對於問題 2,為了讓一個 TCP 客戶端程序認為連接已建立,faketcp 服務端只需要做兩件事情:
2a. 等待客戶端發來的 SYN TCP segment
2b. 發送一個包含 SYN 和 ACK 的 TCP segment
2c. 忽視對方發來的包含 ACK 的 segment
在做完這兩件事 情(收一個 SYN、發一個 SYN+ACK)之後,TCP 客戶端程序會認為連接已建立。而做這三件事情並不占 用 faketcp 服務端的資源(?)換句話說,faketcp 服務端可以一直重復做這兩件事件,接受不計其 數的 TCP 連接,而 faketcp 服務端自己毫發無損。很快我們將看到如何用程序來實現這一點。
基於對以上兩個問題的分析,說明單獨談論“TCP 並發連接數”是沒有意義的,因為連接數基 本上是要多少有多少。更有意義的性能指標或許是:“每秒鐘收發多少條消息”、“每秒鐘收發多少字 節的數據”、“支持多少個活動的並發客戶”等等。
faketcp 的程序實現
代碼見: https://github.com/chenshuo/recipes/tree/master/faketcp 可以直接用 make 編譯
為了驗 證我上面的說法,我寫了幾個小程序來實現 faketcp,這幾個程序可以發起或接受不計其數的 TCP 並 發連接,並且不消耗操作系統資源,連動態內存分配都不會用到。
我家裡有一台運行 Ubuntu Linux 10.04 的 PC 機,hostname 是 atom,所有的試驗都在這上面進行。
家裡試驗環境的網 絡配置是:
陳碩在《談一談網絡編 程學習經驗》中曾提到“可以用 TUN/TAP 設備在用戶態實現一個能與本機點對點通信的 TCP/IP 協議 棧”,這次的試驗正好可以用上這個辦法。
試驗的網絡配置是:
具體做法 是:在 atom 上通過打開 /dev/net/tun 設備來創建一個 tun0 虛擬網卡,然後把這個網卡的地址設為 192.168.0.1/24,這樣 faketcp 程序就扮演了 192.168.0.0/24 這個網段上的所有機器。atom 發給 192.168.0.2~192.168.0.254 的 IP packet 都會發給 faketcp 程序,faketcp 程序可以模擬其中任何 一個 IP 給 atom 發 IP packet。
程序分成幾步來實現。
第一步:實現 icmp echo 協 議,這樣就能 ping 通 faketcp 了。
代碼見 https://github.com/chenshuo/recipes/blob/master/faketcp/icmpecho.cc
其中響應 icmp echo request 的函數在 https://github.com/chenshuo/recipes/blob/master/faketcp/faketcp.cc#L57 這個函數在後面的程 序中也會用到。
運行方法,打開 3 個命令行窗口:
1. 在第 1 個窗口運行 sudo ./icmpecho ,程序顯示
allocted tunnel interface tun0
2. 在第 2 個窗口運行
$ sudo ifconfig tun0 192.168.0.1/24
$ sudo tcpdump -i tun0
3. 在第 3 個 窗口運行
$ ping 192.168.0.2
$ ping 192.168.0.3
$ ping 192.168.0.234
發現每個 192.168.0.X 的 IP 都能 ping 通。
第二步:實現拒絕 TCP 連接的功能,即在收到 SYN TCP segment 的時候發送 RST segment。
代碼見 https://github.com/chenshuo/recipes/blob/master/faketcp/rejectall.cc
運行方法,打開 3 個命令行窗口,頭兩個窗口的操作與前面相同,運行的 faketcp 程序是 ./rejectall
3. 在 第 3 個窗口運行
$ nc 192.168.0.2 2000
$ nc 192.168.0.2 3333
$ nc 192.168.0.7 5555
發現向其中任意一個 IP 發起的 TCP 連接都被拒接了。
第三步:實 現接受 TCP 連接的功能,即在收到SYN TCP segment 的時候發回 SYN+ACK。這個程序同時處理了連接 斷開的情況,即在收到 FIN segment 的時候發回 FIN+ACK。
代碼見 https://github.com/chenshuo/recipes/blob/master/faketcp/acceptall.cc
運行方法,打開 3 個命令行窗口,步驟與前面相同,運行的 faketcp 程序是 ./acceptall。這次會發現 nc 能和 192.168.0.X 中的每一個 IP 每一個 PORT 都能連通。還可以在第 4 個窗口中運行 netstat –tpn , 以確認連接確實建立起來了。如果在 nc 中輸入數據,數據會堆積在操作系統中,表現為 netstat 顯 示的發送隊列(Send-Q)的長度增加。
第四步:在第三步接受 TCP 連接的基礎上,實現接收數 據,即在收到包含 payload 數據 的 TCP segment 時發回 ACK。
代碼見 https://github.com/chenshuo/recipes/blob/master/faketcp/discardall.cc
運行方法,打開 3 個命令行窗口,步驟與前面相同,運行的 faketcp 程序是 ./acceptall。這次會發現 nc 能和 192.168.0.X 中的每一個 IP 每一個 PORT 都能連通,數據也能發出去。還可以在第 4 個窗口中運行 netstat –tpn ,以確認連接確實建立起來了,並且發送隊列的長度為 0。
這一步已經解決了 前面的問題 2,扮演任意 TCP 服務端。
第五步:解決前面的問題 1,扮演客戶端向 atom 發起 任意多的連接。
代碼見 https://github.com/chenshuo/recipes/blob/master/faketcp/connectmany.cc
這一步的運行 方法與前面不同,打開 4 個命令行窗口。
1. 在第 1 個窗口運行 sudo ./connectmany 192.168.0.1 2007 1000 ,表示將向 192.168.0.1:2007 發起 1000 個並發連接。
程序顯示
allocted tunnel interface tun0
press enter key to start connecting 192.168.0.1:2007
查看本欄目
2. 在第 2 個窗口運行
$ sudo ifconfig tun0 192.168.0.1/24
$ sudo tcpdump -i tun0
3. 在第 3 個窗口運行一個能接收並發 TCP 連接的服務程序,可以是 httpd,也可以是 muduo 的 echo 或 discard 示例,程序應 listen 2007 端口。
4. 回到第 1 個窗口中敲回車,然後在第 4 個窗口中用 netstat -tpn 來觀察並發連接 。
有興趣的話,還可以繼續擴展,做更多的有關 TCP 的試驗,以進一步加深理解,驗證操作系 統 TCP/IP 協議棧面對不同輸入的行為。甚至可以按我在《談一談網絡編程學習經驗》中提議的那樣, 實現完整的 TCP 狀態機,做出一個簡單的 mini tcp stack。
第一道題的答案:
在只考 慮 IPv4 的情況下,並發數的理論上限是 2**48。考慮某些 IP 段被保留了,這個上界可適當縮小,但 數量級不變。實際的限制是操作系統全局文件描述符的數量,以及內存大小。
一個 TCP 連接有 兩個 end points,每個 end point 是 {ip, port},題目說其中一個 end point 已經固定,那麼留下 一個 end point 的自由度,即 2 ** 48。客戶端 IP 的上限是 2**32 個,每個客戶端IP發起連接的上 限是 2**16,乘到一起得理論上限。
即便客戶端使用 NAT,也不影響這個理論上限。(為什麼 ?)
在真實的 Linux 系統中,可以通過調整內核參數來支持上百萬並發連接,具體做法見:
http://urbanairship.com/blog/2010/09/29/linux-kernel-tuning-for- c500k/
http://www.metabrew.com/article/a-million-user-comet-application-with- mochiweb-part-3
(.完.)