UNIX網絡編程總結

作爲一名現代開發人員,在日常的開發中不可避免的會接觸到網絡編程。網絡編程已經成爲現代開發人員不可或缺的基本素養,網絡編程本身又繞不開socket與tcp。雖然各個語言都提供了豐富的網絡庫,開發人員直接使用socket api的機會很少,但是對於socket api的行爲與tcp協議棧的交互過程也應該有所瞭解。這樣對於日常的開發設計與故障診斷都有所幫助。

本文將以圖示的方式討論了socket函數的行爲與tcp之間的交互。介紹了《UNIX網絡編程卷1:套接字聯網API》中的補充總結。
例子使用unpv13e/tcpcliserv。異常情況在Centos7.x86_64上進行測試。所碼出的文字儘量做到嚴謹,但限於能力有限,如有錯誤,請指正,以防誤導他人。

下文首先介紹socket api正常的使用情況與tcp之間的交互過程,再對各個異常情況分別描述。

正常情況

我們使用echo服務例子來介紹正常情況下,socket api行爲與tcp協議之間交互過程。
由客戶端發送數據,服務端回傳結果。數據收發函數統一使用阻塞read/write,也可以使用recv/send,recvmsg/sendmsg等。此例很多處理不夠嚴謹,不可作爲生產代碼流程使用。
先上圖:
在這裏插入圖片描述
圖中socket api使用默認阻塞模式。client app指客戶端應用程序,即直接調用socket api的進程。client TCP指client端的tcp協議棧,即client端操作系統內核。server端相同。

連接的建立過程

  1. server app創建listening socket,對監聽端口號進行bind(介紹bind的文章),然後調用listen監聽,此時server TCP從初始狀態 被動打開 ,進入LISTEN狀態,等待外來的連接。listen函數會使內核協議棧創建半連接隊列與全連接隊列,其長度受backlog參數影響。
  2. server app調用accept阻塞,等待全連接隊列有可用連接。
  3. client app創建socket,調用connect阻塞。client TCP從初始狀態 主動打開 到SYN_SENT狀態,此時,client tcp向server tcp發送SYN報文( 第一次握手 )。
  4. server TCP收到client tcp發來的SYN後,將紀錄放入半連接隊列,狀態轉換爲SYN_RCVE,並回復SYN+ACK ( 第二次握手 )。連接隊列滿見下文。
  5. client TCP收到後SYN+ACK後,進入ESTABLISHED,向server TCP回覆ACK ( 第三次握手 )。此時client app的connect函數返回。
  6. server TCP收到第三次握手的ACK後,將該紀錄從半連接隊列移到全連接隊列,進入ESTABLISHED狀態。此時,server app阻塞的accept將取出全連接隊列頭紀錄,創建connected socket返回,供server app收發數據。全連接隊列滿的情況見下文。

至此連接建立完成,之後便可使用建立好的socket進行數據收發了。tcp數據傳輸是一個很大的主題,這裏只關心與socket api交互過程。

數據收發

  1. client app端在connect返回後,就可以調用write寫入數據了,client TCP發送PSH報文。
    • 阻塞:write將阻塞到寫入全部數據到 發送緩衝區 返回。如果被信號中斷,返回值可能小於傳入的長度參數(short write),造成寫入部分數據。errno爲EINTR。以下是man 7 signal的描述。
       If  a  blocked  call to one of the following interfaces is interrupted by a signal handler, then the call will be automatically restarted after the signal handler returns if
       the SA_RESTART flag was used; otherwise the call will fail with the error EINTR:
    
           * read(2), readv(2), write(2), writev(2), and ioctl(2) calls on "slow" devices.  A "slow" device is one where the I/O call may block for an indefinite time, for example,
             a  terminal,  pipe,  or  socket.  (A disk is not a slow device according to this definition.)  If an I/O call on a slow device has already transferred some data by the
             time it is interrupted by a signal handler, then the call will return a success status (normally, the number of bytes transferred). 
    
    • 非阻塞:write能寫入儘量多數據到 發送緩衝區 後返回。
  2. server TCP收到PSH後將數據放入 接收緩衝 區中,回覆ACK。server app端調用read讀取接收緩衝中的數據。
  • read函數同樣有讀出的數據小於期望讀取的長度,也叫 短讀。不論阻塞還是非阻塞。原因與write大致相同。

日常開發中所說的 沾包分包 就是短讀短寫的直接產物。下面用一張圖概括一下沾包分包的產生。
在這裏插入圖片描述

  1. client發送了3長度數據,到了server端接收緩衝中。由於server TCP回覆了ACK,所以client TCP發送緩衝被清空。
  2. server app由於中斷或其他原因,只讀出了1長度數據,短讀造成分包。
  3. 這時client app又寫入了5長度數據到發送緩衝中,但是server TCP此時只能接收3長度,然後回覆3長度的ACK。
  4. server app這時再讀取會讀出5長度的數據,造成沾包。

所以,在開發階段與應用層協議設計時要考慮這些情況,比如要增加循環寫入來避免短寫,增加包體長度字段來處理短讀問題。

關閉階段

先調用close的一方,稱爲 主動關閉,另一方爲 被動關閉

  1. client app調用close函數(不考慮SO_LINGER),將socket的引用計數減1立即返回。一般情況,socket沒有引用,client TCP將嘗試將發送緩衝區中的數據發送完成,然後執行終止序列發送FIN報文,執行主動關閉。此時狀態進入FIN_WAIT_1。
  2. server TCP收到FIN後,執行被動關閉。狀態變爲CLOSE_WAIT。若此時有阻塞read,將返回0。server TCP返回ACK報文。此時server app仍然可使用該connected socket發送數據,但一般將調用close關閉該socket,server TCP也將發送FIN,並進入LAST_ACK。
  3. client TCP先收到ACK後,狀態變爲FIN_WAIT_2。而後收到對端發來的FIN報文,狀態變爲TIME_WAIT,並回復ACK。然後等待2倍MSL時間後,狀態變爲CLOSED,釋放socket資源。
  4. server TCP收到最後的ACK後,狀態變爲CLOSED,釋放socket資源。

異常情況

異常情況總結有以下幾種:

  1. 對端app異常崩潰。
  2. 對端主機崩潰或不可達。
  3. 半連接隊列滿。
  4. 全連接隊列滿。

其中1/2可能發生在的任何環節,其中大部分都會觸發超時重傳機制。所以需要先介紹下tcp的重傳。
我們知道tcp在發送一個包以後,都需要ACK確認。如果沒有收到ACK就會發生重傳。而重傳又分爲兩種重傳, 基於定時器重傳快速重傳 。快速重傳大部分是處理包亂序的情況。我們討論的異常情況一般都是沒有後續包的情況。基於定時器重傳又叫超時重傳,要基於一個超時時間,這個時間就叫做 RTO (Retransmission Timeout)。RTO的計算又要基於 RTT (round-trip-time)來評估初始值。在每次超時後,以指數增長RTO用於下次的超時時間。

  • 內核在超時次數達到一定數量後,會調用tcp_write_err,設置待處理錯誤爲ETIMEDOUT,關閉socket,喚醒阻塞在該socket的系統調用,或喚醒select。這裏如果沒有阻塞的讀寫操作也沒用select等非阻塞的io複用的話,就得不到通知。所以建議日常開發儘量使用select等io複用,這樣在發生錯誤情況時,select會被喚醒。

對端app異常崩潰

以server app崩潰爲例:
如果client app在 server app在崩潰後調用connect,server TCP將回復RST報文,connect返回ECONNREFUSED錯誤。

server app建連後崩潰:
在這裏插入圖片描述

  1. server app崩潰,server端內核會發現,然後由server TCP向client發送FIN報文,就像tcp連接終止的前半部分。
  • 但client app是無法知道對端是正常close,還是因爲崩潰了。
  1. client TCP響應ACK,狀態變爲CLOSE_WAIT。若此時client app有阻塞read,會返回0表示EOF。此時屬於半關閉,client app仍可向socket寫入數據,client TCP發送PSH報文。
  2. 由於server app已經崩潰,server TCP收到PSH後,將回復RST。
  3. client TCP收到RST後,client app之後讀操作都將返回ECONNRESET錯誤。如果是寫操作將發生SIGPIPE信號,如果有處理該信號,寫操作將返回EPIPE錯誤。
  4. client app調用close,關閉連接,執行最後揮手。
  • 如果client在對端崩潰後,沒有進行讀寫,那client app將永遠不會知道對端已經崩潰了。
  • 如果server app崩潰後又重啓了,client app再進行寫時,由於server TCP無法找到之前已經銷燬的socket,所以同樣會發送RST。
    圖中爲了說明問題,實際中read返回EOF,就不應該再此調用read了。

對端主機崩潰

還以server主機崩潰爲例。server主機崩潰意味着server TCP失去響應,client TCP發送的任何數據報都得不到ACK。這和對端網絡不可達其實是相同的。都會引起client TCP的超時重傳。崩潰發生在不同的階段,判斷重傳失敗的策略有些細微差別。

建連階段

SYN無回覆

當client app調用connect函數,發送SYN後,服務器主機崩潰,或者乾脆網絡不可達。
使用iptables drop 掉SYN包進行測試。

iptables -A INPUT -p tcp --dport 9877 --syn -j DROP

在這裏插入圖片描述
從tcpdump抓包看到,client發送SYN後,又連續發送了6個包,每個時間間隔爲1, 2, 4, 8, 16, 32s,之後又等了64s,connect函數返回ETIMEDOUT。總共等了127s。

$ date; ./tcpcli01 127.0.0.1; date 
Wed Jul 11 00:46:24 CST 2018
connect error: Connection timed out
Wed Jul 11 00:48:31 CST 2018

這個例子中有兩個問題:

  • 測試的抓包結果和其他文章中描述的(總共等待63s,沒有最後的64s)不一樣,是因爲alios做過修改嗎?有待驗證。
  • UNP中說,重傳階段如果某個中間路由器判定服務器主機不可達,從而響應一個"destination unreachable"(目的地不可達) ICMP消息,那麼所返回的錯誤是EHOSTUNREACH或ENETUNREACH。我是沒有遇到這中情況。

重傳次數6,是net.ipv4.tcp_syn_retries = 6參數控制的。
首次超時時間1s,由於SYN發出後,ACK還沒有收到所以無法計算RTO,所以使用了初始設置,具體實現

#define TCP_TIMEOUT_INIT ((unsigned)(1*HZ)) /* RFC6298 2.1 initial RTO value    */

在這裏插入圖片描述

隨帶介紹一下SYN + ACK後無回覆的情況。

SYN + ACK無回覆

當client app調用connect函數,client TCP發送SYN,server TCP回覆了SYN + ACK,這時client主機崩潰。我們使用

iptables -A INPUT -p tcp --dport 9877 --tcp-flags ALL ACK -j DROP

屏蔽掉client TCP回覆的ACK包,進行抓包:
在這裏插入圖片描述
由於net.ipv4.tcp_synack_retries = 5,所以server TCP重傳了5次。第6次超時後,tcp_write_err。
期間server端運行netstat,socket在SYN_RECV狀態停留63s後消失。

netstat -npt | grep 9877
tcp        0      0 server-ip:9877        client-ip:51151      SYN_RECV    -

示意圖:
在這裏插入圖片描述

著名的SYN Flood攻擊,便是此種情況。

數據收發階段

在這裏插入圖片描述
在數據收發階段的超時重傳,超時時間使用到了RTO的計算,而重傳次數受
net.ipv4.tcp_retries1 = 3
net.ipv4.tcp_retries2 = 15
這兩個參數約束,具體計算方法就不贅述了。可以參考源碼,或1 2
由於此例使用MacOS測試,結果與linux有些出入,但原理相同。

  • 重傳了13次,總共32s,最後向對端發送RST(雖然沒什麼用)。
  • 文章中中提到放棄重傳的最大時間上限是924.6s(15min),也就是說app有可能在15min後才能感知到錯誤。
  • 可以看到首次重傳MacOS爲120ms,linux爲TCP_RTO_MIN(200ms)。

關閉階段

關閉階段對於app端的影響不是很大,但是對於高併發大請求量服務器卻至關重要。

第一次揮手無回覆

client調用close,client TCP進入FIN_WAIT_1後收不到ACK。tcp協議棧使用net.ipv4.tcp_orphan_retries = 0配置項來約束了FIN包的重傳次數。源碼,如果沒有設置,將使用默認值8。
使用iptables進行測試:

iptables -A INPUT -p tcp --dport 9877 --tcp-flags ALL FIN,ACK -j DROP

在這裏插入圖片描述

17:09:40 tcp 0 1 127.0.0.1:51393 127.0.0.1:9877 FIN_WAIT1
......
17:11:22 tcp 0 1 127.0.0.1:51393 127.0.0.1:9877 FIN_WAIT1
  • 這裏抓包結果爲比源碼多一次,9次重傳,總共大約102s。
    在這裏插入圖片描述

第二次揮手後無回覆

在這裏插入圖片描述
此情況不會造成重傳,socket停留在FIN_WAIT_2的時間受net.ipv4.tcp_fin_timeout = 60控制。
可以使用腳本測試:

while sleep 1; do
    netstat -ant | grep FIN_WAIT2 | while read content; do
        echo -n $(date +"%T") ""
        echo $content
    done
done

觀察結果可得到時間大約爲1min。

第三次揮手後無回覆

在這裏插入圖片描述
在發送完第三次揮手即FIN後,該端狀態爲LAST_ACK,之後對端崩潰或網絡不可達造成接收不到最後的ACK。這時會觸發重傳FIN。達到重傳上限後,tcp_write_err。

15:35:51 tcp 0 1 127.0.0.1:9877 127.0.0.1:49828 LAST_ACK
.....
15:37:33 tcp 0 1 127.0.0.1:9877 127.0.0.1:49828 LAST_ACK
  • 重傳9次,總共大約103s,首次重傳200ms。與FIN_WAIT_1結果相同。看來他們的邏輯是相同的。

驗證一下:

  1. 將net.ipv4.tcp_orphan_retries設置爲4,sysctl -w net.ipv4.tcp_orphan_retries=4。
  2. 分別抓包查看FIN_WAIT_1和LAST_ACK的停留時間。
  • FIN_WAIT_1
    在這裏插入圖片描述
17:25:51 tcp 0 1 127.0.0.1:51659 127.0.0.1:9877 FIN_WAIT1
....
17:26:02 tcp 0 1 127.0.0.1:51659 127.0.0.1:9877 FIN_WAIT1
  • LAST_ACK
    在這裏插入圖片描述
17:29:48 tcp 0 1 127.0.0.1:9877 127.0.0.1:51708 LAST_ACK
....
17:30:00 tcp 0 1 127.0.0.1:9877 127.0.0.1:51708 LAST_ACK

看來這兩個狀態都受net.ipv4.tcp_orphan_retries配置影響,只不過不是次數的意思。

關閉階段總結

  • TIME_WAIT狀態通常也需要大約1min的時間。

由此可以看到,這些狀態停留時間還是很長的,我使用內網測試,RTO都相對很少,如果在公網時間會更長。
對於反向代理服務器,由於端口限制,在處理短連接請求時,如果過多的連接得不到釋放,將大大降低服務器併發量,net.ipv4.ip_local_port_range如果安3w算的話,一個請求停留在TIME_WAIT爲1min,那麼QPS只能到500。
網上有大量的優化帖子討論的系統參數配置,都是對於以上幾種狀態時間的優化。

連接隊列滿

client TCP發送SYN,server TCP接收到SYN後需要將該紀錄添加到半連接隊列,等待之後的ACK到達,如果這時連接隊列滿了,tcp協議棧會做什麼處理呢?
tcp協議棧在處理連接隊列滿的情況時,只是簡單丟棄。推薦閱讀:
How TCP backlog works in Linux
linux裏的backlog詳解

半連接隊列滿

源碼

int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
{
    // ......
        /* Accept backlog is full. If we have already queued enough
     * of warm entries in syn queue, drop request. It is better than
     * clogging syn queue with openreqs with exponentially increasing
     * timeout.
     */
    if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
        NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
        goto drop;
    }
    // ......
drop:
    NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENDROPS);
    return 0;
}

linux源碼中會有一個定時器定期清理超時的半連接請求。
當client的SYN到達server TCP時,如果server TCP全連接隊列滿了並且半連接隊列>1,server TCP只是簡單的丟棄該包,不做任何的後續處理,之後的情形如同”SYN無回覆“,client收不到SYN + ACK,會定時重傳SYN。在127s後,client的connect函數返回ETIMEDOUT錯誤。

全連接隊列滿

源碼連接

/*
 * The three way handshake has completed - we got a valid synack -
 * now create the new socket.
 */
struct sock *tcp_v4_syn_recv_sock(struct sock *sk, struct sk_buff *skb,
                  struct request_sock *req,
                  struct dst_entry *dst)
{
    // ......
    if (sk_acceptq_is_full(sk))
        goto exit_overflow;
    // ......
exit_overflow:
    NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
exit_nonewsk:
    dst_release(dst);
exit:
    NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENDROPS);
    return NULL;
    // ....
}

同樣簡單丟棄,只是做了一些統計。之後就如同 ”SYN + ACK無回覆“ 的情形一樣了。
synack_retry_ack
server TCP定時重傳SYN + ACK包,63s後服務端關閉socket。但此時client已經認爲連接成功,client TCP爲ESTABLISHED,如果client app不做任何讀寫操作,將不會感知到對端連接關閉。當client app調用write,client TCP向對端發送PSH後,server TCP會回覆RST。client app之後的調用將返回ECONNRESET。
圖示:
full_conn_full

總結

以上簡單的介紹了一下socket api通常的使用過程,並與tcp協議之間的交互。幫助大家理解記憶,並熟悉異常情況的協議棧的行爲,socket api的反應。
從文章可以看出雖然都說tcp是可靠傳輸,但是對於應用層來說,只有其在全部正常情況時,才能可靠。如果發送異常情況,其可靠性是不能得到保證的。

  • 在異常情況發生時,應用程序可能在數分鐘之後才能感知到,也有可能乾脆無感知。想要快速反應,應用層的心跳是必要的。而tcp的keepalive存在很多不足,比如:
    1. 在發送數據時,keepalive的timer會被重置,這時如果網絡不可達,就需要等到重傳超時後返回。
    2. keepalive的時間間隔設置是全系統共用的。
    3. tcp層的keepalive無法發現應用程序負載過高或死鎖等應用級錯誤。
  • 只通過tcp,應用層是無法知道數據到底有沒有被對方應用程序收到。在異常情況發生時,數據可能被丟棄。所以要保證消息送達,需要加應用級的ACK機制。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章