1. 程式人生 > 實用技巧 >Socket技術詳解

Socket技術詳解

Socket原理

1、什麼是Socket

在計算機通訊領域,socket 被翻譯為“套接字”,它是計算機之間進行通訊一種約定或一種方式。通過 socket 這種約定,一臺計算機可以接收其他計算機的資料,也可以向其他計算機發送資料
  socket起源於Unix,而Unix/Linux基本哲學之一就是“一切皆檔案”,都可以用“開啟open –> 讀寫write/read –> 關閉close”模式來操作。
  我的理解就是Socket就是該模式的一個實現:即socket是一種特殊的檔案,一些socket函式就是對其進行的操作(讀/寫IO、開啟、關閉)。

  Socket()函式返回一個整型的Socket描述符,隨後的連線建立、資料傳輸等操作都是通過該Socket實現的。

2、網路中程序如何通訊

既然Socket主要是用來解決網路通訊的,那麼我們就來理解網路中程序是如何通訊的。

2.1、本地程序間通訊

a、訊息傳遞(管道、訊息佇列、FIFO)
  b、同步(互斥量、條件變數、讀寫鎖、檔案和寫記錄鎖、訊號量)?【不是很明白】
  c、共享記憶體(匿名的和具名的,eg:channel)
  d、遠端過程呼叫(RPC)

2.2、網路中程序如何通訊

我們要理解網路中程序如何通訊,得解決兩個問題:
  a、我們要如何標識一臺主機,即怎樣確定我們將要通訊的程序是在那一臺主機上執行。

  b、我們要如何標識唯一程序,本地通過pid標識,網路中應該怎樣標識?
解決辦法:
  a、TCP/IP協議族已經幫我們解決了這個問題,網路層的“ip地址”可以唯一標識網路中的主機
  b、傳輸層的“協議+埠”可以唯一標識主機中的應用程式(程序),因此,我們利用三元組(ip地址,協議,埠)就可以標識網路的程序了,網路中的程序通訊就可以利用這個標誌與其它程序進行互動

3、Socket怎麼通訊

現在,我們知道了網路中程序間如何通訊,即利用三元組【ip地址,協議,埠】可以進行網路間通訊了,那我們應該怎麼實現了,因此,我們socket應運而生,它就是利用三元組解決網路通訊的一箇中間件工具,就目前而言,幾乎所有的應用程式都是採用socket,如UNIX BSD的套接字(socket)和UNIX System V的TLI(已經被淘汰)。

Socket通訊的資料傳輸方式,常用的有兩種:
  a、SOCK_STREAM:表示面向連線的資料傳輸方式。資料可以準確無誤地到達另一臺計算機,如果損壞或丟失,可以重新發送,但效率相對較慢。常見的 http 協議就使用 SOCK_STREAM 傳輸資料,因為要確保資料的正確性,否則網頁不能正常解析。
  b、SOCK_DGRAM:表示無連線的資料傳輸方式。計算機只管傳輸資料,不作資料校驗,如果資料在傳輸中損壞,或者沒有到達另一臺計算機,是沒有辦法補救的。也就是說,資料錯了就錯了,無法重傳。因為 SOCK_DGRAM 所做的校驗工作少,所以效率比 SOCK_STREAM 高。
  例如:QQ 視訊聊天和語音聊天就使用 SOCK_DGRAM 傳輸資料,因為首先要保證通訊的效率,儘量減小延遲,而資料的正確性是次要的,即使丟失很小的一部分資料,視訊和音訊也可以正常解析,最多出現噪點或雜音,不會對通訊質量有實質的影響

4、TCP/IP協議

4.1、概念

TCP/IP【TCP(傳輸控制協議)和IP(網際協議)】提供點對點的連結機制,將資料應該如何封裝、定址、傳輸、路由以及在目的地如何接收,都加以標準化。它將軟體通訊過程抽象化為四個抽象層,採取協議堆疊的方式,分別實現出不同通訊協議。協議族下的各種協議,依其功能不同,被分別歸屬到這四個層次結構之中,常被視為是簡化的七層OSI模型。

它們之間好比送信的線路和驛站的作用,比如要建議送信驛站,必須得了解送信的各個細節。

TCP(Transmission Control Protocol,傳輸控制協議)是一種面向連線的、可靠的、基於位元組流的通訊協議,資料在傳輸前要建立連線,傳輸完畢後還要斷開連線,客戶端在收發資料前要使用 connect() 函式和伺服器建立連線。建立連線的目的是保證IP地址、埠、物理鏈路等正確無誤,為資料的傳輸開闢通道。
TCP建立連線時要傳輸三個資料包,俗稱三次握手(Three-way Handshaking)。可以形象的比喻為下面的對話:

[Shake 1] 套接字A:“你好,套接字B,我這裡有資料要傳送給你,建立連線吧。”
[Shake 2] 套接字B:“好的,我這邊已準備就緒。”
[Shake 3] 套接字A:“謝謝你受理我的請求。
4.2、TCP的粘包問題以及資料的無邊界性: https://blog.csdn.net/m0_37947204/article/details/80490512
4.4、TCP資料報結構:
20180529001000428.jpeg

帶陰影的幾個欄位需要重點說明一下:
  (1) 序號:Seq(Sequence Number)序號佔32位,用來標識從計算機A傳送到計算機B的資料包的序號,計算機發送資料時對此進行標記。
  (2) 確認號:Ack(Acknowledge Number)確認號佔32位,客戶端和伺服器端都可以傳送,Ack = Seq + 1。
  (3) 標誌位:每個標誌位佔用1Bit,共有6個,分別為 URG、ACK、PSH、RST、SYN、FIN,具體含義如下:

(1)URG:緊急指標(urgent pointer)有效。
(2)ACK:確認序號有效。
(3)PSH:接收方應該儘快將這個報文交給應用層。
(4)RST:重置連線。
(5)SYN:建立一個新連線。
(6)FIN:斷開一個連線。
4.5、連線的建立(三次握手):

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

   20180529001324885.jpeg

客戶端呼叫 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狀態,連線建立成功,接下來就可以收發資料了。

4.6、TCP四次握手斷開連線

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

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

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


20180529001837204.jpeg

建立連線後,客戶端和伺服器都處於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狀態。
4.7、關於 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 包

4.8.優雅的斷開連線–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() 不會

5、OSI模型

TCP/IP對OSI的網路模型層進行了劃分如下:


20150615140039701.jpeg

TCP/IP協議參考模型把所有的TCP/IP系列協議歸類到四個抽象層中
  應用層:TFTP,HTTP,SNMP,FTP,SMTP,DNS,Telnet 等等
  傳輸層:TCP,UDP
  網路層:IP,ICMP,OSPF,EIGRP,IGMP
  資料鏈路層:SLIP,CSLIP,PPP,MTU
  每一抽象層建立在低一層提供的服務上,並且為高一層提供服務,看起來大概是這樣子的

   20150615140707753.png 20150615141705040.png

6、Socket常用函式介面及其原理

圖解socket函式:


20150615150446559.png 20150615150618996.jpeg
6.1、使用socket()函式建立套接字
int socket(int af, int type, int protocol);
  1. af 為地址族(Address Family),也就是 IP 地址型別,常用的有 AF_INET 和 AF_INET6。AF 是“Address Family”的簡寫,INET是“Inetnet”的簡寫。AF_INET 表示 IPv4 地址,例如 127.0.0.1;AF_INET6 表示 IPv6 地址,例如 1030::C9B4:FF12:48AA:1A2B。
    大家需要記住127.0.0.1,它是一個特殊IP地址,表示本機地址,後面的教程會經常用到。
  2. type 為資料傳輸方式,常用的有 SOCK_STREAM 和 SOCK_DGRAM
  3. protocol 表示傳輸協議,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分別表示 TCP 傳輸協議和 UDP 傳輸協議
6.2、使用bind()和connect()函式

socket() 函式用來建立套接字,確定套接字的各種屬性,然後伺服器端要用 bind() 函式將套接字與特定的IP地址和埠繫結起來,只有這樣,流經該IP地址和埠的資料才能交給套接字處理;而客戶端要用 connect() 函式建立連線

int bind(int sock, struct sockaddr *addr, socklen_t addrlen);  

sock 為 socket 檔案描述符,addr 為 sockaddr 結構體變數的指標,addrlen 為 addr 變數的大小,可由 sizeof() 計算得出
下面的程式碼,將建立的套接字與IP地址 127.0.0.1、埠 1234 繫結:

//建立套接字
int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
//建立sockaddr_in結構體變數
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));  //每個位元組都用0填充
serv_addr.sin_family = AF_INET;  //使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");  //具體的IP地址
serv_addr.sin_port = htons(1234);  //埠
//將套接字和IP、埠繫結
bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

connect() 函式用來建立連線,它的原型為:

int connect(int sock, struct sockaddr *serv_addr, socklen_t addrlen); 
6.3、使用listen()和accept()函式

於伺服器端程式,使用 bind() 繫結套接字後,還需要使用 listen() 函式讓套接字進入被動監聽狀態,再呼叫 accept() 函式,就可以隨時響應客戶端的請求了。
通過** listen() 函式**可以讓套接字進入被動監聽狀態,它的原型為:

int listen(int sock, int backlog); 

sock 為需要進入監聽狀態的套接字,backlog 為請求佇列的最大長度。
所謂被動監聽,是指當沒有客戶端請求時,套接字處於“睡眠”狀態,只有當接收到客戶端請求時,套接字才會被“喚醒”來響應請求。

請求佇列
當套接字正在處理客戶端請求時,如果有新的請求進來,套接字是沒法處理的,只能把它放進緩衝區,待當前請求處理完畢後,再從緩衝區中讀取出來處理。如果不斷有新的請求進來,它們就按照先後順序在緩衝區中排隊,直到緩衝區滿。這個緩衝區,就稱為請求佇列(Request Queue)。

緩衝區的長度(能存放多少個客戶端請求)可以通過 listen() 函式的 backlog 引數指定,但究竟為多少並沒有什麼標準,可以根據你的需求來定,併發量小的話可以是10或者20。

如果將 backlog 的值設定為 SOMAXCONN,就由系統來決定請求佇列長度,這個值一般比較大,可能是幾百,或者更多。

當請求佇列滿時,就不再接收新的請求,對於 Linux,客戶端會收到 ECONNREFUSED 錯誤

注意:listen() 只是讓套接字處於監聽狀態,並沒有接收請求。接收請求需要使用 accept() 函式。

當套接字處於監聽狀態時,可以通過 accept() 函式來接收客戶端請求。它的原型為:

int accept(int sock, struct sockaddr *addr, socklen_t *addrlen); 

它的引數與 listen() 和 connect() 是相同的:sock 為伺服器端套接字,addr 為 sockaddr_in 結構體變數,addrlen 為引數 addr 的長度,可由 sizeof() 求得。

accept() 返回一個新的套接字來和客戶端通訊,addr 儲存了客戶端的IP地址和埠號,而 sock 是伺服器端的套接字,大家注意區分。後面和客戶端通訊時,要使用這個新生成的套接字,而不是原來伺服器端的套接字。

最後需要說明的是:listen() 只是讓套接字進入監聽狀態,並沒有真正接收客戶端請求,listen() 後面的程式碼會繼續執行,直到遇到 accept()。accept() 會阻塞程式執行(後面程式碼不能被執行),直到有新的請求到來。

6.4、socket資料的接收和傳送

Linux下資料的接收和傳送
Linux 不區分套接字檔案和普通檔案,使用 write() 可以向套接字中寫入資料,使用 read() 可以從套接字中讀取資料。

前面我們說過,兩臺計算機之間的通訊相當於兩個套接字之間的通訊,在伺服器端用 write() 向套接字寫入資料,客戶端就能收到,然後再使用 read() 從套接字中讀取出來,就完成了一次通訊。
write() 的原型為:

ssize_t write(int fd, const void *buf, size_t nbytes);

fd 為要寫入的檔案的描述符,buf 為要寫入的資料的緩衝區地址,nbytes 為要寫入的資料的位元組數。
write() 函式會將緩衝區 buf 中的 nbytes 個位元組寫入檔案 fd,成功則返回寫入的位元組數,失敗則返回 -1。
read() 的原型為:

ssize_t read(int fd, void *buf, size_t nbytes);

fd 為要讀取的檔案的描述符,buf 為要接收資料的緩衝區地址,nbytes 為要讀取的資料的位元組數。

read() 函式會從 fd 檔案中讀取 nbytes 個位元組並儲存到緩衝區 buf,成功則返回讀取到的位元組數(但遇到檔案結尾則返回0),失敗則返回 -1。

6.5、socket緩衝區以及阻塞模式

socket緩衝區
每個 socket 被建立後,都會分配兩個緩衝區,輸入緩衝區和輸出緩衝區。

write()/send() 並不立即向網路中傳輸資料,而是先將資料寫入緩衝區中,再由TCP協議將資料從緩衝區傳送到目標機器。一旦將資料寫入到緩衝區,函式就可以成功返回,不管它們有沒有到達目標機器,也不管它們何時被髮送到網路,這些都是TCP協議負責的事情。

TCP協議獨立於 write()/send() 函式,資料有可能剛被寫入緩衝區就傳送到網路,也可能在緩衝區中不斷積壓,多次寫入的資料被一次性發送到網路,這取決於當時的網路情況、當前執行緒是否空閒等諸多因素,不由程式設計師控制。

read()/recv() 函式也是如此,也從輸入緩衝區中讀取資料,而不是直接從網路中讀取

20180528234331238.jpeg

這些I/O緩衝區特性可整理如下:

(1)I/O緩衝區在每個TCP套接字中單獨存在;
(2)I/O緩衝區在建立套接字時自動生成;
(3)即使關閉套接字也會繼續傳送輸出緩衝區中遺留的資料;
(4)關閉套接字將丟失輸入緩衝區中的資料。

輸入輸出緩衝區的預設大小一般都是 8K,可以通過 getsockopt() 函式獲取:

unsigned optVal;
int optLen = sizeof(int);
getsockopt(servSock, SOL_SOCKET, SO_SNDBUF, (char*)&optVal, &optLen);
printf("Buffer length: %d\n", optVal);

阻塞模式
對於TCP套接字(預設情況下),當使用 write()/send() 傳送資料時:

1) 首先會檢查緩衝區,如果緩衝區的可用空間長度小於要傳送的資料,那麼 write()/send() 會被阻塞(暫停執行),直到緩衝區中的資料被髮送到目標機器,騰出足夠的空間,才喚醒 write()/send() 函式繼續寫入資料。
2) 如果TCP協議正在向網路傳送資料,那麼輸出緩衝區會被鎖定,不允許寫入,write()/send() 也會被阻塞,直到資料傳送完畢緩衝區解鎖,write()/send() 才會被喚醒。
3) 如果要寫入的資料大於緩衝區的最大長度,那麼將分批寫入。
4) 直到所有資料被寫入緩衝區 write()/send() 才能返回。

當使用 read()/recv() 讀取資料時:

1) 首先會檢查緩衝區,如果緩衝區中有資料,那麼就讀取,否則函式會被阻塞,直到網路上有資料到來。
2) 如果要讀取的資料長度小於緩衝區中的資料長度,那麼就不能一次性將緩衝區中的所有資料讀出,剩餘資料將不斷積壓,直到有 read()/recv() 函式再次讀取。
3) 直到讀取到資料後 read()/recv() 函式才會返回,否則就一直被阻塞。
這就是TCP套接字的阻塞模式。所謂阻塞,就是上一步動作沒有完成,下一步動作將暫停,直到上一步動作完成後才能繼續,以保持同步性。

TCP套接字預設情況下是阻塞模式



作者:yongfutian
連結:https://www.jianshu.com/p/066d99da7cbd
來源:簡書