Socket原理
轉至:https://www.jianshu.com/p/066d99da7cbd
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.3、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,具體含義如下:
(1)URG:緊急指針(urgent pointer)有效。
(2)ACK:確認序號有效。
(3)PSH:接收方應該盡快將這個報文交給應用層。
(4)RST:重置連接。
(5)SYN:建立一個新連接。
(6)FIN:斷開一個連接。
4.4、連接的建立(三次握手):
使用 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狀態,連接建立成功,接下來就可以收發數據了。
4.5、TCP四次握手斷開連接
建立連接非常重要,它是數據正確傳輸的前提;斷開連接同樣重要,它讓計算機釋放不再使用的資源。如果連接不能正常斷開,不僅會造成數據傳輸錯誤,還會導致套接字不能關閉,持續占用資源,如果並發量高,服務器壓力堪憂。
斷開連接需要四次握手,可以形象的比喻為下面的對話:
[Shake 1] 套接字A:“任務處理完畢,我希望斷開連接。”
[Shake 2] 套接字B:“哦,是嗎?請稍等,我准備一下。”
等待片刻后……
[Shake 3] 套接字B:“我准備好了,可以斷開連接了。”
[Shake 4] 套接字A:“好的,謝謝合作。”
下圖演示了客戶端主動斷開連接的場景:
建立連接后,客戶端和服務器都處於ESTABLISED狀態。這時,客戶端發起斷開連接的請求:
- 客戶端調用 close() 函數后,向服務器發送 FIN 數據包,進入FIN_WAIT_1狀態。FIN 是 Finish 的縮寫,表示完成任務需要斷開連接。
- 服務器收到數據包后,檢測到設置了 FIN 標志位,知道要斷開連接,於是向客戶端發送“確認包”,進入CLOSE_WAIT狀態。
注意:服務器收到請求后並不是立即斷開連接,而是先向客戶端發送“確認包”,告訴它我知道了,我需要准備一下才能斷開連接。 - 客戶端收到“確認包”后進入FIN_WAIT_2狀態,等待服務器准備完畢后再次發送數據包。
- 等待片刻后,服務器准備完畢,可以斷開連接,於是再主動向客戶端發送 FIN 包,告訴它我准備好了,斷開連接吧。然后進入LAST_ACK狀態。
- 客戶端收到服務器的 FIN 包后,再向服務器發送 ACK 包,告訴它你斷開連接吧。然后進入TIME_WAIT狀態。
- 服務器收到客戶端的 ACK 包后,就斷開連接,關閉套接字,進入CLOSED狀態。
4.6、關於 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.7優雅的斷開連接–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的網絡模型層進行了划分如下:
TCP/IP協議參考模型把所有的TCP/IP系列協議歸類到四個抽象層中
應用層:TFTP,HTTP,SNMP,FTP,SMTP,DNS,Telnet 等等
傳輸層:TCP,UDP
網絡層:IP,ICMP,OSPF,EIGRP,IGMP
數據鏈路層:SLIP,CSLIP,PPP,MTU
每一抽象層建立在低一層提供的服務上,並且為高一層提供服務,看起來大概是這樣子的
6、Socket常用函數接口及其原理
圖解socket函數:
6.1、使用socket()函數創建套接字
int socket(int af, int type, int protocol);
- 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地址,表示本機地址,后面的教程會經常用到。 - type 為數據傳輸方式,常用的有 SOCK_STREAM 和 SOCK_DGRAM
- 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() 函數也是如此,也從輸入緩沖區中讀取數據,而不是直接從網絡中讀取
這些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套接字默認情況下是阻塞模式