Linux網路通訊(TCP通訊端編寫,多程序多執行緒版本)

2022-11-10 21:00:23

預備知識

源IP地址和目的IP地址

IP地址在上一篇部落格中也介紹過,它是用來標識網路中不同主機的地址。兩臺主機進行通訊時,傳送方需要知道自己往哪一臺主機傳送,這就需要知道接受方主機的的IP地址,也就是目的IP地址,因為兩臺主機是要進行通訊的,所以接收方需要給傳送方進行一個響應,這時接收方主機就需要知道傳送方主機的IP地址,也就是源IP地址。有了這兩個地址,兩臺主機才能夠找到對端主機。

  • 源IP地址: 傳送方主機的IP地址,保證響應主機「往哪放」
  • 目的IP地址: 接收方主機的IP地址,保證傳送方主機「往哪發」

埠號

埠號是屬於傳輸層協定的一個概念,它是一個16位元的整數,用來標識主機上的某一個程序

注意:一個埠號只能被一個程序佔用

在上面說過,公網IP地址是用來標識全網內唯一的一臺主機,埠號又是用來標識一臺主機上的唯一一個程序,所以IP地址+埠號 就可以標識全網內唯一一個程序

埠號和程序ID:
二者都是用來唯一標識某一個程序。它們的區別和聯絡是:

一臺主機上可以存在大量的程序,但不是所有的程序都需要對外進行網路請求。任何的網路服務和使用者端程序通訊,如果要進行正常的資料通訊,必須要用埠號來唯一標識自身的程序,只有需要進行網路請求的程序才需要用埠號來表示自身的唯一性,所以說埠號更多的是網路級的概念。程序pid可以用來標識所有程序的唯一性,是作業系統層面的概念。二者是不同層面表示程序唯一性的機制。

源埠號和目的埠號:
兩臺主機進行通訊,只有對端主機的IP地址只能夠幫我們找到對端的主機,但是我們還需要找到對端提供服務的程序,這個程序可以通過對端程序繫結的埠號找到,也就是目的埠號,同樣地,對端主機也需要給傳送方一個響應,通過源IP地址找到傳送方的那一臺主機,找到主機還是不夠的,還需要找到對端主機是哪一個程序發起了請求,響應方需要通過發起請求的程序繫結的埠號找到該程序,也就是源埠號,然後就可以進行響應。

  • 源埠號: 傳送方主機的服務程序繫結的埠號,保證接收方能夠找到對應的服務
  • 目的埠號: 接收方主機的服務程序繫結的埠號,保證傳送方能夠找到對應的服務

socket通訊的本質: 跨網路的程序間通訊。從上面可以看出,網路通訊就是兩臺主機上的程序在進行通訊。

注意:一個區域網才擁有一個獨立的IP,IP地址只能定位到一個區域網,無法定位到具體哪臺裝置,要想定位到哪臺裝置,就必須知道這個裝置的MAC地址,IP地址解決的是資料在外網(因特網,網際網路)的傳輸問題,而MAC解決的是資料在內網(區域網)的傳輸問題,但是MAC地址不需要我們組包,鏈路層底層協定棧就會幫你組好。

Socket通訊端

Socket 是在應用層和傳輸層之間的一個抽象層,它把 TCP/IP 層複雜的操作抽象為幾個簡單的介面,供應用層呼叫實現程序在網路中的通訊。Socket 起源於 UNIX,在 UNIX 一切皆檔案的思想下,程序間通訊就被冠名為檔案描述符(file descriptor),Socket 是一種「開啟—讀/寫—關閉」模式的實現,伺服器和使用者端各自維護一個「檔案」,在建立連線開啟後,可以向檔案寫入內容供對方讀取或者讀取對方內容,通訊結束時關閉檔案。

在網路通訊中,通訊端一定是成對出現的。一端的傳送緩衝區對應對端的接收緩衝區。

重點:通訊端本質上也是一個檔案描述符,指向的是一個「網路檔案」。普通檔案的檔案緩衝區對應的是磁碟,資料先寫入檔案緩衝區,再重新整理到磁碟,「網路檔案」的檔案緩衝區對應的是網路卡,它會把檔案緩衝區的資料重新整理到網路卡,然後傳送到網路中。
建立一個通訊端做的工作就是開啟一個檔案,接下來就是要將該檔案和網路關聯起來,這就是繫結的操作,完成了繫結,檔案緩衝區的資料才知道往哪重新整理。

網路位元組序

我們已經知道,記憶體中的多位元組資料相對於記憶體地址有著大端和小端的區分。同樣,網路資料流同樣有大端和小端的區分。

思考一下,如何定義網路資料流的地址呢?

傳送主機通常將傳送緩衝區中的資料按記憶體地址從低到高的順序發出,接收主機把從網路上接到的位元組一次儲存在接收緩衝區中,也就是按照記憶體地址從低到高的順序儲存。

網路資料流的地址應該這樣規定:先發出的資料是低地址,後發出的資料是高地址。

  • 大端位元組序: 高位存放在低地址,低位存放在高地址
  • 小端位元組序: 低位存放在低地址,高位存放在高地址

如果雙方主機的資料在記憶體儲存的位元組序不同,就會造成接收方收到的資料出現偏差,所以為了解決這個問題,又有了下面的規定:

  • TCP/IP協定規定,網路資料流採用大端位元組序,不管這臺主機是大端機還是小端機, 都會按照這個TCP/IP規定的網路位元組序來傳送/接收資料
  • 所以如果傳送的主機是小端機,就需要把要傳送的資料先轉為大端,再進行傳送,如果是大端,就可以直接進行傳送。

為了方便我們進行網路程式的程式碼編寫,有下面幾個API提供給我們用來做網路位元組序和主機位元組序的轉換,如下:

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

說明:

  • h代表的是host,n代表的是network,s代表的是16位元的短整型,l代表的是32位元長整形
  • 如果主機是小端位元組序,函數會對引數進行處理,進行大小端轉換
  • 如果主機是大端位元組序,函數不會對這些引數處理,直接返回

注意:在程式設計中我們需要自行進行大小端轉化的就只有三個:ip地址,傳輸資料和埠,這兩個資料需要我們進行大端的轉化,其他的在計算機組包的時候會自動給我們轉化。

Socket常見的API

常用的有以下幾個,後面會具體的介紹

// 建立 socket 檔案描述符 (TCP/UDP, 使用者端 + 伺服器)
int socket(int domain, int type, int protocol);
// 繫結埠號 (TCP/UDP, 伺服器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);
// 開始監聽socket (TCP, 伺服器)
int listen(int socket, int backlog);
// 接收請求 (TCP, 伺服器)
int accept(int socket, struct sockaddr* address,
socklen_t* address_len);
// 建立連線 (TCP, 使用者端)
int connect(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);

Sockaddr結構體

  • sockaddr_in用來進行網路通訊,sockaddr_un結構體用來進行本地通訊
  • sockaddr_in結構體儲存了協定家族,埠號,IP等資訊,網路通訊時可以通過這個結構體把自己的資訊傳送給對方,也可以通過這個結構體獲取遠端的這些資訊
  • 可以看出,這三個結構體的前16位元時一樣的,代表的是協定家族,可以根據這個引數判斷需要進行哪種通訊(本地和跨網路)
  • IPv4和IPv6的地址格式定義在netinet/in.h中,IPv4地址用sockaddr_in結構體表示,包括16位元地址型別, 16位元埠號和32位元IP地址;而IPv6地址用sockaddr_in6結構體來表示
  • IPv4、 IPv6地址型別分別定義為常數AF_INET、 AF_INET6。這樣,只要取得某種sockaddr結構體的首地址,不需要知道具體是哪種型別的sockaddr結構體,就可以根據地址型別欄位確定結構體中的內容
  • socket API可以都用struct sockaddr *型別表示,在使用的時候需要強制轉化成sockaddr;這樣的好處是程式的通用性,可以接收IPv4,IPv6,以及UNIX Domain Socket各種型別的sockaddr結構體指標為引數

注意:IPv4和IPv6分別有自己對應的結構體,但是為了統一,我們不知道使用者要傳的是ipv4還是ipv6,所以就類似於我們不知道使用者要輸入char還是int型別,此時我們就會寫成void *型別;同理,為了統一,這裡有個通用的通訊端結構體struct sockaddr,將結構體IPv4和IPv6轉化成sockaddr型別就可以了,struct sockaddr會根據ipv4和ipv6結構體的前幾位判斷需要傳輸的協定型別是IPv4還是IPv6。

sockaddr_in的結構: 因為我們主要用到網路通訊,所以這裡主要介紹這個結構體,開啟/usr/include/linux/in.h

sin_family代表的是地址型別,我們主要用的是AF_INETsin_port代表的是埠號,sin_addr代表的是網路地址,也就是IP地址,用了一個結構體struct in_addr進行描述

struct in_addr
{
	_be32 a_addr;
}

這裡填充的就是IPv4的地址,一個32位元的整數

地址轉換函數

IP地址可以用點分十進位制的字串(例如127.0.0.1),這裡涉及到字串和32位元整網路的大端資料之間的相互轉換。下面價紹二者之間轉化的庫函數:

int inet_pton(int af, const char *src, void *dst);
功能:
	將點分十進位制字串轉換成32位元網路大端的資料
引數:
	af:
		AF_INET IPV4
		AF_INET6 TPV6
	src:點分十進位制串的首地址
	dst:32位元網路資料的地址
返回值:成功返回1,失敗返回-1

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
功能:
	將32位元大端的網路資料轉化成點分十進位制字串
引數:
	af:
		AF_INET IPV4
		AF_INET6 TPV6
	src:32位元大端的網路資料地址
	dst:儲存點分十進位制串地址
	size:儲存點分進位制串陣列的大小
返回值:成功則返回指向陣列的指標,出錯返回NULL
注意:net_ntop函數的dst引數不可以是一個空指標。呼叫者必須為目標儲存單元分配記憶體並指定其大小,呼叫成功時,這個指標就是該函數的返回值

char *inet_ntoa(struct in_addr in);
引數:
	in_addr:描述ip地址的結構體

注意: inet_ntoa這個函數內部會申請一塊空間,儲存轉換後的IP的結果,這塊空間被放在靜態儲存區,不需要我們手動釋放。且第二次呼叫該函數,會把結果放到上一次的靜態儲存區中,所以會覆蓋上一次呼叫該函數的結果,是執行緒不安全的。inet_ntop這個函數是由呼叫者自己提供一個緩衝區儲存結果,是執行緒安全的。

TCP通訊的基本流程

伺服器端:

1. 呼叫 socket 函數建立 socket(偵聽socket)
2. 呼叫 bind 函數 將 socket繫結到某個ip和埠的二元組上
3. 呼叫 listen 函數 開啟偵聽
4. 當有使用者端請求連線上來後,呼叫 accept 函數接受連線,產生一個新的 socket(使用者端 socket)
5. 基於新產生的 socket 呼叫 send 或 recv 函數開始與使用者端進行資料交流
6. 通訊結束後,呼叫 close 函數關閉偵聽 socket

看上圖:給大家講解一下伺服器端的流程

1.首先伺服器端會呼叫socket函數建立一個通訊端,上面說過了通訊端是一個特殊的」網路檔案「,存在讀寫緩衝區

2.呼叫bind函數將這個通訊端繫結ip和埠號,注意此時的ip和埠號都是伺服器自己的埠號和ip,因為伺服器是被動的連線,生成的是監聽通訊端,監聽的是使用者端發來的要連線的伺服器的ip和埠號,監聽通訊端會檢視自己繫結的ip和埠號和使用者端發來的要連線的伺服器的ip和埠號是否和自己一樣,才能決定是否接受連線

3.呼叫listen函數,使得通訊端變成一個被動的監聽通訊端,使已係結的通訊端等待監聽使用者端的連線請求,並設定伺服器同時可以連線的數量(已連線佇列和未連線佇列),當監聽到使用者端發來的ip和埠號與未連線佇列中的通訊端吻合時,就把使用者端發來的通訊端資訊放到已連線佇列當中

4.呼叫accept函數,如果listen已連線佇列中沒有請求的話,該函數會阻塞,直到連線佇列發來資訊,該函數的第一個引數用來標識伺服器端通訊端,第二個引數用來儲存使用者端通訊端,實際上accept函數指定了伺服器接收使用者端的連線,並將使用者端的通訊端資訊(ip和埠)儲存了下來,因為當伺服器給使用者端傳送資料的時候需要知道使用者端的ip和埠

  • 值得注意的是,accept會生成一個新的通訊端連結,這個通訊端已經連線了伺服器和使用者端,原來的監聽通訊端和使用者端的連線就會斷開,以後的通訊就是新的連線通訊端和使用者端進行通訊
  • 為什麼要建立一個新的通訊端呢?因為監聽通訊端有自己的工作,還需要監聽其他來訪的使用者端的連線請求,如果用監聽通訊端和使用者端進行通訊,那麼其他使用者端想要連線該伺服器的埠就不會成功,影響很大

5.基於新產生的 socket 呼叫 send 或 recv 函數開始與使用者端進行資料交流

6.通訊結束後,呼叫 close 函數關閉偵聽 socket

使用者端:

1. 呼叫 socket函數建立使用者端 socket
2. 呼叫 connect 函數嘗試連線伺服器
3. 連線成功以後呼叫 send 或 recv 函數開始與伺服器進行資料交流
4. 通訊結束後,呼叫 close 函數關閉偵聽socket

TCP相關的通訊端API

TCP是面向連線的,不同於UDP,TCP需要建立好通訊端並且繫結埠號,繫結好之後,還需要進行監聽,等待並獲取連線。

  • listen
int listen(int sockfd, int backlog); 
功能:
	將通訊端設定為監聽狀態,監聽socket的到來
引數:
	sockfd:要設定的通訊端(稱為監聽通訊端)
	backlog:連線佇列的長度
返回值:成功返回0,失敗返回-1
  • accept
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
功能:
	接受請求,獲取建立好的連線
引數:
	sockfd:監聽通訊端
	addr:獲取使用者端的ip和埠資訊(ipv4通訊端結構體地址)
	addrlen:ipv4通訊端結構體的大小的地址
socklen_t addrlen = sizeof(struct sockaddr);
返回值:成功返回一個連線通訊端,用來標識遠端建立好連線的通訊端,失敗返回-1
  • connect
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
功能:
	發起請求,請求與伺服器建立連線(一般用於使用者端向伺服器端傳送請求)
引數:
	sockfd:通訊端,發起連線請求的通訊端
    addr:ipv4通訊端結構體的地址,描述自身的相關資訊,用來標識自身,需要自己填充,讓對端知道是請求方的資訊,以便進行響應
    addrlen:描述addr的大小(ipv4通訊端結構體的長度)
返回值: 成功返回0,失敗返回-1

思考一下:不知道大家是否對accept會有疑惑,已經通過socket建立好了一個通訊端,accept又返回了一個通訊端,這兩個通訊端有什麼區別嗎?UDP只有一個通訊端就可以進行通訊了,而TCP還需要這麼多個,這是為什麼?

答案是肯定有的,socket建立的通訊端是用來伺服器端本身進行繫結的。因為UDP是面向資料包,無連線的,所以建立好一個通訊端之後直接等待資料到來即可,而TCP是面向連線,需要等待連線的到來,並獲取連線,普通的一個通訊端是不能夠進行連線的監聽,這時就需要用的listen來對建立好的通訊端進行設定,將其設定為監聽狀態,這樣這個通訊端就可以不斷監聽連線狀態,如果連線到來了,就需要通過accept獲取連線,獲取連線後返回一個值,也是通訊端,這個通訊端是用來描述每一個建立好的連線,方便維護連線和給對端進行響應,後期都是通過該通訊端對使用者端進行通訊,也就是對使用者端進行服務。
所以說,開始建立的通訊端是與自身強相關的,用來描述自身,並且需要進行監聽,所以我們也會稱這個通訊端叫做監聽通訊端,獲取到的每一個連線都用一個通訊端對其進行唯一性標識,方便維護與服務。
一個通俗的類比,監聽通訊端好比是一家飯館拉客的,不斷地去店外拉客進店,拉客進店後顧客需要享受服務,這時就是服務員對其進行各種服務,服務員就好比是accept返回的通訊端,此時拉客的不需要關心服務員是如何服務顧客的,只需要繼續去店外拉客進入店內就餐即可。

基於TCP協定的通訊端協定

伺服器

整體框架

封裝一個類,來描述tcp伺服器端,成員變數包含埠號和監聽通訊端兩個即可,ip像udp伺服器端一樣,繫結INADDR_ANY,建構函式根據傳參初始化port,解構的時候關閉監聽通訊端即可

#define DEFAULT_PORT 8080 // 預設埠號為8080
#define BACK_LOG 5 // listen的第二個引數

class TcpServer
{
public:
  TcpServer(int port = DEFAULT_PORT)
    :_port(port)
     ,_listen_sock(-1)
  {}
  ~TcpServer()
  {
    if (_listen_sock >= 0) close(_listen_sock);
  }
private:
  int _port;
  int _listen_sock;
};

伺服器端的初始化

建立通訊端

建立通訊端用到的是socket這個介面,具體介紹如下:

int socket(int domain, int type, int protocol); 
功能:
	建立通訊端
引數:
	domain:協定家族,我們用的都是IPV4,這裡會填AF_INET
    type:協定型別。可以選擇SOCK_DGRAM(資料包,UDP)和         SOCK_STREAM(流式服務,TCP)
    protocol:協定類別,這裡填寫0,根據前面的引數自動推導需要那種型別
返回值: 成功返回一個檔案描述符,失敗返回-1

程式碼如下:

bool TcpServerInit()
{
	// 建立通訊端
	_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
	if (_listen_sock < 0){
	  cerr << "socket creat fail" << endl;
	  return false;
	}
	cout << "socket creat succes, sock: " << _listen_sock << endl;
}

繫結埠號

繫結埠號需要用到bind這個介面:

int bind(int sockfd, struct sockaddr *my_addr, socklen_taddrlen); 
引數:
	sockfd:通訊端
	my_addr:這裡傳一個sockaddr_in的結構體,裡面記錄這原生的資訊:sin_family(協定家族)、sin_port(埠號)和sin_addr(地址),用來進行繫結
	addrlen:第二個引數的結構體的大小
返回值: 成功返回0,失敗返回-1

這裡埠號我們填充一個8080,協定家族填充的還是AF_INET,這裡IP繫結一個欄位叫INADDR_ANY(通配地址),值為0,表示取消對單個IP的繫結,伺服器端有多個IP,如果指明繫結那個IP,那麼伺服器端只能夠從這個IP獲取資料,如果繫結INADDR_ANY,那麼伺服器端可以接受來自本主機任意IP對該埠號傳送過來的資料
填充好了這個結構體,我們需要它進行強轉為struct sockaddr

注意: 因為資料是要傳送到網路中,所以要將主機序列的埠號轉為網路序列的埠號

繫結埠號,需要填充struct sockaddr_in這個結構體,裡面有協定家族,埠號和IP,埠號根據使用者傳參進行填寫,IP直接繫結INADDR_ANY,具體程式碼如下:

bool TcpServerInit()
{
  // 繫結
  struct sockaddr_in local;
  memset(&local, 0, sizeof(local));

  local.sin_family = AF_INET;
  local.sin_port = htons(_port);
  local.sin_addr.s_addr = INADDR_ANY;
  
  if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
    cout << "bind fail" << endl;
    return false;
  }
  cout << "bind success" << endl;
}

將通訊端設定為監聽狀態

這裡就需要用的listen這個介面,讓通訊端處於監聽狀態,然後可以去監聽連線的到來程式碼也很簡單,具體如下:

bool TcpServerInit()
{
  // 將通訊端設定為監聽狀態
  if (listen(_listen_sock, BACK_LOG) < 0){
    cout << "listen fail" << endl;
    return false;
  }
  cout << "listen success" << endl;
}

迴圈獲取連線

聽通訊端通過accept獲取連線,一次獲取連線失敗不要直接將伺服器端關閉,而是重新去獲取連線就好,因為獲取一個連線失敗而直接關閉伺服器端,帶來的損失是很大的,所以只需要重新獲取連線即可,返回的用於通訊通訊端記錄下來,進行通訊,然後可以用多種方式為各種連線連線提供服務,具體服務方式後面細說,先看獲取連線的一部分程式碼:

void loop()
{
  struct sockaddr_in peer;// 獲取遠端埠號和ip資訊
  socklen_t len = sizeof(peer);
  while (1){ 
    // 獲取連結 
    // sock 是進行通訊的一個通訊端  _listen_sock 是進行監聽獲取連結的一個通訊端 
    int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len); 
    if (sock < 0){ 
      cout << "accept fail, continue accept" << endl; 
      continue; 
    }
    // 提供服務 service 後面介紹 
  }
}

使用者端

整體框架

和伺服器端一樣,封裝一個類描述,類成員有伺服器端ip、伺服器端繫結的埠號以及自身通訊端,程式碼如下:

class TcpClient
{
public:
  TcpClient(string ip, int port)
    :_server_ip(ip)
     ,_server_port(port)
     ,_sock(-1)
  {}
  ~TcpClient()
  {
    if (_sock >= 0) close(_sock);
  }
private:
  string _server_ip;
  int _server_port;
  int _sock;
};

使用者端初始化

使用者端的初始化只需要建立通訊端即可,不需要繫結埠號,發起連線請求的時候,會自動給使用者端分配一個埠號。建立通訊端和伺服器端是一樣的,程式碼如下:

bool TcpClientInit()
{
    // 建立通訊端
    _sock = socket(AF_INET, SOCK_STREAM, 0);
    if (_sock < 0){
      cout << "socket creat fail" << endl;
      return false;
    }
    cout << "socket creat succes, sock: " << _sock << endl;

    return true;
}

使用者端啟動

發起連線請求

使用connect函數,想伺服器端發起連線請求,注意,呼叫這個函數之前,需要先填充好伺服器端的資訊,有協定家族、埠號和IP,請求連線失敗直接退出程序,重新啟動程序即可,連線成功之後就可以像伺服器端發起各自的服務請求(後面介紹),程式碼如下:

void TcpClientStart()
{
  // 連線伺服器
  struct sockaddr_in peer;

  peer.sin_family = AF_INET;
  peer.sin_port = htons(_server_port);
  peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());
  
  if (connect(_sock, (struct sockaddr*)&peer, sizeof(peer)) < 0){
    // 連線失敗
    cerr << "connect fail" <<endl;
    exit(-1);
  }
  cout << "connect success" << endl;
  Request();// 下面介紹
}

發起服務請求

請求很簡單,只需要讓使用者輸入字串請求,然後將請求通過write(send也可以)傳送過去,然後建立一個緩衝區,通過read(recv也可以)讀取伺服器端的響應,這裡需要著重介紹一下read的返回值

  1. 大於0:實際讀取的位元組數
  2. 等於0:讀到了檔案末尾,說明對端關閉,用在伺服器端就是使用者端關閉,用在使用者端就是伺服器端關閉了,使用者端可以直接退出
  3. 小於0:說明讀取失敗
void Request()
{
  string msg;
  while (1){
    cout << "Please Enter# ";
    getline(cin, msg);
    write(_sock, msg.c_str(), msg.size());
    char buf[256];
    ssize_t size = read(_sock, buf, sizeof(buf)-1);
    if (size <= 0){
      cerr << "read error" << endl;
      exit(-1);
    }
    buf[size] = 0;
    cout << buf << endl;
  }
}

不同版本的伺服器端服務程式碼

多程序版本

思路: 為了給不同的連線提供服務,所以我們需要讓父程序去不斷獲取連線,獲取連線後,讓父程序建立一個子程序去為這個獲取到的連線提供服務,那麼問題來了,子程序去服務連線,父程序是否需要等待子程序?按常理來說,是需要的,如果不等待的話,子程序退出,子程序的資源就沒有人回收,就變成殭屍程序了,如果父程序等待子程序的話,父程序就需要阻塞在哪,無法去獲取到新的連線,這也是不完全可行的,所以就有了一下兩種解決方案:

  • 1.通過註冊SIGCHLD(子程序退出會想父程序發起該訊號)訊號,把它的處理訊號的方式改成SIG_IGN(忽略),此時子程序退出就會自動清理資源不會產生殭屍程序,也不會通知父程序,這種方法比較推薦,也比較簡單粗暴
  • 2.通過建立子程序,子程序建立孫子程序,子程序直接退出,讓1號程序領養孫子程序,這樣父程序只需要等很短的時間就可以回收子程序的資源,這樣父程序可以繼續去獲取連線,孫子程序給連線提供服務即可

方法一程式碼編寫:

void loop()
{
  // 對SIGCHLD訊號進行註冊,處理方式為忽略
  signal(SIGCHLD, SIG_IGN);
  struct sockaddr_in peer;// 獲取遠端埠號和ip資訊
  socklen_t len = sizeof(peer);
  while (1){ 
    // 獲取連結 
    // sock 是進行通訊的一個通訊端  _listen_sock 是進行監聽獲取連結的一個通訊端 
    int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len); 
    if (sock < 0){ 
      cout << "accept fail, continue accept" << endl; 
      continue; 
    } 
 
    // 建立子程序
    pid_t id = fork();
    if (id == 0){
      //子程序,通訊的工作交給子程序,父程序只負責監聽
      close(_listen_sock);//可以不關閉,但是建議關閉,防止後期子程序對監聽通訊端進行一些操作,影響父程序
      //在前面的部落格中講過,父子程序共用檔案表,對檔案進行讀寫操作會影響彼此,但是由於子程序有自己的PCB,有自己的檔案表項,關閉自己程序的檔案描述符不會造成影響  
      int peerPort = ntohs(peer.sin_port);
      string peerIp = inet_ntoa(peer.sin_addr);
      cout << "get a new link, [" << peerIp << "]:[" << peerPort  << "]"<< endl;
      Server(peerIp, peerPort, sock);
    }
    // 父程序繼續去獲取連線
  }
}
void Server(string ip, int port, int sock)
{
   while (1){
     char buf[256];
     ssize_t size = read(sock, buf, sizeof(buf)-1);
     if (size > 0){
       // 正常讀取size位元組的資料
       buf[size] = 0;
       cout << "[" << ip << "]:[" << port  << "]# "<< buf <<endl;
       string msg = "server get!-> ";
       msg += buf;
       write(sock, msg.c_str(), msg.size());
     }
     else if (size == 0){
       // 對端關閉
       cout << "[" << ip << "]:[" << port  << "]# close" << endl;
       break;
     }
     else{
       // 出錯
       cerr << sock << "read error" << endl; 
       break;
     }
   }

   close(sock);
   cout << "service done" << endl;
   // 子程序退出
   exit(0);
}

完整版程式碼:

#include<iostream>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<signal.h>
#include<pthread.h>
using namespace std;
#define DEFAULT_PORT 8080
#define BACK_LOG 5
class TcpServer
{
public:
   TcpServer(int port = DEFAULT_PORT):_port(port),_listen_sock(-1)
   { }
  ~TcpServer()
  {
     if(_listen_sock>=0)
    {
      close(_listen_sock);
     }
  }
 public:
  //建立通訊端
  bool TcpServerInit()
{
     //建立通訊端
     _listen_sock = socket(AF_INET,SOCK_STREAM,0);
    if(_listen_sock<0)
     {
       cout<<"通訊端建立失敗"<<endl;
       return false;
     }
    cout<<"通訊端建立成功,sock:"<<_listen_sock<<endl;
    //繫結埠號
     struct sockaddr_in local;
     memset(&local,0,sizeof(local));
     local.sin_family = AF_INET;
     local.sin_port = htons(_port);
     local.sin_addr.s_addr = INADDR_ANY;
     if(bind(_listen_sock,(struct sockaddr *)&local,sizeof(local))<0)
     {
         cout<<"繫結失敗"<<endl;
         return false;
     }
      cout<<"繫結成功"<<endl;
      //將通訊端設定成監聽通訊端
      if(listen(_listen_sock,BACK_LOG)<0)
      {
         cout<<"監聽通訊端建立失敗"<<endl;
         return false;
      }
         cout<<"監聽通訊端建立成功"<<endl;
         return true;
 }
  //迴圈獲取連線
  void loop()
  {
         //對訊號SIGCHLD訊號進行註冊,處理方式為忽略,子程序結束的時候會交由核心處理
         signal(SIGCHLD,SIG_IGN);
         struct sockaddr_in peer;//獲取使用者端的埠號和ip
         socklen_t len = sizeof(peer);
         while(1)
         {
           //獲取連線
           //sock是進行通訊的一個通訊端,_listen_sock是用來監聽的通訊端
           int sock = accept(_listen_sock,(struct sockaddr *)&peer,&len);
           if(sock<0)
           {
              cout<<"accept fail,continue accept"<<endl;
              continue;
           }
          //建立子程序
          pid_t id = fork();
          if(id == 0)
          {
              //子程序
              close(_listen_sock);//可以不關閉,但是建議關閉,防止後期子程序對監聽通訊端進行一些操作,影響父程序
              //在前面的部落格中講過,父子程序共用檔案表,對檔案進行讀寫操作會影響彼此,但是由於子程序有自己的PCB,有自己的檔案表項,關閉自己程序的檔案描述符不會造成影響
              int peerPort = ntohs(peer.sin_port);
              string peerIp = inet_ntoa(peer.sin_addr);
              cout<<"獲得了一個新的連線,["<< peerIp <<"]:["<< peerPort <<"]"<<endl;
              this->Server(peerIp,peerPort,sock);
           }
         //父程序繼續取獲取連線
       }
   }
   void Server(string ip,int port,int sock)
  {
         while(1)
         {
             char buf[256];
             ssize_t size = read(sock,buf,sizeof(buf)-1);
             if (size > 0){
                // 正常讀取size位元組的資料
                buf[size] = 0;
                cout << "[" << ip << "]:[" << port  << "]# "<< buf <<endl;
                string msg = "server get!-> ";
                msg += buf;
                write(sock, msg.c_str(), msg.size());
         }else if (size == 0){
                // 對端關閉
                cout << "[" << ip << "]:[" << port  << "]# close" << endl;
                break;
         }
         else{
                // 出錯
                cout << sock << "read error" << endl;
                break;
        }
         }
       close(sock);
       cout<<"server done"<<endl;
       //子程序退出
       exit(0);
   }
private:
   int _port;
   int _listen_sock;
};
int main(int argc,char* argv[])
{
 
  if (argc != 2){
    cout << "Usage:" << argv[0] << "port:" << endl;   
    exit(-1);
  }
  int port = atoi(argv[1]);
  TcpServer* usr = new TcpServer(port);
  usr->TcpServerInit();
  usr->loop();

  delete usr;
  system("pause");
  return EXIT_SUCCESS;
}

執行結果如下:

注意: 方法二中,父程序建立好子程序之後,子程序可以將監聽通訊端關閉,此時該通訊端對子程序來說是沒有用的,當然也可以不用關閉,沒有多大的浪費。但父程序關閉掉服務sock是有必要的,因為此時父程序不需要維護這些通訊端了,孫子程序維護即可,如果不關閉,且有很多使用者端向伺服器端發起請求,那麼父程序這邊就要維護很多不必要的通訊端,讓父程序的檔案描述符不夠用,造成檔案描述符洩漏,所以父程序關閉服務通訊端是必須的。
方法二程式碼編寫:

 //迴圈獲取連線
 void loop()
 {
        struct sockaddr_in peer;//獲取使用者端的埠號和ip
        socklen_t len = sizeof(peer);
        while(1)
        {
          //獲取連線
          //sock是進行通訊的一個通訊端,_listen_sock是用來監聽的通訊端
          int sock = accept(_listen_sock,(struct sockaddr *)&peer,&len);
          if(sock<0)
          {
              cout<<"accept fail,continue accept"<<endl;
              continue;
          }
          //建立子程序
          pid_t id = fork();
          if(id == 0)
          {
            //子程序
            //子程序和父程序檔案描述符一致
            close(_listen_sock);//可以不關閉,但是建議關閉,防止後期子程序對監聽通訊端進>    行一些操作,影響父程序
            if(fork()>0)
            {
                //父程序
                //直接退出,讓孫子程序被os(1號程序)領養,退出的時候資源被作業系統回收
                exit(0);
            }
  
           //孫子程序
           int peerPort = ntohs(peer.sin_port);
           string peerIp = inet_ntoa(peer.sin_addr);
           cout<<"獲得了一個新的連線,["<< peerIp <<"]:["<< peerPort <<"]"<<endl;
        this->Server(peerIp,peerPort,sock);
        }
        //關閉sock,如果不關閉,那麼爺爺程序可用的檔案描述符越來越少
        //通訊的工作交給孫子程序
        close(sock);
        //爺爺程序等待兒子程序
        waitpid(-1,nullptr,0);
    }
 }
void Server(string ip, int port, int sock)
{
   while (1){
     char buf[256];
     ssize_t size = read(sock, buf, sizeof(buf)-1);
     if (size > 0){
       // 正常讀取size位元組的資料
       buf[size] = 0;
       cout << "[" << ip << "]:[" << port  << "]# "<< buf <<endl;
       string msg = "server get!-> ";
       msg += buf;
       write(sock, msg.c_str(), msg.size());
     }
     else if (size == 0){
       // 對端關閉
       cout << "[" << ip << "]:[" << port  << "]# close" << endl;
       break;
     }
     else{
       // 出錯
       cerr << sock << "read error" << endl; 
       break;
     }
   }

   close(sock);
   cout << "service done" << endl;
   // 子程序退出
   exit(0);
}

小夥伴們可以動手執行一下哦~

多執行緒版本

思路: 通過建立一個執行緒為使用者端提供服務,建立好的執行緒之間進行執行緒分離,這樣主執行緒就不需要等待其它執行緒了
方法: 讓啟動函數執行服務的程式碼,其中最後一個引數可以傳一個類過去,這個類包含了,使用者端埠號和通訊端資訊,如下:

struct Info
{
  int _port;
  std::string _ip;
  int _sock;

  Info(int port, string ip, int sock)
    :_port(port)
     ,_ip(ip)
     ,_sock(sock)
  {}
};

注意: 這裡為了不讓thread_run多一個this指標這個引數,所以用static修飾該函數,就沒有this指標這個引數了,為了讓建立出來的執行緒執行緒就可以呼叫該Service函數,這裡將Service函數也用static修飾

static void* thread_run(void* arg)
{
  Info info = *(Info*)arg;
  delete (Info*)arg;
  // 執行緒分離
  pthread_detach(pthread_self());
  Service(info._ip, info._port, info._sock);
}
void loop()
{
  struct sockaddr_in peer;// 獲取遠端埠號和ip資訊
  socklen_t len = sizeof(peer);
  while (1){ 
    // 獲取連結 
    // sock 是進行通訊的一個通訊端  _listen_sock 是進行監聽獲取連結的一個通訊端 
    int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len); 
    if (sock < 0){ 
      cout << "accept fail, continue accept" << endl; 
      continue; 
    } 
    // 多執行緒版本 
    pthread_t tid; 
    int peerPort = ntohs(peer.sin_port); 
    string peerIp = inet_ntoa(peer.sin_addr);
    Info* info = new Info(peerPort, peerIp, sock); 
    pthread_create(&tid, nullptr, thread_run, (void*)info);
  }
}
static void Service(string ip, int port, int sock)
{
   while (1){
     char buf[256];
     ssize_t size = read(sock, buf, sizeof(buf)-1);
     if (size > 0){
       // 正常讀取size位元組的資料
       buf[size] = 0;
       cout << "[" << ip << "]:[" << port  << "]# "<< buf << endl;
       string msg = "server get!-> ";
       msg += buf;
       write(sock, msg.c_str(), msg.size());
     }
     else if (size == 0){
       // 對端關閉
       cout << "[" << ip << "]:[" << port  << "]# close" << endl;
       break;
     }
     else{
       // 出錯
       cout << sock << "read error" << endl; 
       break;
     }
   }

   close(sock);
   cout << "service done" << endl;
}

執行緒池版本

由於還沒有介紹執行緒池的相關知識,下一章部落格將會更新執行緒池的知識和執行緒池版本的伺服器程式碼