接下來一段時間會對大家進行網絡通信的魔鬼訓練-理解socket


引子

下一篇標題是《深入理解MQ生產端的底層通信過程》,建議文章讀完之前、或者讀完之后,再讀一遍我之前寫的《RabbitMQ設計原理解析》,結合理解一下。

 

我大學時流行過一個韓劇《大長今》,大女主長今是個女廚。她升級打怪的過程中,中國明朝來了個官員,是個吃貨。那時候大明八方來朝,威風凜凜。那小朝鮮國可不敢怠慢,理論上應該大魚大肉。人家長今憑借女主光環,給官員上了一桌素餐。官員勃然大怒,要把長今拉去砍頭。長今解釋說:官員脾胃失和,不適合大魚大肉,讓官員給她一段時間,天天吃她做的菜,他吃着吃着就會覺得素餐好吃了。官員就和她簽了對賭協議。吃了一段時間素餐之后,官員向長今道歉,說明知道自己身體不適合大魚大肉,但是管不住嘴,長今幫了他大忙。

 

其實要講《深入理解MQ生產端的底層通信過程》這一篇之前我也做了很多的鋪墊:從《架構師之路-https底層原理》的https協議,到《一個http請求進來都經過了什么(2021版)》實際上經過的物理通道,然后深入理解三次握手《懂得三境界-使用dubbo時請求超過問題》。有的文章讀起來有點難度,我希望大家能像那位中國的官員一樣,雖然不情願但還是堅持一段時間,相信對於多數人來言對底層通信的理解會提升一個層次。

 

接下來是網絡編程的干貨時間,是下一篇文章的預備知識,不用擔心,淺顯易懂(多讀幾遍的話)。

 

socket編程究竟是什么?

 

socket的本質

socket的本質就是一種類型的文件,所以一個socket在進行讀寫操作時會對應一個文件描述符fd(file descriptor)。

 

socket的作用

 

 

上圖是四層TCP/IP網絡標准中,TCP/IP協議族的主要成員。今天只看上面兩層。

 

最上層的應用層,涉及的協議封裝的命令平時工作中也很常用,比如:ping、telnet。也有一些不是通過命令但也非常常用,比如:http。下一層的應用層有可靠的TCP協議和不可靠的UDP協議。平時工作中,常見的中間件如zookeeper、redis、dubbo這些都是使用TCP協議,因為這個內部封裝完善,使用更簡單。

 

要注意的是傳輸層操作是在內核空間完成的,就是說不是靠咱們平時的應用編碼可以直接介入的。咱們平時直接用的就是應用層協議。想通過應用層操作傳輸層怎么辦呢?這就用到了socket編程。

 

socket的簡單原理

 

 

Socket位於TCP/IP之上,通過Socket可以方便的進行通信連接。對外屏蔽了復雜的TCP/IP。它是一種"打開—讀/寫—關閉"模式的實現,服務器和客戶端各自維護一個"文件"(有對應的文件描述符fd),在建立連接打開后,可以向自己文件寫入內容供對方讀取或者讀取對方內容,通訊結束時關閉文件。

 

 

 

要注意的是,想建立通信連接,需要一對socket。一個是客戶端的socket,另外一個是服務端的socket。每個socket對應一個文件描述符fd。讀和寫都是通過這個fd完成的。但是一個socket對應兩個緩沖區。一個讀緩沖區,對應接收端;一個寫緩沖區,對應發送端。

 

再次理解三次握手和四次揮手

 

 

上面是TCP下通信調用Linux Socket API流程。

 

服務端一啟動,就要先調用socket函數建立socket,socket會調用bind函數綁定對應的IP和端口。之后listen函數的作用可能和大多數人理解都不同,它的主要作用是設置監聽上限。就是允許多少個客戶端進行連接。accept函數是以監聽客戶端請求的。調用了這個函數就相當於咱們平時的thrift服務端啟動了。具備了三次握手的條件。

 

這時候客戶端也建立一個套接字,調用connect函數執行三次握手。成功后,服務端調用accept函數新建立一個socket專門用來和這個客戶端進行通信。之前的老socket用來監聽別的請求。這里注意:客戶端套接字和服務端套接字是成對出現。但是這里一共出現了三個套接字。因為客戶端和服務端正式握手時,服務端使用的是新建的socket來處理這個客戶端的通信。因為老的socket還需要監聽是否有其他的客戶端。

 

接下來的send、recv和write函數都是處理數據的,這里不過多解釋。

 

客戶端使用close函數進行四次揮手關閉與服務端的連接。服務端使用recv函數接收到了關閉請求執行揮手。

 

程序理解

Linux Socket API很多語言都有對它的實現,差不多的。這里因為我本人更熟悉Java,這里用Java做說明。

這里使用我自己之前寫的《懂了!國際算法體系對稱算法DES原理》中的代碼,去掉加解密的部分:

public void client() throws Exception {
int i = 1;
while (i <= 2) {
Socket socket = new Socket("127.0.0.1", 520);
//向服務器端第一次發送字符串
OutputStream netOut = socket.getOutputStream();
InputStream io = socket.getInputStream();
String msg = i == 1 ? "客戶端:我知道我是任性太任性,傷透了你的心。我是追夢的人,追一生的緣分。" :
"客戶端:我願意嫁給你,你卻不能答應我。";
System.out.println(msg);
netOut.write(msg.getBytes());
netOut.flush();
byte[] bytes = new byte[i == 1 ? 104 : 64];
io.read(bytes);
String response = new String(bytes);
System.out.println(response);
netOut.close();
io.close();
socket.close();
i++;
}
}

如果不開服務端,只執行客戶端代碼,則報異常:

java.net.ConnectException: Connection refused: connect

 

咱們來看這個代碼做了什么:啟動客戶端,與服務端建立連接,理論上要調用linux的socket和connect兩個函數。這個動作在new Socket實例化的時候是做了的:

private Socket(SocketAddress address, SocketAddress localAddr,
boolean stream) throws IOException {
setImpl();
// backward compatibility
if (address == null)
throw new NullPointerException();
try {
createImpl(stream);
if (localAddr != null)
bind(localAddr);

connect(address);

} catch (IOException | IllegalArgumentException | SecurityException e) {

try {
close();
} catch (IOException ce) {
e.addSuppressed(ce);
}
throw e;
}
}

 

然后咱們看服務端代碼:

@Test
public void server() throws Exception {
ServerSocket serverSocket = new ServerSocket(520);
int i = 1;
while (i <= 2) {
String msg = i == 1 ? "服務端:我知道你是任性太任性,傷透了我的心。同是追夢的人,難舍難分。" :
"服務端:你願意嫁給你,我卻不能向你承諾。";
Socket socket = serverSocket.accept();
InputStream io = socket.getInputStream();
byte[] bytes = new byte[i == 1 ? 112 : 64];
io.read(bytes);
System.out.println(new String(bytes));
OutputStream os = socket.getOutputStream();
System.out.println(msg);
byte[] outBytes = msg.getBytes();
os.write(outBytes);
os.flush();
os.close();
io.close();
i++;
}
}

如果客戶端沒有啟動,只啟動服務端。上面提到會進入監聽狀態,這里程序用的是最簡單的阻塞式監聽。

 

如上所示,在執行accept方法時,server開始打圈圈,阻塞了。客戶端啟動后,server進行到了下面讀取數據的階段:

 

執行完后客戶端和服務端都正常返回結果:

客戶端:我知道我是任性太任性,傷透了你的心。我是追夢的人,追一生的緣分。    

服務端:我知道你是任性太任性,傷透了我的心。同是追夢的人,難舍難分。

客戶端:我願意嫁給你,你卻不能答應我。       

服務端:你願意嫁給你,我卻不能向你承諾。

 

/**
* Create a server with the specified port, listen backlog, and
* local IP address to bind to. The <i>bindAddr</i> argument
* can be used on a multi-homed host for a ServerSocket that
* will only accept connect requests to one of its addresses.
* If <i>bindAddr</i> is null, it will default accepting
* connections on any/all local addresses.
* The port must be between 0 and 65535, inclusive.
* A port number of {@code 0} means that the port number is
* automatically allocated, typically from an ephemeral port range.
* This port number can then be retrieved by calling
* {@link #getLocalPort getLocalPort}.
*
* <P>If there is a security manager, this method
* calls its {@code checkListen} method
* with the {@code port} argument
* as its argument to ensure the operation is allowed.
* This could result in a SecurityException.
*
* The {@code backlog} argument is the requested maximum number of
* pending connections on the socket. Its exact semantics are implementation
* specific. In particular, an implementation may impose a maximum length
* or may choose to ignore the parameter altogther. The value provided
* should be greater than {@code 0}. If it is less than or equal to
* {@code 0}, then an implementation specific default will be used.
* <P>
* @param port the port number, or {@code 0} to use a port
* number that is automatically allocated.
* @param backlog requested maximum length of the queue of incoming
* connections.
* @param bindAddr the local InetAddress the server will bind to
*
* @throws SecurityException if a security manager exists and
* its {@code checkListen} method doesn't allow the operation.
*
* @throws IOException if an I/O error occurs when opening the socket.
* @exception IllegalArgumentException if the port parameter is outside
* the specified range of valid port values, which is between
* 0 and 65535, inclusive.
*
* @see SocketOptions
* @see SocketImpl
* @see SecurityManager#checkListen
* @since JDK1.1
*/
public ServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException {
setImpl();
if (port < 0 || port > 0xFFFF)
throw new IllegalArgumentException(
"Port value out of range: " + port);
if (backlog < 1)
backlog = 50;
try {
bind(new InetSocketAddress(bindAddr, port), backlog);
} catch(SecurityException e) {
close();
throw e;
} catch(IOException e) {
close();
throw e;
}
}

這是服務端ServerSocket的實例化過程,注意一下backlog這個參數,就是《懂得三境界-使用dubbo時請求超過問題》里產生問題的罪魁禍首。

 

這里注釋已經說的很明白了,我就直接翻譯成中文:

 

創建一個指定端口的服務端,監聽backlog和綁定的本地IP。bindAddr參數可以用於多個網絡端口的主機。但是一個服務端Socket只能連接到其中一個地址。如果bindAddr參數為空,它會默認連接本機。端口值必須介於0到65535之間。端口號通常是從臨時端口段(1024之后)動態指定的,可以通過getLocalPort方法把值取出來。

 

如果有安全管理(在上面代碼里看不到安全管理是因為這段代碼在bind方法里面),則會對端口進行權限檢查,確保操作是允許的。這一步可能引發安全檢查異常。

 

backlog參數是這個socket等待連接的最大允許請求量。它的精確語義和實現有關。需要重點來說的是,這個實現可以選擇自己指定一個上限同時選擇忽略這個參數,並且這個自己指定的上線還要比這里的backlog參數值大。如果實現里是小於等於這里的backlog參數的,就會直接使用實現的默認值。

 

總結

強烈建議讀完本文再次讀一遍《懂得三境界-使用dubbo時請求超過問題》,深入理解backlog問題。

 

歷史推薦

Redis集群搭建采坑總結

技術方案設計的方法

SpringBoot啟動原理

學習Spring的思考框架


免責聲明!

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



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