TCP使用注意事項總結


發送或者接受數據過程中對端可能發生的情況匯總

《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
選項,否則本端無法得知對端主機已經崩潰。

某個連接長時間沒有數據流動

這一種情況對應表格中的第三、四行。

  1. 如果沒有設置SO_KEEPALIVE選項,那么如果對端只是進程崩潰,那么本端還是可以通過select檢測到的,但是如果對端主機崩潰或者變得不可達,那么本端沒有辦法得知,這個連接也得不到正常的關閉。
  2. 如果設置了該選項。
    這個選項是用來檢測對端是否主機崩潰或者變得不可達(比如網線斷開),而不是檢測對端進程是否崩潰,如果是進程崩潰的話會發送一個FIN,本端可以用select檢測到。但是如果對端長時間沒有數據流動,我們除了設置這個選項,沒有辦法得知對端是不是主機崩潰或者變得不可達。
    設置該選項后,如果2小時內該套接字任一方向上都沒有數據交換,TCP就自動給對端發送一個探測分節,可能出現三種情況:
    1. 對端響應ACK。表示一切正常,應用進程不會得到任何通知。
    2. 對端響應RST,表示對端已崩潰且以重新啟動,該套接字的待處理錯誤被置為ECONNRESET,套接字被關閉。
    3. 對端沒有任何響應,那么隔一段時間再次發送探測分節,如果還是沒有響應,套接字錯誤被置為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數據。

參考資料:

  1. 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);
  }


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM