1. 程式人生 > >TCP連線與斷開詳解(socket通訊)

TCP連線與斷開詳解(socket通訊)

一、TCP資料報結構以及三次握手

TCP(Transmission Control Protocol,傳輸控制協議)是一種面向連線的、可靠的、基於位元組流的通訊協議,資料在傳輸前要建立連線,傳輸完畢後還要斷開連線。

客戶端在收發資料前要使用 connect() 函式和伺服器建立連線。建立連線的目的是保證IP地址、埠、物理鏈路等正確無誤,為資料的傳輸開闢通道。

TCP建立連線時要傳輸三個資料包,俗稱三次握手(Three-way Handshaking)。可以形象的比喻為下面的對話:

  • [Shake 1] 套接字A:“你好,套接字B,我這裡有資料要傳送給你,建立連線吧。”
  • [Shake 2] 套接字B:“好的,我這邊已準備就緒。”
  • [Shake 3] 套接字A:“謝謝你受理我的請求。”

TCP資料報結構

我們先來看一下TCP資料報的結構:


帶陰影的幾個欄位需要重點說明一下:
1) 序號:Seq(Sequence Number)序號佔32位,用來標識從計算機A傳送到計算機B的資料包的序號,計算機發送資料時對此進行標記。

2) 確認號:Ack(Acknowledge Number)確認號佔32位,客戶端和伺服器端都可以傳送,Ack = Seq + 1。

3) 標誌位:每個標誌位佔用1Bit,共有6個,分別為 URG、ACK、PSH、RST、SYN、FIN,具體含義如下:

URG:緊急指標(urgent pointer)有效。

ACK:確認序號有效。

PSH:接收方應該儘快將這個報文交給應用層。

RST:重置連線。

SYN:建立一個新連線。

FIN:斷開一個連線。

對英文字母縮寫的總結:Seq 是 Sequence 的縮寫,表示序列;Ack(ACK) 是 Acknowledge 的縮寫,表示確認;SYN 是 Synchronous 的縮寫,願意是“同步的”,這裡表示建立同步連線;FIN 是 Finish 的縮寫,表示完成。

連線的建立(三次握手)

使用 connect() 建立連線時,客戶端和伺服器端會相互發送三個資料包,請看下圖:


客戶端呼叫 socket() 函式建立套接字後,因為沒有建立連線,所以套接字處於CLOSED

狀態;伺服器端呼叫 listen() 函式後,套接字進入LISTEN狀態,開始監聽客戶端請求。

這個時候,客戶端開始發起請求:
1) 當客戶端呼叫 connect() 函式後,TCP協議會組建一個數據包,並設定 SYN 標誌位,表示該資料包是用來建立同步連線的。同時生成一個隨機數字 1000,填充“序號(Seq)”欄位,表示該資料包的序號。完成這些工作,開始向伺服器端傳送資料包,客戶端就進入了SYN-SEND狀態。

2) 伺服器端收到資料包,檢測到已經設定了 SYN 標誌位,就知道這是客戶端發來的建立連線的“請求包”。伺服器端也會組建一個數據包,並設定 SYN 和 ACK 標誌位,SYN 表示該資料包用來建立連線,ACK 用來確認收到了剛才客戶端傳送的資料包。

伺服器生成一個隨機數 2000,填充“序號(Seq)”欄位。2000 和客戶端資料包沒有關係。

伺服器將客戶端資料包序號(1000)加1,得到1001,並用這個數字填充“確認號(Ack)”欄位。

伺服器將資料包發出,進入SYN-RECV狀態。

3) 客戶端收到資料包,檢測到已經設定了 SYN 和 ACK 標誌位,就知道這是伺服器發來的“確認包”。客戶端會檢測“確認號(Ack)”欄位,看它的值是否為 1000+1,如果是就說明連線建立成功。

接下來,客戶端會繼續組建資料包,並設定 ACK 標誌位,表示客戶端正確接收了伺服器發來的“確認包”。同時,將剛才伺服器發來的資料包序號(2000)加1,得到 2001,並用這個數字來填充“確認號(Ack)”欄位。

客戶端將資料包發出,進入ESTABLISED狀態,表示連線已經成功建立。

4) 伺服器端收到資料包,檢測到已經設定了 ACK 標誌位,就知道這是客戶端發來的“確認包”。伺服器會檢測“確認號(Ack)”欄位,看它的值是否為 2000+1,如果是就說明連線建立成功,伺服器進入ESTABLISED狀態。

至此,客戶端和伺服器都進入了ESTABLISED狀態,連線建立成功,接下來就可以收發資料了。

最後的說明

三次握手的關鍵是要確認對方收到了自己的資料包,這個目標就是通過“確認號(Ack)”欄位實現的。計算機會記錄下自己傳送的資料包序號 Seq,待收到對方的資料包後,檢測“確認號(Ack)”欄位,看Ack = Seq + 1是否成立,如果成立說明對方正確收到了自己的資料包。

二、TCP資料的傳輸過程

建立連線後,兩臺主機就可以相互傳輸資料了。如下圖所示:


圖1:TCP 套接字的資料交換過程


上圖給出了主機A分2次(分2個數據包)向主機B傳遞200位元組的過程。首先,主機A通過1個數據包傳送100個位元組的資料,資料包的 Seq 號設定為 1200。主機B為了確認這一點,向主機A傳送 ACK 包,並將 Ack 號設定為 1301。

為了保證資料準確到達,目標機器在收到資料包(包括SYN包、FIN包、普通資料包等)包後必須立即回傳ACK包,這樣傳送方才能確認資料傳輸成功。

此時 Ack 號為 1301 而不是 1201,原因在於 Ack 號的增量為傳輸的資料位元組數。假設每次 Ack 號不加傳輸的位元組數,這樣雖然可以確認資料包的傳輸,但無法明確100位元組全部正確傳遞還是丟失了一部分,比如只傳遞了80位元組。因此按如下的公式確認 Ack 號:

Ack號 = Seq號 + 傳遞的位元組數 + 1

與三次握手協議相同,最後加 1 是為了告訴對方要傳遞的 Seq 號。

下面分析傳輸過程中資料包丟失的情況,如下圖所示:


圖2:TCP套接字資料傳輸過程中發生錯誤


上圖表示通過 Seq 1301 資料包向主機B傳遞100位元組的資料,但中間發生了錯誤,主機B未收到。經過一段時間後,主機A仍未收到對於 Seq 1301 的ACK確認,因此嘗試重傳資料。

為了完成資料包的重傳,TCP套接字每次傳送資料包時都會啟動定時器,如果在一定時間內沒有收到目標機器傳回的 ACK 包,那麼定時器超時,資料包會重傳。

上圖演示的是資料包丟失的情況,也會有 ACK 包丟失的情況,一樣會重傳。

重傳超時時間(RTO, Retransmission Time Out)

這個值太大了會導致不必要的等待,太小會導致不必要的重傳,理論上最好是網路 RTT 時間,但又受制於網路距離與瞬態時延變化,所以實際上使用自適應的動態演算法(例如 Jacobson 演算法和 Karn 演算法等)來確定超時時間。

往返時間(RTT,Round-Trip Time)表示從傳送端傳送資料開始,到傳送端收到來自接收端的 ACK 確認包(接收端收到資料後便立即確認),總共經歷的時延。

重傳次數

TCP資料包重傳次數根據系統設定的不同而有所區別。有些系統,一個數據包只會被重傳3次,如果重傳3次後還未收到該資料包的 ACK 確認,就不再嘗試重傳。但有些要求很高的業務系統,會不斷地重傳丟失的資料包,以盡最大可能保證業務資料的正常互動。

三、TCP四次握手斷開連線

建立連線非常重要,它是資料正確傳輸的前提;斷開連線同樣重要,它讓計算機釋放不再使用的資源。如果連線不能正常斷開,不僅會造成資料傳輸錯誤,還會導致套接字不能關閉,持續佔用資源,如果併發量高,伺服器壓力堪憂。

建立連線需要三次握手,斷開連線需要四次握手,可以形象的比喻為下面的對話:

  • [Shake 1] 套接字A:“任務處理完畢,我希望斷開連線。”
  • [Shake 2] 套接字B:“哦,是嗎?請稍等,我準備一下。”
  • 等待片刻後……
  • [Shake 3] 套接字B:“我準備好了,可以斷開連線了。”
  • [Shake 4] 套接字A:“好的,謝謝合作。”


下圖演示了客戶端主動斷開連線的場景:


建立連線後,客戶端和伺服器都處於ESTABLISED狀態。這時,客戶端發起斷開連線的請求:
1) 客戶端呼叫 close() 函式後,向伺服器傳送 FIN 資料包,進入FIN_WAIT_1狀態。FIN 是 Finish 的縮寫,表示完成任務需要斷開連線。

2) 伺服器收到資料包後,檢測到設定了 FIN 標誌位,知道要斷開連線,於是向客戶端傳送“確認包”,進入CLOSE_WAIT狀態。

注意:伺服器收到請求後並不是立即斷開連線,而是先向客戶端傳送“確認包”,告訴它我知道了,我需要準備一下才能斷開連線。

3) 客戶端收到“確認包”後進入FIN_WAIT_2狀態,等待伺服器準備完畢後再次傳送資料包。

4) 等待片刻後,伺服器準備完畢,可以斷開連線,於是再主動向客戶端傳送 FIN 包,告訴它我準備好了,斷開連線吧。然後進入LAST_ACK狀態。

5) 客戶端收到伺服器的 FIN 包後,再向伺服器傳送 ACK 包,告訴它你斷開連線吧。然後進入TIME_WAIT狀態。

6) 伺服器收到客戶端的 ACK 包後,就斷開連線,關閉套接字,進入CLOSED狀態。

關於 TIME_WAIT 狀態的說明

客戶端最後一次傳送 ACK包後進入 TIME_WAIT 狀態,而不是直接進入 CLOSED 狀態關閉連線,這是為什麼呢?

TCP 是面向連線的傳輸方式,必須保證資料能夠正確到達目標機器,不能丟失或出錯,而網路是不穩定的,隨時可能會毀壞資料,所以機器A每次向機器B傳送資料包後,都要求機器B”確認“,回傳ACK包,告訴機器A我收到了,這樣機器A才能知道資料傳送成功了。如果機器B沒有回傳ACK包,機器A會重新發送,直到機器B回傳ACK包。

客戶端最後一次向伺服器回傳ACK包時,有可能會因為網路問題導致伺服器收不到,伺服器會再次傳送 FIN 包,如果這時客戶端完全關閉了連線,那麼伺服器無論如何也收不到ACK包了,所以客戶端需要等待片刻、確認對方收到ACK包後才能進入CLOSED狀態。那麼,要等待多久呢?

資料包在網路中是有生存時間的,超過這個時間還未到達目標主機就會被丟棄,並通知源主機。這稱為報文最大生存時間(MSL,Maximum Segment Lifetime)。TIME_WAIT 要等待 2MSL 才會進入 CLOSED 狀態。ACK 包到達伺服器需要 MSL 時間,伺服器重傳 FIN 包也需要 MSL 時間,2MSL 是資料包往返的最大時間,如果 2MSL 後還未收到伺服器重傳的 FIN 包,就說明伺服器已經收到了 ACK 包。

四、優雅的斷開連線--shutdown()

呼叫 close()/closesocket() 函式意味著完全斷開連線,即不能傳送資料也不能接收資料,這種“生硬”的方式有時候會顯得不太“優雅”。


圖1:close()/closesocket() 斷開連線


上圖演示了兩臺正在進行雙向通訊的主機。主機A傳送完資料後,單方面呼叫 close()/closesocket() 斷開連線,之後主機A、B都不能再接受對方傳輸的資料。實際上,是完全無法呼叫與資料收發有關的函式。
一般情況下這不會有問題,但有些特殊時刻,需要只斷開一條資料傳輸通道,而保留另一條。
使用 shutdown() 函式可以達到這個目的,它的原型為:

  1. int shutdown(int sock, int howto); //Linux

  2. int shutdown(SOCKET s, int howto); //Windows

sock 為需要斷開的套接字,howto 為斷開方式。

howto 在 Linux 下有以下取值:

  • SHUT_RD:斷開輸入流。套接字無法接收資料(即使輸入緩衝區收到資料也被抹去),無法呼叫輸入相關函式。
  • SHUT_WR:斷開輸出流。套接字無法傳送資料,但如果輸出緩衝區中還有未傳輸的資料,則將傳遞到目標主機。
  • SHUT_RDWR:同時斷開 I/O 流。相當於分兩次呼叫 shutdown(),其中一次以 SHUT_RD 為引數,另一次以 SHUT_WR 為引數。


howto 在 Windows 下有以下取值:

  • SD_RECEIVE:關閉接收操作,也就是斷開輸入流。
  • SD_SEND:關閉傳送操作,也就是斷開輸出流。
  • SD_BOTH:同時關閉接收和傳送操作。


至於什麼時候需要呼叫 shutdown() 函式,下節我們會以檔案傳輸為例進行講解。

close()/closesocket()和shutdown()的區別

確切地說,close() / closesocket() 用來關閉套接字,將套接字描述符(或控制代碼)從記憶體清除,之後再也不能使用該套接字,與C語言中的 fclose() 類似。應用程式關閉套接字後,與該套接字相關的連線和快取也失去了意義,TCP協議會自動觸發關閉連線的操作。
shutdown() 用來關閉連線,而不是套接字,不管呼叫多少次 shutdown(),套接字依然存在,直到呼叫 close() / closesocket() 將套接字從記憶體清除。
呼叫 close()/closesocket() 關閉套接字時,或呼叫 shutdown() 關閉輸出流時,都會向對方傳送 FIN 包。FIN 包表示資料傳輸完畢,計算機收到 FIN 包就知道不會再有資料傳送過來了。
預設情況下,close()/closesocket() 會立即向網路中傳送FIN包,不管輸出緩衝區中是否還有資料,而shutdown() 會等輸出緩衝區中的資料傳輸完畢再發送FIN包。也就意味著,呼叫 close()/closesocket() 將丟失輸出緩衝區中的資料,而呼叫 shutdown() 不會。

五、socket檔案傳輸功能的實現

我們來完成 socket 檔案傳輸程式,這是一個非常實用的例子。要實現的功能為:client 從 server 下載一個檔案並儲存到本地。
編寫這個程式需要注意兩個問題:
1) 檔案大小不確定,有可能比緩衝區大很多,呼叫一次 write()/send() 函式不能完成檔案內容的傳送。接收資料時也會遇到同樣的情況。
要解決這個問題,可以使用 while 迴圈,例如:

  1. //Server 程式碼

  2. int nCount;

  3. while( (nCount = fread(buffer, 1, BUF_SIZE, fp)) > 0 ){

  4. send(sock, buffer, nCount, 0);

  5. }

  6. //Client 程式碼

  7. int nCount;

  8. while( (nCount = recv(clntSock, buffer, BUF_SIZE, 0)) > 0 ){

  9. fwrite(buffer, nCount, 1, fp);

  10. }

對於 Server 端的程式碼,當讀取到檔案末尾,fread() 會返回 0,結束迴圈。

對於 Client 端程式碼,有一個關鍵的問題,就是檔案傳輸完畢後讓 recv() 返回 0,結束 while 迴圈。

注意:讀取完緩衝區中的資料 recv() 並不會返回 0,而是被阻塞,直到緩衝區中再次有資料。

2) Client 端如何判斷檔案接收完畢,也就是上面提到的問題——何時結束 while 迴圈。
最簡單的結束 while 迴圈的方法當然是檔案接收完畢後讓 recv() 函式返回 0,那麼,如何讓 recv() 返回 0 呢?recv() 返回 0 的唯一時機就是收到FIN包時。
FIN 包表示資料傳輸完畢,計算機收到 FIN 包後就知道對方不會再向自己傳輸資料,當呼叫 read()/recv() 函式時,如果緩衝區中沒有資料,就會返回 0,表示讀到了”socket檔案的末尾“。
這裡我們呼叫 shutdown() 來發送FIN包:server 端直接呼叫 close()/closesocket() 會使輸出緩衝區中的資料失效,檔案內容很有可能沒有傳輸完畢連線就斷開了,而呼叫 shutdown() 會等待輸出緩衝區中的資料傳輸完畢。
本節以Windows為例演示檔案傳輸功能,Linux與此類似,不再贅述。請看下面完整的程式碼。
伺服器端 server.cpp:

  1. #include <stdio.h>

  2. #include <stdlib.h>

  3. #include <winsock2.h>

  4. #pragma comment (lib, "ws2_32.lib") //載入 ws2_32.dll

  5. #define BUF_SIZE 1024

  6. int main(){

  7. //先檢查檔案是否存在

  8. char *filename = "D:\\send.avi"; //檔名

  9. FILE *fp = fopen(filename, "rb"); //以二進位制方式開啟檔案

  10. if(fp == NULL){

  11. printf("Cannot open file, press any key to exit!\n");

  12. system("pause");

  13. exit(0);

  14. }

  15. WSADATA wsaData;

  16. WSAStartup( MAKEWORD(2, 2), &wsaData);

  17. SOCKET servSock = socket(AF_INET, SOCK_STREAM, 0);

  18. sockaddr_in sockAddr;

  19. memset(&sockAddr, 0, sizeof(sockAddr));

  20. sockAddr.sin_family = PF_INET;

  21. sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");

  22. sockAddr.sin_port = htons(1234);

  23. bind(servSock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));

  24. listen(servSock, 20);

  25. SOCKADDR clntAddr;

  26. int nSize = sizeof(SOCKADDR);

  27. SOCKET clntSock = accept(servSock, (SOCKADDR*)&clntAddr, &nSize);

  28. //迴圈傳送資料,直到檔案結尾

  29. char buffer[BUF_SIZE] = {0}; //緩衝區

  30. int nCount;

  31. while( (nCount = fread(buffer, 1, BUF_SIZE, fp)) > 0 ){

  32. send(clntSock, buffer, nCount, 0);

  33. }

  34. shutdown(clntSock, SD_SEND); //檔案讀取完畢,斷開輸出流,向客戶端傳送FIN包

  35. recv(clntSock, buffer, BUF_SIZE, 0); //阻塞,等待客戶端接收完畢

  36. fclose(fp);

  37. closesocket(clntSock);

  38. closesocket(servSock);

  39. WSACleanup();

  40. system("pause");

  41. return 0;

  42. }


客戶端程式碼:

  1. #include <stdio.h>

  2. #include <stdlib.h>

  3. #include <WinSock2.h>

  4. #pragma comment(lib, "ws2_32.lib")

  5. #define BUF_SIZE 1024

  6. int main(){

  7. //先輸入檔名,看檔案是否能建立成功

  8. char filename[100] = {0}; //檔名

  9. printf("Input filename to save: ");

  10. gets(filename);

  11. FILE *fp = fopen(filename, "wb"); //以二進位制方式開啟(建立)檔案

  12. if(fp == NULL){

  13. printf("Cannot open file, press any key to exit!\n");

  14. system("pause");

  15. exit(0);

  16. }

  17. WSADATA wsaData;

  18. WSAStartup(MAKEWORD(2, 2), &wsaData);

  19. SOCKET sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

  20. sockaddr_in sockAddr;

  21. memset(&sockAddr, 0, sizeof(sockAddr));

  22. sockAddr.sin_family = PF_INET;

  23. sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");

  24. sockAddr.sin_port = htons(1234);

  25. connect(sock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));

  26. //迴圈接收資料,直到檔案傳輸完畢

  27. char buffer[BUF_SIZE] = {0}; //檔案緩衝區

  28. int nCount;

  29. while( (nCount = recv(sock, buffer, BUF_SIZE, 0)) > 0 ){

  30. fwrite(buffer, nCount, 1, fp);

  31. }

  32. puts("File transfer success!");

  33. //檔案接收完畢後直接關閉套接字,無需呼叫shutdown()

  34. fclose(fp);

  35. closesocket(sock);

  36. WSACleanup();

  37. system("pause");

  38. return 0;

  39. }

在D盤中準備好send.avi檔案,先執行 server,再執行 client:
Input filename to save: D:\\recv.avi↙
//稍等片刻後
File transfer success!
開啟D盤就可以看到 recv.avi,大小和 send.avi 相同,可以正常播放。
注意 server.cpp 第42行程式碼,recv() 並沒有接收到 client 端的資料,當 client 端呼叫 closesocket() 後,server 端會收到FIN包,recv() 就會返回,後面的程式碼繼續執行。