TCP使用注意事項總結
目錄
- 傳送或者接受資料過程中對端可能發生的情況彙總
- 本端TCP傳送資料時對端程序已經崩潰
- 本端TCP傳送資料時對端主機已經崩潰
- 本端TCP傳送資料時對端主機已經關機
- 某個連線長時間沒有資料流動
- TCP傳送資料不全
- TCP資料傳送不全例項
- 為什麼會出現資料傳送不全的現象?
- 如何解決(如何正確關閉連線)?
- SIGPIPE訊號
- 什麼場景下會產生SIGPIPE訊號?
- 如何處理SIGPIPE訊號?
- Nagle演算法,TCP_NODELAY
- SO_RESUSEADDR
- 為什麼要設計2MSL狀態?
- 為什麼處於2MSL狀態時該插口對定義的連線不能被再用?
- 示例
- 解決辦法
傳送或者接受資料過程中對端可能發生的情況彙總
《UNP》p159總結了如下的情況:
情形 | 對端程序崩潰 | 對端主機崩潰 | 對端主機不可達 |
---|---|---|---|
本端TCP正主動傳送資料 | 對端TCP傳送一個FIN,這通過使用select判斷可讀條件立即能檢測出來,如果本端TCP傳送另一個分節,對端TCP就以RST響應。如果本端TCP在收到RST後應用程序仍試圖寫套接字,我們的套接字實現就給該程序傳送一個SIGPIPE訊號 | 本端TCP將超時,且套接字的待處理錯誤被置為ETIMEDOUT | 本端TCP將超時,且套接字的待處理錯誤被置為EHOSTUNREACH |
本端TCP正主動接收資料 | 對端TCP傳送一個FIN,我們將把它作為一個EOF讀入 | 我們將停止接收資料 | 我們將停止接收資料 |
連線空閒,保持存活選項已設定 | 對端TCP傳送一個FIN,這通過select判斷可讀條件能立即檢測出來 | 在無資料交換2小時後,傳送9個保持存活探測分節,然後套接字的待處理錯誤被置為ETIMEDOUT | 在無資料交換2小時後,傳送9個保持存活探測分節,然後套接字的待處理錯誤被置為HOSTUNREACH |
連線空閒,保持存活選項未設定 | 對端TCP傳送一個FIN,這通過select判斷可讀條件能立即檢測出來 | 無 | 無 |
本端TCP傳送資料時對端程序已經崩潰
服務端接收客戶端的資料並丟棄:
int acceptOrDie(uint16_t port)
{
int listenfd = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
assert(listenfd >= 0);
int yes = 1;
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)))
{
perror("setsockopt");
exit(1);
}
struct sockaddr_in addr;
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = INADDR_ANY;
if (::bind(listenfd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)))
{
perror("bind");
exit(1);
}
if (::listen(listenfd, 5))
{
perror("listen");
exit(1);
}
struct sockaddr_in peer_addr;
bzero(&peer_addr, sizeof(peer_addr));
socklen_t addrlen = 0;
int sockfd = ::accept(listenfd, reinterpret_cast<struct sockaddr*>(&peer_addr), &addrlen);
if (sockfd < 0)
{
perror("accept");
exit(1);
}
::close(listenfd);
return sockfd;
}
void discard(int sockfd)
{
char buf[65536];
while (true)
{
int nr = ::read(sockfd, buf, sizeof buf);
if (nr <= 0)
break;
}
}
int main(int argc, char* argv[]) {
if (argc < 2) {
cout << "usage:./server port\n";
exit(0);
}
int sockfd = acceptOrDie(atoi(argv[1])); //建立socket, bind, listen
discard(sockfd); //讀取並丟棄所有客戶端傳送的資料
return 0;
}
客戶端從命令列接受字串併發送給服務端:
struct sockaddr_in resolveOrDie(const char* host, uint16_t port)
{
struct hostent* he = ::gethostbyname(host);
if (!he)
{
perror("gethostbyname");
exit(1);
}
assert(he->h_addrtype == AF_INET && he->h_length == sizeof(uint32_t));
struct sockaddr_in addr;
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr = *reinterpret_cast<struct in_addr*>(he->h_addr);
return addr;
}
int main(int argc, char* argv[]) {
if (argc < 3) {
cout << "usage:./cli host port\n";
exit(0);
}
struct sockaddr_in addr = resolveOrDie(argv[1], atoi(argv[2]));
int sockfd = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
assert(sockfd >= 0);
int ret = ::connect(sockfd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
if (ret)
{
perror("connect");
exit(1);
}
char sendline[1024];
while (fgets(sendline, sizeof sendline, stdin) != NULL) { //從命令列讀資料
write_n(sockfd, sendline, strlen(sendline)); //傳送給服務端
}
return 0;
}
先啟動tcpdump觀察資料包的流動,然後分別啟動服務端和客戶端。
下面是三次握手的資料包:
15:33:21.184993 IP 221.218.38.144.53186 > 172.19.0.16.1234: Flags [S], seq 1654237964, win 64240, options [mss 1412,nop,wscale 8,nop,nop,sackOK], length 0
15:33:21.185027 IP 172.19.0.16.1234 > 221.218.38.144.53186: Flags [S.], seq 3710209371, ack 1654237965, win 29200, options [mss 1460,nop,nop,sackOK,nop,wscale 7], length 0
15:33:21.230698 IP 221.218.38.144.53186 > 172.19.0.16.1234: Flags [.], ack 1, win 259, length 0
然後終止服務端程序,觀察資料包的情況。服務端程序終止後,會向客戶端傳送一個FIN分節,客戶端核心迴應一個ACK。此時客戶端阻塞在fgets,感受不到這個FIN分節。
15:33:49.310810 IP 172.19.0.16.1234 > 221.218.38.144.53186: Flags [F.], seq 1, ack 8, win 229, length 0
15:33:49.356453 IP 221.218.38.144.53186 > 172.19.0.16.1234: Flags [.], ack 2, win 259, length 0
如果這時客戶端繼續傳送資料,因為服務端程序已經不在了,所以服務端核心響應一個RST分節。
15:34:31.198332 IP 221.218.38.144.53186 > 172.19.0.16.1234: Flags [P.], seq 8:16, ack 2, win 259, length 8
15:34:31.198360 IP 172.19.0.16.1234 > 221.218.38.144.53186: Flags [R], seq 3710209373, win 0, length 0
如果客戶端在收到RST分節後,繼續傳送資料,將會收到SIGPIPE訊號,如果使用預設的處理方式,客戶端程序將會崩潰。
如果我們在客戶端程式碼中忽略SIGPIPE訊號,那麼客戶端不會崩潰。
signal(SIGPIPE, SIG_IGN); // 忽略 SIGPIPE 訊號
本端TCP傳送資料時對端主機已經崩潰
這種情況本端TCP會超時,且套接字待處理錯誤會被置為ETIMEDOUT。
本端TCP傳送資料時對端主機已經關機
服務端主機關機和崩潰不同,關機時會關閉程序開啟的描述符,所以會發送FIN分節,客戶端如果處理得當,就能檢測到。但是如果是對端主機崩潰,除非設定了SO_KEEPALIVE
選項,否則本端無法得知對端主機已經崩潰。
某個連線長時間沒有資料流動
這一種情況對應表格中的第三、四行。
- 如果沒有設定SO_KEEPALIVE選項,那麼如果對端只是程序崩潰,那麼本端還是可以通過select檢測到的,但是如果對端主機崩潰或者變得不可達,那麼本端沒有辦法得知,這個連線也得不到正常的關閉。
- 如果設定了該選項。
這個選項是用來檢測對端是否主機崩潰或者變得不可達(比如網線斷開),而不是檢測對端程序是否崩潰,如果是程序崩潰的話會發送一個FIN,本端可以用select檢測到。但是如果對端長時間沒有資料流動,我們除了設定這個選項,沒有辦法得知對端是不是主機崩潰或者變得不可達。
設定該選項後,如果2小時內該套接字任一方向上都沒有資料交換,TCP就自動給對端傳送一個探測分節,可能出現三種情況:- 對端響應ACK。表示一切正常,應用程序不會得到任何通知。
- 對端響應RST,表示對端已崩潰且以重新啟動,該套接字的待處理錯誤被置為ECONNRESET,套接字被關閉。
- 對端沒有任何響應,那麼隔一段時間再次傳送探測分節,如果還是沒有響應,套接字錯誤被置為ETIMEOUT,套接字被關閉。
TCP傳送資料不全
TCP本身是可靠,但是如果使用不當會給人造成TCP不可靠的錯覺。
TCP資料傳送不全例項
假設服務端接收連線後呼叫後開啟一個本地檔案,然後將檔案內容通過socket傳送給客戶端。
int main(int argc, char* argv[]) {
if (argc < 3) {
printf("Usage:%s filename port\n", argv[0]);
return 0;
}
int sockfd = acceptOrDie(atoi(argv[2]));
printf("accept client\n");
FILE* fp = fopen(argv[1], "rb");
if (!fp) {
return 0;
}
printf("sleeping 10 seconds\n");
sleep(10);
char buf[8192];
size_t nr = 0;
while ((nr = fread(buf, 1, sizeof buf, fp)) > 0) { //讀檔案
write_n(sockfd, buf, nr); //傳送給客戶端
}
fclose(fp);
printf("finish sending file %s\n", argv[1]);
}
首先在在服務端啟動該程式./send file_1M_size 1234
。file_1M_size的1M大小的檔案。
用nc作為客戶端nc localhost 1234 | wc -c
。
連線建立後,服務端會sleep 10秒,然後拷貝檔案,最終客戶端輸出:
1048576
這裡沒問題,確實傳送了1M資料的檔案。
如果我們在服務端sleep 10秒期間,在客戶端輸入了一些資料:
root@DESKTOP-2A432QS:/mnt/c/Users/12401/Desktop/network_programing/recipes-master/tpc# nc localhost 1234 | wc -c
abcdfef
976824
abcdfef是我們傳送給服務端的,976824是收到的位元組數。顯然不夠1M。
為什麼會出現資料傳送不全的現象?
建立連線後,客戶端也向服務端傳送了一些資料,這些資料到達服務端後,儲存在服務端的核心緩衝區中。服務端讀取檔案後呼叫write傳送出去,雖然write返回了,但這僅僅代表要傳送的資料已經被放到了核心傳送緩衝區,並不代表已經被客戶端接收了。這時服務端while迴圈結束,直接退出了main函式,這會導致close連線,當接收緩衝區還有資料沒有讀取時呼叫close,將會向對端傳送一個RST分節,該分節會導致傳送緩衝區中待發送的資料被丟棄,而不是正常的TCP斷開連線序列,從而導致客戶端沒有收到完整的檔案。
問題的本質是:在沒有確認對端程序已經收到了完整的資料,就close了socket。那麼如何保證確保對端程序已經收到了完整的資料呢?
如何解決(如何正確關閉連線)?
一句話:read讀到0之後才close。
傳送完資料後,呼叫shutdown(第二個引數設定為SHUT_WR),後跟一個read呼叫,該read返回0,表示對端也關閉了連線(這意味著對端應用程序完整接收了我們傳送的資料),然後才close。
傳送方接收方程式結構如下:
傳送方:1.send() , 2.傳送完畢後呼叫shutdown(WR), 5.read()->0(此時傳送方才算能確認接收方已經接收了全部資料), 6.close()。
接收方:3.read()->0(說明沒有資料可讀了), 4.如果沒有資料可發呼叫close()。
序號表明了時間的順序。
我們修改之前的服務端程式碼:
int main(int argc, char* argv[]) {
if (argc < 3) {
printf("Usage:%s filename port\n", argv[0]);
return 0;
}
int sockfd = acceptOrDie(atoi(argv[2]));
printf("accept client\n");
FILE* fp = fopen(argv[1], "rb");
if (!fp) {
return 0;
}
printf("sleeping 10 seconds\n");
sleep(10);
char buf[8192];
size_t nr = 0;
while ((nr = fread(buf, 1, sizeof buf, fp)) > 0) {
write_n(sockfd, buf, nr);
}
fclose(fp);
shutdown(sockfd, SHUT_WR); //新增程式碼,傳送FIN分節
while ((nr = read(sockfd, buf, sizeof buf)) > 0) { //新增程式碼,等客戶端close
//do nothing
}
printf("finish sending file %s\n", argv[1]);
}
這次在while迴圈結束後,不是直接退出main,而是shutdown,然後迴圈read,等客戶端先close,客戶端close後,read會返回0,然後退出main函式。這樣就能保證資料被完整發送了。
root@DESKTOP-2A432QS:/mnt/c/Users/12401/Desktop/network_programing/recipes-master/tpc# nc localhost 1234 | wc -c
abcdefg
1048576
這次就算客戶端傳送了資料,也能保證收到了完整的1M資料。
參考資料:
- why is my tcp not reliable
SIGPIPE訊號
什麼場景下會產生SIGPIPE訊號?
如果一個 socket 在接收到了 RST packet之後,程式仍然向這個socket寫入資料,那麼就會產生SIGPIPE訊號。
具體例子見“本端TCP傳送資料時對端程序已經崩潰”這一節。
如何處理SIGPIPE訊號?
signal(SIGPIPE, SIG_IGN); // 忽略 SIGPIPE 訊號
直接忽略該訊號,此時write()會返回-1,並且此時errno的值為EPIPE。
Nagle演算法,TCP_NODELAY
Nagle演算法的基本定義是任意時刻,最多隻能有一個未被確認的小段。 所謂“小段”,指的是小於MSS尺寸的資料塊,所謂“未被確認”,是指一個數據塊傳送出去後,沒有收到對方傳送的ACK確認該資料已收到。
通過TCP_NODELAY選項關閉Nagle演算法,一般都需要。
SO_RESUSEADDR
TCP主動關閉的一端在傳送最後一個ACK後,必須在TIME_WAIT狀態等待2倍的MSL(報文最大生存時間)。
在連線處於2MSL狀態期間,由該插口對(src_ip:src_port, dest_ip:dest_port)定義的連線不能被再次使用。對於服務端,如果伺服器主動斷開連線,那麼在2MSL時間內,該伺服器無法在相同的埠,再次啟動。
可以使用SO_REUSEADDR選項,允許一個程序重新使用處於2MSL等待的埠。
為什麼要設計2MSL狀態?
這樣可以防止最後一個ACK丟失,如果丟失了,在2倍的MSL時間內,對端會重發FIN,然後主動關閉的一端可以再次傳送ACK,以確保連線正確關閉。
為什麼處於2MSL狀態時該插口對定義的連線不能被再用?
假設處於2MSL狀態的插口對,能再次被使用,那麼前一個連線遲到的報文對這個新的連線會有影響。
示例
以前文的sender為例,在服務端執行./sender file_1M_size 1234
,然後客戶端進行連線nc localhost 1234 | wc -c
,連線後,終止sender程序。
用netstat檢視會發現這個連線處於TIME_WAIT狀態,然後試圖再在1234埠啟動sender會發現:
bind: Address already in use
解決辦法
開啟套接字的SO_REUSEADDR選項。
int yes = 1;
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)))
{
perror("setsockopt");
exit(1);
}