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
狀態,開始監聽客戶端請求。這個時候,客戶端開始發起請求:
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() 函式可以達到這個目的,它的原型為:
-
int shutdown(int sock, int howto); //Linux
-
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 迴圈,例如:
-
//Server 程式碼
-
int nCount;
-
while( (nCount = fread(buffer, 1, BUF_SIZE, fp)) > 0 ){
-
send(sock, buffer, nCount, 0);
-
}
-
//Client 程式碼
-
int nCount;
-
while( (nCount = recv(clntSock, buffer, BUF_SIZE, 0)) > 0 ){
-
fwrite(buffer, nCount, 1, fp);
-
}
對於 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:
-
#include <stdio.h>
-
#include <stdlib.h>
-
#include <winsock2.h>
-
#pragma comment (lib, "ws2_32.lib") //載入 ws2_32.dll
-
#define BUF_SIZE 1024
-
int main(){
-
//先檢查檔案是否存在
-
char *filename = "D:\\send.avi"; //檔名
-
FILE *fp = fopen(filename, "rb"); //以二進位制方式開啟檔案
-
if(fp == NULL){
-
printf("Cannot open file, press any key to exit!\n");
-
system("pause");
-
exit(0);
-
}
-
WSADATA wsaData;
-
WSAStartup( MAKEWORD(2, 2), &wsaData);
-
SOCKET servSock = socket(AF_INET, SOCK_STREAM, 0);
-
sockaddr_in sockAddr;
-
memset(&sockAddr, 0, sizeof(sockAddr));
-
sockAddr.sin_family = PF_INET;
-
sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
-
sockAddr.sin_port = htons(1234);
-
bind(servSock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));
-
listen(servSock, 20);
-
SOCKADDR clntAddr;
-
int nSize = sizeof(SOCKADDR);
-
SOCKET clntSock = accept(servSock, (SOCKADDR*)&clntAddr, &nSize);
-
//迴圈傳送資料,直到檔案結尾
-
char buffer[BUF_SIZE] = {0}; //緩衝區
-
int nCount;
-
while( (nCount = fread(buffer, 1, BUF_SIZE, fp)) > 0 ){
-
send(clntSock, buffer, nCount, 0);
-
}
-
shutdown(clntSock, SD_SEND); //檔案讀取完畢,斷開輸出流,向客戶端傳送FIN包
-
recv(clntSock, buffer, BUF_SIZE, 0); //阻塞,等待客戶端接收完畢
-
fclose(fp);
-
closesocket(clntSock);
-
closesocket(servSock);
-
WSACleanup();
-
system("pause");
-
return 0;
-
}
客戶端程式碼:
-
#include <stdio.h>
-
#include <stdlib.h>
-
#include <WinSock2.h>
-
#pragma comment(lib, "ws2_32.lib")
-
#define BUF_SIZE 1024
-
int main(){
-
//先輸入檔名,看檔案是否能建立成功
-
char filename[100] = {0}; //檔名
-
printf("Input filename to save: ");
-
gets(filename);
-
FILE *fp = fopen(filename, "wb"); //以二進位制方式開啟(建立)檔案
-
if(fp == NULL){
-
printf("Cannot open file, press any key to exit!\n");
-
system("pause");
-
exit(0);
-
}
-
WSADATA wsaData;
-
WSAStartup(MAKEWORD(2, 2), &wsaData);
-
SOCKET sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
-
sockaddr_in sockAddr;
-
memset(&sockAddr, 0, sizeof(sockAddr));
-
sockAddr.sin_family = PF_INET;
-
sockAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
-
sockAddr.sin_port = htons(1234);
-
connect(sock, (SOCKADDR*)&sockAddr, sizeof(SOCKADDR));
-
//迴圈接收資料,直到檔案傳輸完畢
-
char buffer[BUF_SIZE] = {0}; //檔案緩衝區
-
int nCount;
-
while( (nCount = recv(sock, buffer, BUF_SIZE, 0)) > 0 ){
-
fwrite(buffer, nCount, 1, fp);
-
}
-
puts("File transfer success!");
-
//檔案接收完畢後直接關閉套接字,無需呼叫shutdown()
-
fclose(fp);
-
closesocket(sock);
-
WSACleanup();
-
system("pause");
-
return 0;
-
}
在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() 就會返回,後面的程式碼繼續執行。