1. 程式人生 > >淺談 TCP/IP 網路程式設計中 socket 的行為

淺談 TCP/IP 網路程式設計中 socket 的行為

來源:PromisE_謝 
連結:www.cnblogs.com/promise6522/archive/2012/03/03/2377935.html

我認為,想要熟練掌握 Linux 下的 TCP/IP 網路程式設計,至少有三個層面的知識需要熟悉:

  1. TCP/IP協議(如連線的建立和終止、重傳和確認、滑動視窗和擁塞控制等等)
  2. Socket I/O系統呼叫(重點如read/write),這是TCP/IP協議在應用層表現出來的行為。
  3. 編寫Performant, Scalable的伺服器程式。包括多執行緒、IO Multiplexing、非阻塞、非同步等各種技術。

關於TCP/IP協議,建議參考 Richard Stevens 的《TCP/IP Illustrated,vol1》(TCP/IP詳解卷1)。

關於第二層面,依然建議Richard Stevens的《Unix network proggramming,vol1》(Unix網路程式設計卷1),這兩本書公認是Unix網路程式設計的聖經。

至於第三個層面,UNP 的書中有所提及,也有著名的 C10K 問題,業界也有各種各樣的框架和解決方案,本人才疏學淺,在這裡就不一一敷述。

本文的重點在於第二個層面,主要總結一下 Linux 下 TCP/IP 網路程式設計中的 read/write 系統呼叫的行為,知識來源於自己網路程式設計的粗淺經驗和對《Unix網路程式設計卷1》相關章節的總結。由於本人接觸Linux下網路程式設計時間不長,錯誤和疏漏再所難免,望看官不吝賜教。

一、read/write 的語義:為什麼會阻塞?

先從write說起:

#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);

首先,write 成功返回,只是 buf 中的資料被複制到了 kernel 中的 TCP 傳送緩衝區。至於資料什麼時候被髮往網路,什麼時候被對方主機接收,什麼時候被對方程序讀取,系統呼叫層面不會給予任何保證和通知。

write 在什麼情況下會阻塞?當 kernel 的該 socket 的傳送緩衝區已滿時。對於每個 socket,擁有自己的send buffer 和 receive buffer。從 Linux 2.6 開始,兩個緩衝區大小都由系統來自動調節(autotuning),但一般在 default 和 max 之間浮動。

# 獲取socket的傳送/接受緩衝區的大小:(後面的值是在我在Linux 2.6.38 x86_64上測試的結果)
sysctl net.core.wmem_default       #126976
sysctl net.core.wmem_max        #131071
sysctl net.core.wmem_default       #126976
sysctl net.core.wmem_max           #131071

已經發送到網路的資料依然需要暫存在 send buffer 中,只有收到對方的 ack 後,kernel 才從 buffer 中清除這一部分資料,為後續傳送資料騰出空間。接收端將收到的資料暫存在 receive buffer 中,自動進行確認。但如果 socket 所在的程序不及時將資料從 receive buffer 中取出,最終導致 receive buffer 填滿,由於 TCP 的滑動視窗和擁塞控制,接收端會阻止傳送端向其傳送資料。這些控制皆發生在 TCP/IP 棧中,對應用程式是透明的,應用程式繼續傳送資料,最終導致 send buffer 填滿,write 呼叫阻塞

一般來說,由於接收端程序從 socket 讀資料的速度跟不上傳送端程序向 socket 寫資料的速度,最終導致傳送端 write 呼叫阻塞。

而 read 呼叫的行為相對容易理解,從 socket 的 receive buffer 中拷貝資料到應用程式的 buffer 中。read 呼叫阻塞,通常是傳送端的資料沒有到達

二、 blocking(預設)和nonblock模式下 read/write 行為的區別

將 socket fd 設定為 nonblock(非阻塞)是在伺服器程式設計中常見的做法,採用 blocking IO 併為每一個client 建立一個執行緒的模式開銷巨大且可擴充套件性不佳(帶來大量的切換開銷),更為通用的做法是採用執行緒池+Nonblock I/O+Multiplexing(select/poll,以及Linux上特有的epoll)。

// 設定一個檔案描述符為nonblock
int set_nonblocking(int fd)
{
    int flags;
    if ((flags = fcntl(fd, F_GETFL, 0)) == -1)
        flags = 0;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

幾個重要的結論:

  • read總是在接收緩衝區有資料時立即返回,而不是等到給定的 read buffer 填滿時返回

只有當receive buffer為空時,blocking模式才會等待,而nonblock模式下會立即返回-1(errno = EAGAIN或EWOULDBLOCK)

  • blocking 的 write 只有在緩衝區足以放下整個 buffer 時才返回(與 blocking read 並不相同)

nonblock write 則是返回能夠放下的位元組數,之後呼叫則返回-1(errno = EAGAIN或EWOULDBLOCK)

對於 blocking 的 write 有個特例:當 write 正阻塞等待時對面關閉了 socket,則 write 則會立即將剩餘緩衝區填滿並返回所寫的位元組數,再次呼叫則 write 失敗(connection reset by peer),這正是下個小節要提到的。

三、 read/write對連線異常的反饋行為

對應用程式來說,與另一程序的 TCP 通訊其實是完全非同步的過程:

  1. 我並不知道對面什麼時候、能否收到我的資料
  2. 我不知道什麼時候能夠收到對面的資料
  3. 我不知道什麼時候通訊結束(主動退出或是異常退出、機器故障、網路故障等等)

對於 1 和 2,採用 write() -> read() -> write() -> read() ->… 的序列,通過 blocking read 或者 nonblock read+輪詢 的方式,應用程式基於可以保證正確的處理流程。

對於 3,kernel 將這些事件的“通知”通過 read/write 的結果返回給應用層。

假設 A 機器上的一個程序 a 正在和 B 機器上的程序 b 通訊:某一時刻 a 正阻塞在 socket 的 read 呼叫上(或者在nonblock下輪詢socket)

當 b 程序終止時,無論應用程式是否顯式關閉了 socket(OS會負責在程序結束時關閉所有的檔案描述符,對於socket,則會發送一個 FIN 包到對面)。

  • 同步通知:程序 a 對已經收到 FIN 的 socket 呼叫 read,如果已經讀完了receive buffer的剩餘位元組,則會返回 EOF: 0

  • 非同步通知:如果程序 a 正阻塞在 read 呼叫上(前面已經提到,此時receive buffer一定為空,因為read在receive buffer有內容時就會返回),則 read 呼叫立即返回EOF,程序 a 被喚醒。

socket 在收到 FIN 後,雖然呼叫 read 會返回 EOF,但程序 a 依然可以其呼叫 write,因為根據 TCP 協議,收到對方的 FIN 包只意味著對方不會再發送任何訊息。 在一個雙方正常關閉的流程中,收到 FIN 包的一端將剩餘資料傳送給對面(通過一次或多次 write),然後關閉 socket。

但是事情遠遠沒有想象中簡單。優雅地(gracefully)關閉一個 TCP 連線,不僅僅需要雙方的應用程式遵守約定,中間還不能出任何差錯。

假如 b 程序是異常終止的,傳送 FIN 包是 OS 代勞的,b 程序已經不復存在,當機器再次收到該 socket 的訊息時,會迴應 RST(因為擁有該 socket 的程序已經終止)。a 程序對收到 RST 的 socket 呼叫 write 時,作業系統會給 a 程序傳送 SIGPIPE,預設處理動作是終止程序,知道你的程序為什麼毫無徵兆地死亡了吧:)

from 《Unix Network programming, vol1》 3rd Edition:

“It is okay to write to a socket that has received a FIN, but it is an error to write to a socket that has received an RST.”

通過以上的敘述,核心通過socket的read/write將雙方的連線異常通知到應用層,雖然很不直觀,似乎也夠用。

這裡說一句題外話:

不知道有沒有同學會和我有一樣的感慨:在寫 TCP/IP 通訊時,似乎沒怎麼考慮連線的終止或錯誤,只是在 read/write 錯誤返回時關閉 socket,程式似乎也能正常執行,但某些情況下總是會出奇怪的問題。想完美處理各種錯誤,卻發現怎麼也做不對。

原因之一是:socket(或者說TCP/IP棧本身)對錯誤的反饋能力是有限的。

考慮這樣的錯誤情況:

不同於 b 程序退出(此時 OS 會負責為所有開啟的 socket 傳送 FIN 包),當 B 機器的 OS 崩潰(注意不同於人為關機,因為關機時所有程序的退出動作依然能夠得到保證)/主機斷電/網路不可達時,a 程序根本不會收到 FIN 包作為連線終止的提示。

如果a程序阻塞在 read 上,那麼結果只能是永遠的等待。

如果 a 程序先 write 然後阻塞在 read,由於收不到 B 機器 TCP/IP 棧的 ack,TCP會持續重傳 12 次(時間跨度大約為9分鐘),然後在阻塞的read呼叫上返回錯誤:ETIMEDOUT/EHOSTUNREACH/ENETUNREACH

假如 B 機器恰好在某個時候恢復和A機器的通路,並收到 a 某個重傳的pack,因為不能識別所以會返回一個RST,此時 a 程序上阻塞的 read 呼叫會返回錯誤 ECONNREST

恩,socket 對這些錯誤還是有一定的反饋能力的,前提是在對面不可達時你依然做了一次 write 呼叫,而不是輪詢或是阻塞在 read上,那麼總是會在重傳的週期內檢測出錯誤。如果沒有那次write呼叫,應用層永遠不會收到連線錯誤的通知。

write 的錯誤最終通過 read 來通知應用層,有點陰差陽錯?

四、還需要做什麼?

至此,我們知道了僅僅通過 read/write 來檢測異常情況是不靠譜的,還需要一些額外的工作:

$ cat /proc/sys/net/ipv4/tcp_keepalive_time
7200
$ cat /proc/sys/net/ipv4/tcp_keepalive_intvl
75
$ cat /proc/sys/net/ipv4/tcp_keepalive_probes
9

1、 使用TCP的KEEPALIVE功能?

以上引數的大致意思是:keepalive routine 每2小時(7200秒)啟動一次,傳送第一個probe(探測包),如果在75秒內沒有收到對方應答則重發probe,當連續9個probe沒有被應答時,認為連線已斷。(此時read呼叫應該能夠返回錯誤,待測試)

但在我印象中keepalive不太好用,預設的時間間隔太長,又是整個TCP/IP棧的全域性引數:修改會影響其他程序,Linux的下似乎可以修改per socket的keepalive引數?(希望有使用經驗的人能夠指點一下),但是這些方法不是portable的。

2、 進行應用層的心跳

嚴格的網路程式中,應用層的心跳協議是必不可少的。雖然比TCP自帶的keep alive要麻煩不少(怎樣正確地實現應用層的心跳,我或許會用一篇專門的文章來談一談),但有其最大的優點:可控。