網絡通信基礎重難點解析 03:bind 函數難點解析

bind 函數如何選擇綁定地址

上一節的服務器代碼中演示了 bind 函數的使用方法,讓我們再看一下相關的代碼:

struct sockaddr_in bindaddr;
bindaddr.sin_family = AF_INET;
bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
bindaddr.sin_port = htons(3000);
if (bind(listenfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1)
{
std::cout << "bind listen socket error." << std::endl;
return -1;
}

其中 bind 的地址我們使用了一個宏叫 INADDR_ANY ,關於這個宏的解釋如下:

If an application does not care what local address is assigned, specify the constant value INADDR_ANY for an IPv4 local address or the constant value in6addr_any for an IPv6 local address in the sa_data member of the name parameter. This allows the underlying service provider to use any appropriate network address, potentially simplifying application programming in the presence of multihomed hosts (that is, hosts that have more than one network interface and address).

意譯一下:

如果應用程序不關心bind綁定的ip地址,可以使用INADDR_ANY(如果是IPv6,則對應in6addr_any),這樣底層的(協議棧)服務會自動選擇一個合適的ip地址,這樣使在一個有多個網卡機器上選擇ip地址問題變得簡單。

也就是說 INADDR_ANY 相當於地址 0.0.0.0。可能讀者還是不太明白我想表達什麼。這裏我舉個例子,假設我們在一臺機器上開發一個服務器程序,使用 bind 函數時,我們有多個ip 地址可以選擇。首先,這臺機器對外訪問的ip地址是 120.55.94.78,這臺機器在當前局域網的地址是 192.168.1.104;同時這臺機器有本地迴環地址127.0.0.1

如果你指向本機上可以訪問,那麼你 bind 函數中的地址就可以使用127.0.0.1; 如果你的服務只想被局域網內部機器訪問,bind 函數的地址可以使用192.168.1.104;如果希望這個服務可以被公網訪問,你就可以使用地址**0.0.0.0 ** 或 INADDR_ANY

bind 函數端口號問題

網絡通信程序的基本邏輯是客戶端連接服務器,即從客戶端的地址:端口連接到服務器地址:端口上,以 4.2 小節中的示例程序爲例,服務器端的端口號使用 3000,那客戶端連接時的端口號是多少呢?TCP 通信雙方中一般服務器端端口號是固定的,而客戶端端口號是連接發起時由操作系統隨機分配的(不會分配已經被佔用的端口)。端口號是一個 C short 類型的值,其範圍是0~65535,知道這點很重要,所以我們在編寫壓力測試程序時,由於端口數量的限制,在某臺機器上網卡地址不變的情況下壓力測試程序理論上最多隻能發起六萬五千多個連接。注意我說的是理論上,在實際情況下,由於當時的操作系統很多端口可能已經被佔用,實際可以使用的端口比這個更少,例如,一般規定端口號在1024以下的端口是保留端口,不建議用戶程序使用。而對於 Windows 系統,MSDN 甚至明確地說:

On Windows Vista and later, the dynamic client port range is a value between 49152 and 65535. This is a change from Windows Server 2003 and earlier where the dynamic client port range was a value between 1025 and 5000.
Vista 及以後的Windows,可用的動態端口範圍是49152~65535,而 Windows Server及更早的系統,可以的動態端口範圍是1025~5000。(你可以通過修改註冊表來改變這一設置,參考網址:https://docs.microsoft.com/en-us/windows/desktop/api/winsock/nf-winsock-bind)

如果將 bind 函數中的端口號設置成0,那麼操作系統會隨機給程序分配一個可用的偵聽端口,當然服務器程序一般不會這麼做,因爲服務器程序是要對外服務的,必須讓客戶端知道確切的ip地址和端口號。

很多人覺得只有服務器程序可以調用 bind 函數綁定一個端口號,其實不然,在一些特殊的應用中,我們需要客戶端程序以指定的端口號去連接服務器,此時我們就可以在客戶端程序中調用 bind 函數綁定一個具體的端口。

我們用代碼來實際驗證一下上路所說的,爲了能看到連接狀態,我們將客戶端和服務器關閉socket的代碼註釋掉,這樣連接會保持一段時間。

  • 情形一:客戶端代碼不綁定端口

修改後的服務器代碼如下:

/**
 * TCP服務器通信基本流程
 * zhangyl 2018.12.13
 */
#include <sys/types.h> 
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <vector>

int main(int argc, char* argv[])
{
    //1.創建一個偵聽socket
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd == -1)
    {
        std::cout << "create listen socket error." << std::endl;
        return -1;
    }

    //2.初始化服務器地址
    struct sockaddr_in bindaddr;
    bindaddr.sin_family = AF_INET;
    bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    bindaddr.sin_port = htons(3000);
    if (bind(listenfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1)
    {
        std::cout << "bind listen socket error." << std::endl;
        return -1;
    }

	//3.啓動偵聽
    if (listen(listenfd, SOMAXCONN) == -1)
    {
        std::cout << "listen error." << std::endl;
        return -1;
    }
	
	//記錄所有客戶端連接的容器
	std::vector<int> clientfds;
    while (true)
    {
        struct sockaddr_in clientaddr;
        socklen_t clientaddrlen = sizeof(clientaddr);
		//4. 接受客戶端連接
        int clientfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clientaddrlen);
        if (clientfd != -1)
        {         	
			char recvBuf[32] = {0};
			//5. 從客戶端接受數據
			int ret = recv(clientfd, recvBuf, 32, 0);
			if (ret > 0) 
			{
				std::cout << "recv data from client, data: " << recvBuf << std::endl;
				//6. 將收到的數據原封不動地發給客戶端
				ret = send(clientfd, recvBuf, strlen(recvBuf), 0);
				if (ret != strlen(recvBuf))
					std::cout << "send data error." << std::endl;
				
				std::cout << "send data to client successfully, data: " << recvBuf << std::endl;
			} 
			else 
			{
				std::cout << "recv data error." << std::endl;
			}
			
			//close(clientfd);
			clientfds.push_back(clientfd);
        }
    }
	
	//7.關閉偵聽socket
	close(listenfd);

    return 0;
}

修改後的客戶端代碼如下:

/**
 * TCP客戶端通信基本流程
 * zhangyl 2018.12.13
 */
#include <sys/types.h> 
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>

#define SERVER_ADDRESS "127.0.0.1"
#define SERVER_PORT     3000
#define SEND_DATA       "helloworld"

int main(int argc, char* argv[])
{
    //1.創建一個socket
    int clientfd = socket(AF_INET, SOCK_STREAM, 0);
    if (clientfd == -1)
    {
        std::cout << "create client socket error." << std::endl;
        return -1;
    }

    //2.連接服務器
    struct sockaddr_in serveraddr;
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
    serveraddr.sin_port = htons(SERVER_PORT);
    if (connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) == -1)
    {
        std::cout << "connect socket error." << std::endl;
        return -1;
    }

	//3. 向服務器發送數據
	int ret = send(clientfd, SEND_DATA, strlen(SEND_DATA), 0);
	if (ret != strlen(SEND_DATA))
	{
		std::cout << "send data error." << std::endl;
		return -1;
	}
	
	std::cout << "send data successfully, data: " << SEND_DATA << std::endl;
	
	//4. 從客戶端收取數據
	char recvBuf[32] = {0};
	ret = recv(clientfd, recvBuf, 32, 0);
	if (ret > 0) 
	{
		std::cout << "recv data successfully, data: " << recvBuf << std::endl;
	} 
	else 
	{
		std::cout << "recv data error, data: " << recvBuf << std::endl;
	}
	
	//5. 關閉socket
	//close(clientfd);
	//這裏僅僅是爲了讓客戶端程序不退出
	while (true) 
	{
		sleep(3);
	}

    return 0;
}

將程序編譯好後(編譯方法和上文一樣),我們先啓動server,再啓動三個客戶端。然後通過 lsof 命令查看當前機器上的 TCP 連接信息,爲了更清楚地顯示結果,已經將不相關的連接信息去掉了,結果如下所示:

[root@localhost ~]# lsof -i -Pn
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
server   1445 root    3u  IPv4  21568      0t0  TCP *:3000 (LISTEN)
server   1445 root    4u  IPv4  21569      0t0  TCP 127.0.0.1:3000->127.0.0.1:40818 (ESTABLISHED)
server   1445 root    5u  IPv4  21570      0t0  TCP 127.0.0.1:3000->127.0.0.1:40820 (ESTABLISHED)
server   1445 root    6u  IPv4  21038      0t0  TCP 127.0.0.1:3000->127.0.0.1:40822 (ESTABLISHED)
client   1447 root    3u  IPv4  21037      0t0  TCP 127.0.0.1:40818->127.0.0.1:3000 (ESTABLISHED)
client   1448 root    3u  IPv4  21571      0t0  TCP 127.0.0.1:40820->127.0.0.1:3000 (ESTABLISHED)
client   1449 root    3u  IPv4  21572      0t0  TCP 127.0.0.1:40822->127.0.0.1:3000 (ESTABLISHED)

上面的結果顯示,server 進程(進程 ID 是 1445)在 3000 端口開啓偵聽,有三個 client 進程(進程 ID 分別是144714481449)分別通過端口號 408184082040822 連到 server 進程上的,作爲客戶端的一方,端口號是系統隨機分配的。

  • 情形二:客戶端綁定端口號 0

    服務器端代碼保持不變,我們修改下客戶端代碼:

    /**
     * TCP客戶端通信基本流程
     * zhangyl 2018.12.13
     */
    #include <sys/types.h> 
    #include <sys/socket.h>
    #include <arpa/inet.h>
    #include <unistd.h>
    #include <iostream>
    #include <string.h>
    
    #define SERVER_ADDRESS "127.0.0.1"
    #define SERVER_PORT     3000
    #define SEND_DATA       "helloworld"
    
    int main(int argc, char* argv[])
    {
        //1.創建一個socket
        int clientfd = socket(AF_INET, SOCK_STREAM, 0);
        if (clientfd == -1)
        {
            std::cout << "create client socket error." << std::endl;
            return -1;
        }
    	
        struct sockaddr_in bindaddr;
        bindaddr.sin_family = AF_INET;
        bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    	//將socket綁定到0號端口上去
        bindaddr.sin_port = htons(0);
        if (bind(clientfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1)
        {
            std::cout << "bind socket error." << std::endl;
            return -1;
        }
    
        //2.連接服務器
        struct sockaddr_in serveraddr;
        serveraddr.sin_family = AF_INET;
        serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);
        serveraddr.sin_port = htons(SERVER_PORT);
        if (connect(clientfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) == -1)
        {
            std::cout << "connect socket error." << std::endl;
            return -1;
        }
    
    	//3. 向服務器發送數據
    	int ret = send(clientfd, SEND_DATA, strlen(SEND_DATA), 0);
    	if (ret != strlen(SEND_DATA))
    	{
    		std::cout << "send data error." << std::endl;
    		return -1;
    	}
    	
    	std::cout << "send data successfully, data: " << SEND_DATA << std::endl;
    	
    	//4. 從客戶端收取數據
    	char recvBuf[32] = {0};
    	ret = recv(clientfd, recvBuf, 32, 0);
    	if (ret > 0) 
    	{
    		std::cout << "recv data successfully, data: " << recvBuf << std::endl;
    	} 
    	else 
    	{
    		std::cout << "recv data error, data: " << recvBuf << std::endl;
    	}
    	
    	//5. 關閉socket
    	//close(clientfd);
    	//這裏僅僅是爲了讓客戶端程序不退出
    	while (true) 
    	{
    		sleep(3);
    	}
    
        return 0;
    }
    

    我們再次編譯客戶端程序,並啓動三個 client 進程,然後用 lsof 命令查看機器上的 TCP 連接情況,結果如下所示:

    [root@localhost ~]# lsof -i -Pn
    COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
    server   1593 root    3u  IPv4  21807      0t0  TCP *:3000 (LISTEN)
    server   1593 root    4u  IPv4  21808      0t0  TCP 127.0.0.1:3000->127.0.0.1:44220 (ESTABLISHED)
    server   1593 root    5u  IPv4  19311      0t0  TCP 127.0.0.1:3000->127.0.0.1:38990 (ESTABLISHED)
    server   1593 root    6u  IPv4  21234      0t0  TCP 127.0.0.1:3000->127.0.0.1:42365 (ESTABLISHED)
    client   1595 root    3u  IPv4  22626      0t0  TCP 127.0.0.1:44220->127.0.0.1:3000 (ESTABLISHED)
    client   1611 root    3u  IPv4  21835      0t0  TCP 127.0.0.1:38990->127.0.0.1:3000 (ESTABLISHED)
    client   1627 root    3u  IPv4  21239      0t0  TCP 127.0.0.1:42365->127.0.0.1:3000 (ESTABLISHED)
    

    通過上面的結果,我們發現三個 client 進程使用的端口號仍然是系統隨機分配的,也就是說綁定 0 號端口和沒有綁定效果是一樣的。

  • 情形三:客戶端綁定一個固定端口

    我們這裏使用 20000 端口,當然讀者可以根據自己的喜好選擇,只要保證所選擇的端口號當前沒有被其他程序佔用即可,服務器代碼保持不變,客戶端綁定代碼中的端口號從 0 改成 20000。這裏爲了節省篇幅,只貼出修改處的代碼:

    struct sockaddr_in bindaddr;
    bindaddr.sin_family = AF_INET;
    bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    //將socket綁定到20000號端口上去
    bindaddr.sin_port = htons(20000);
    if (bind(clientfd, (struct sockaddr *)&bindaddr, sizeof(bindaddr)) == -1)
    {
        std::cout << "bind socket error." << std::endl;
        return -1;
    }
    

    再次重新編譯程序,先啓動一個客戶端後,我們看到此時的 TCP 連接狀態:

    [root@localhost testsocket]# lsof -i -Pn
    COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
    server   1676 root    3u  IPv4  21933      0t0  TCP *:3000 (LISTEN)
    server   1676 root    4u  IPv4  21934      0t0  TCP 127.0.0.1:3000->127.0.0.1:20000 (ESTABLISHED)
    client   1678 root    3u  IPv4  21336      0t0  TCP 127.0.0.1:20000->127.0.0.1:3000 (ESTABLISHED)
    

    通過上面的結果,我們發現 client 進程確實使用 20000 號端口連接到 server 進程上去了。這個時候如果我們再開啓一個 client 進程,我們猜想由於端口號 20000 已經被佔用,新啓動的 client 會由於調用 bind 函數出錯而退出,我們實際驗證一下:

    [root@localhost testsocket]# ./client 
    bind socket error.
    [root@localhost testsocket]# 
    

    結果確實和我們預想的一樣。

在技術面試的時候,有時候面試官會問 TCP 網絡通信的客戶端程序中的 socket 是否可以調用 bind 函數,相信讀到這裏,聰明的讀者已經有答案了。

另外,Linux 的 nc 命令有個 -p 選項(字母 p 是小寫),這個選項的作用就是 nc 在模擬客戶端程序時,可以使用指定端口號連接到服務器程序上去,實現原理相信讀者也明白了。我們還是以上面的服務器程序爲例,這個我們不用我們的 client 程序,改用 nc 命令來模擬客戶端。在 shell 終端輸入:

[root@localhost testsocket]# nc -v -p 9999 127.0.0.1 3000
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Connected to 127.0.0.1:3000.
My name is zhangxf
My name is zhangxf

-v 選項表示輸出 nc 命令連接的詳細信息,這裏連接成功以後,會輸出“Ncat: Connected to 127.0.0.1:3000.” 提示已經連接到服務器的 3000 端口上去了。

-p 選項的參數值是 9999 表示,我們要求 nc 命令本地以端口號 9999 連接服務器,注意不要與端口號 3000 混淆,3000 是服務器的偵聽端口號,也就是我們的連接的目標端口號,9999 是我們客戶端使用的端口號。我們用 lsof 命令來驗證一下我們的 nc 命令是否確實以 9999 端口號連接到 server 進程上去了。

[root@localhost testsocket]# lsof -i -Pn
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
server   1676 root    3u  IPv4  21933      0t0  TCP *:3000 (LISTEN)
server   1676 root    7u  IPv4  22405      0t0  TCP 127.0.0.1:3000->127.0.0.1:9999 (ESTABLISHED)
nc       2005 root    3u  IPv4  22408      0t0  TCP 127.0.0.1:9999->127.0.0.1:3000 (ESTABLISHED)

結果確實如我們期望的一致。

當然,我們用 nc 命令連接上 server 進程以後,我們還給服務器發了一條消息"My name is zhangxf",server 程序收到消息後把這條消息原封不動地返還給我們,以下是 server 端運行結果:

[root@localhost testsocket]# ./server   
recv data from client, data: My name is zhangxf

send data to client successfully, data: My name is zhangxf

關於 lsofnc 命令我們將會在《網絡通信故障排查常用命令》專題中詳細介紹。


本文首發於『easyserverdev』公衆號,歡迎關注,轉載請保留版權信息。

歡迎加入高性能服務器開發 QQ 羣一起交流: 578019391
微信掃碼關注

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章