
文章目錄
- 前言
- 套接字接口
- TCP服務器
- TCP + 多進程
- TCP + 線程池
- 重寫Task任務
- 放函數對象
- 客戶端重連
- 進程組與守護進程
- 進程組和會話
- 守護進程
前言
在互聯網技術蓬勃發展的今天,高并發、高可靠的網絡服務已成為各類應用的核心訴求 —— 從支撐海量用戶的 Web 服務器,到實時交互的分布式系統,甚至是物聯網設備的通信底座,高效的網絡通信設計與進程生命周期管理,始終是保障服務穩定運行的基石。
本文將聚焦 Linux 網絡編程與進程管理 的核心技術,以 “從基礎到進階,從實現到優化” 的脈絡展開:
- 從最基礎的 套接字接口 出發,剖析網絡通信的底層邏輯;
- 通過 TCP 服務器 的搭建,掌握客戶端 - 服務端交互的核心流程;
- 針對高并發場景,探索 多進程、線程池 等并行模型的設計,突破服務吞吐量的瓶頸;
- 最終深入 進程組與守護進程 的實踐,解決服務 “脫離終端、長期穩定運行” 的生產級需求。
TCP通信是面向字節流的,而UDP是面向最字節報的,因此兩者通信方式上有本質的差異。
TCP面向字節流也就意味著,接收方讀取上來的數據可能是不完整的,因此TCP通信要進行協議定制,規定一個消息從哪到哪是一個整體部分。關于協議的定制我們在下一篇博客中詳細講解,本篇文章我們假設通過TCP通信對方就可以拿到一個完整的數據。
套接字接口
TCP的接口和UDP接口有類似的,當時也有一些不同之處。
UDP通信的步驟就是:創建套接字,綁定,接收和發送消息;而TCP與其是不一樣的。
- TCP通信時面向連接的,需要通信雙方先建立連接,服務器一般是比較“被動”的,服務器一直處于等待外界連接的狀態(監聽狀態)。
因此在進行綁定完成之后,服務器要先進入監聽狀態,與客戶端建立連接后才能進行通信:
int listen(int sockfd , int backlog)
:
- 參數一:套接字;
- 參數二:
backlog
表示未完成連接隊列(處于三次握手過程中)和已完成連接隊列(三次握手完成,等待accept
處理)的最大長度之和。用來調節連接時的并發量; - 返回值:成功返回0,失敗-1;
第二個接口,將服務器設置為監聽模式之后,要對客戶端的連接請求做出響應,要接收客戶端的請求:
int accept(int sockfd , struct sockaddr_in *addr , socklen_t *addrlen)
:
- 參數一:套接字;
- 參數二:輸出型參數,一個結構體,存儲著客戶端的ip和端口號信息;
- 參數三:輸出型參數,表示第二個結構體的大小;
- 返回值:返回一個文件描述符,通過該文件描述符可以讓直接使用
write
和read
接口進行通信,就像從文件中進行讀寫一樣。
注意:accept
中的sockfd
也屬于文件描述符,只不過該描述符主要負責將底層的連接請求來上來,而不負責進行IO交互;而accept
返回的文件描述符是專門用來進行IO交互的。
隨著客戶端越來越多,accept
返回的文件描述符也就也來越多,每一個都負責與一個客戶端進行通信。
客戶端要與服務端建立連接,所以需要先服務端發送連接請求:
int connet(int sockfd , struct sockaddr* addr , socklen_t addrlen)
:
- 參數一:套接字;
- 參數二:結構體,內部包含要進行連接的IP和端口號;
- 參數三:參數二結構體的大小;
- 返回值:0表示成功,-1表示失敗。
TCP服務器
使用一個類來實現TCP服務器:
- 內存成員需要有IP和端口號,來進行綁定;
- 并且需要將套接字存儲起來,否則后續在不到套接字就會導致無法關閉對應的網絡文件位置。
- 此處在設計一個
bool
類型的變量,讓用戶可以控制時候打開服務器。
初始化的時候需要外界將這些參數都傳進行保存起來,但是并不在初始化時創建套接字,而是當用戶運行時才進行創建。
const std::string defaultip = "0.0.0.0";class Server
{
public:Server(const uint16_t &port , const std::string &ip = defaultip):port_(port) , ip_(ip){}private:uint16_t port_;std::string ip_;int sockfd_;
};
與UDP一樣,為了保證服務器能夠接收來自各個網卡上的數據,我們再對服務器進行綁定的時候使用ip為0。
在此之前我們需要思考以下接收到的信息如何進行處理?
如果我們直接讓處理方法都在循環內完成,就會導致代碼拓展性差,如果后續希望接入進程池就需要對代碼進行重構,因此此處將對接收到的信息處理方法也單獨封裝一個類:
該類主要負責,將對信息進行處理,處理完后,向客戶端返回數據,因此該類的成員必須有一個string
用來存儲待處理的信息,為了進行通信還需要拿到對應的文件描述符。
我們可以在類中對調用運算符進行重載,在進行消息調用的時候更簡單。
為了后續測試,我們先不進行太復雜的處理:
class Task
{
public:Task(const int & fd , const std::string message):fd_(fd) , message_(message){}bool operator()(){std::string ret = "I have got your message : " + message_;write(fd_ , ret.c_str() , ret.size()); return true;}
private:int fd_;std::string message_;
};
現在可以對服務器進行初始化了,初始化主要分為3步:
- 創建套接字;
- 綁定;
- 設置監聽模式。
void Init(){// 1. 創建套接字// 2. 綁定// 3. 設置監聽模式sockfd_ = socket(AF_INET , SOCK_STREAM , 0);if(sockfd_ < 0){Log(Fatal) << "socket failed ";exit(Socket_Err);}struct sockaddr_in local;local.sin_family = AF_INET;local.sin_port = htons(port_);char clientip[32];inet_aton(ip_.c_str() , &local.sin_addr);if(bind(sockfd_ , (const struct sockaddr*)&local , sizeof(local)) < 0){Log(Fatal) << "bind failed" ;exit(Bind_Err);}if(listen(sockfd_ , 10) < 0){Log(Fatal) << "listen failed" ;exit(Listen_Err);}}
運行服務器了,運行服務器:
- 先建立連接;
- 讀取數據;
- 做出反應。
void Service(int fd_){ char buffer[1024];while(1){int n = read(fd_ , buffer , sizeof(buffer) - 1);if(n > 0){buffer[n] = 0;Task task(fd_ , buffer);task();}else if(n == 0){close(fd_);break;}else {Log(Error) << "read error";close(fd_);break;}}}void Start(){// 1. 建立連接// 2. 讀取消息// 3. 對消息進行處理,并返回struct sockaddr_in client;socklen_t len = sizeof(client);int fd = accept(sockfd_ , (struct sockaddr*)&client , &len);if(fd < 0){Log(Warning) << "accept failed";}Service(fd);}
此處我們將服務單獨進行了封裝,方便后面接入多線程/多進程。
服務器的類編寫完成,后面再進行拓展,當前先進行以下簡單測試:
編寫一個源文件來運行一下服務器:在執行的時候,必須給出端口號。
void Menu(char* argv[])
{std::cout << "\r" << argv[0] << " [port] " << "\n";
}int main(int argc , char* argv[])
{if(argc != 2){Menu(argv);exit(1);}uint16_t port = std::stoi(argv[1]);Server server(port);server.Init();server.Start();return 0;
}
當前服務器編寫完成了,但是客戶端還沒進行實現。如果想對服務端進行測試的話,可以先使用telnet
工具,綁定本地環回地址127.0.0.1
進行測試,但是只能起到本地通信的作用,不會將信息推送到網絡中。
下一步就是編寫客戶端了:
客戶端的編寫就比較簡單了:
- 創建套接字;
- 發送連接請求;
- 連接成功,發送數據;
- 接收數據。
與服務端的編寫類似,只不過要用到connect
接口:
void Menu(char *argv[])
{std::cout << argv[0] << " [ip] " << " [port] " << std::endl;
}int main(int argc, char *argv[])
{if (argc != 3){Menu(argv);exit(1);}std::string ip = argv[1];uint16_t port = std::stoi(argv[2]);// 1.創建套接字int sockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0){std::cerr << " socket failed ";exit(2);}// 2.發送連接請求struct sockaddr_in server;server.sin_family = AF_INET;server.sin_port = htons(port);inet_aton(ip.c_str(), &server.sin_addr);int n = connect(sockfd, (sockaddr *)&server, sizeof(server));if (n < 0){std::cerr << " connect failed ";exit(2);}// 3.進行通信std::string message;char buffer[1024];while (1){std::cout << "Please Enter@";std::getline(std::cin, message);write(sockfd, message.c_str(), message.size());n = read(sockfd, buffer, sizeof(buffer) - 1);if (n > 0){buffer[n] = 0;std::cout << buffer << std::endl;}if (message == "quit")break;}close(sockfd);return 0;
}
以上就是客戶端和服務端的所有代碼編寫,只不過給服務端只能處理一個用戶端。
為了能夠同時處理多個用戶端,此處我們需要使用多進程或多線程來實現。
TCP + 多進程
- 父進程創建子進程,讓子進程來與客戶端進行交互;
- 父進程只負責與子進程建立連接。
此處需要考慮子進程的回收問題,我們并不希望對子進程進行等待,因此有兩種方案:
- 直接將
SIGCHLD
信號進行屏蔽; - 使用孫子進程來完成與客戶端通信,子進程直接回收;
此處我們采用孫子進程的方式直接回收子進程,讓孫子進程被超卓系統領養。
此處我們僅需要對服務端類中得Start
進行修改即可:
void Start(){// 1. 建立連接// 2. 讀取消息// 3. 對消息進行處理,并返回while (1){struct sockaddr_in client;socklen_t len = sizeof(client);int fd = accept(sockfd_, (struct sockaddr *)&client, &len);if (fd < 0){Log(Warning) << "accept failed";}// 使用多進程來實現pid_t id = fork();if (id < 0){Log(Fatal) << "fork failed";}else if (id == 0){close(sockfd_);if (fork() == 0) // 使用孫子進程進行通信{Service(fd);exit(0);}exit(0);}// 父進程直接將fd關閉,不允許父進程與客戶端進行通信close(fd);pid_t rid = waitpid(id, nullptr, 0); // 回收子進程}}
以上就是多進程服務端的修改,也很簡單。
TCP + 線程池
- 主線程先任務隊列中添加任務,而線程池中的線程負責將任務取出來,執行。
引入線程池,向任務隊列中放什么???
有兩種方案:
- 對Task任務類進行從寫;
- 向任務隊列中放函數對象,讓線程能夠直接調用。
此處兩種方法都實現一下:
重寫Task任務
- 我們希望主線程構建一個Task任務,加入到任務隊列中,然后線程池中的線程拿出來執行。
- 線程池中的線程如果想與用戶端進行通信,就必須拿到文件描述符,因此Task類私有成員有一個文件描述符。
- task任務的調用運算符重載,應該變成原來的
Service
函數實現.
重寫如下:
class Task
{
public:Task(const int &fd): fd_(fd){}void operator()(){char buffer[1024];while (1){memset(buffer, 0, sizeof(buffer));int n = read(fd_, buffer, sizeof(buffer) - 1);if (n > 0){buffer[n] = 0;std::string ret = "I have got your message : " + std::string(buffer);write(fd_, ret.c_str(), ret.size());if (strcmp(buffer, "quit") == 0)break;}else if (n == 0){close(fd_);break;}else{Log(Level::Error) << "read error";close(fd_);break;}}}private:int fd_;
};
下一步就是對服務端的Start
的函數進行重寫,主線程負責向線程池放入Task對象:
void Start(){// 1. 建立連接// 2. 讀取消息// 3. 對消息進行處理,并返回std::unique_ptr<thread_poll<Task>>& ptp = thread_poll<Task>::GetInstance();ptp->run();while (1){struct sockaddr_in client;socklen_t len = sizeof(client);int fd = accept(sockfd_, (struct sockaddr *)&client, &len);if (fd < 0){Log(Level::Warning) << "accept failed";}// 父進程直接將fd關閉,不允許父進程與客戶端進行通信ptp->push(Task(fd));}}
通過這種方式,就實現了主線程向任務隊列中放數據,由線程池中的線程來與用戶端進行溝通。
放函數對象
我們已經有現成的函數調用對象了,就是服務端中的Service
函數,但是如果線程池中的線程并沒有在該函數中,因此也就沒有this指針了,所以我們在傳函數對象的時候,可以使用std::bind進行綁定,將this指針綁定到函數對象中,這樣線程池中的線程就可以直接進行調用了。
我們只需要對Service
函數進行綁定,保證線程池中的線程在調用的時候,不需要傳遞任何參數,可以直接調用即可:
void Start(){// 1. 建立連接// 2. 讀取消息// 3. 對消息進行處理,并返回using fun_t = std::function<void()>;std::unique_ptr<thread_poll<fun_t>>& ptp = thread_poll<fun_t>::GetInstance();ptp->run();while (1){struct sockaddr_in client;socklen_t len = sizeof(client);int fd = accept(sockfd_, (struct sockaddr *)&client, &len);if (fd < 0){Log(Level::Warning) << "accept failed";}// 父進程直接將fd關閉,不允許父進程與客戶端進行通信fun_t func = std::bind(&Server::Service , this , fd); // 綁定this指針和文件描述符ptp->push(func);}}
以上兩種方法都比較常用,后一種方法實現上更簡單一些。
客戶端重連
當服務端掛掉或者讀寫出錯時,我們上面的客戶端會直接退出;當服務端出現問題的時候,我們并不應該將客戶端直接退出,而是讓客戶端進行重連,即重新向服務端發送建立連接的請求。
下面我們將進行模擬實現,客戶端重連的機制:
- 客戶端重連,必定需要進行循環;當服務端掛掉時,讓客戶端重新進行
connect
嘗試重新建立連接; - 我們也不能一直讓客戶端進行連接,當嘗試連接的次數達到一定限制時,才讓客戶端退出。
下面時修改后的代碼實現,我們的主循環內部有兩個循環,一個用來控制重連的次數,另一個用來與服務端建立聯系。
void Menu(char *argv[])
{std::cout << argv[0] << " [ip] " << " [port] " << std::endl;
}int main(int argc, char *argv[])
{if (argc != 3){Menu(argv);exit(1);}std::string ip = argv[1];uint16_t port = std::stoi(argv[2]);struct sockaddr_in server;server.sin_family = AF_INET;server.sin_port = htons(port);inet_aton(ip.c_str(), &server.sin_addr);while (1){int cnt = 0, n = 0 , sockfd = -1;const int max_cnt = 6;do{// 1.創建套接字sockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0){std::cerr << " socket failed ";exit(2);}// 2.connextn = connect(sockfd, (sockaddr *)&server, sizeof(server));if (n < 0){std::cout << "connet failed : " << cnt++ << std::endl;sleep(1);}elsebreak;} while (cnt < max_cnt);if (cnt == max_cnt){std::cout << "server error" << std::endl;return 0;}// 3.進行通信std::string message;char buffer[1024];while (1){std::cout << "Please Enter@";std::getline(std::cin, message);write(sockfd, message.c_str(), message.size());n = read(sockfd, buffer, sizeof(buffer) - 1);if (n > 0){buffer[n] = 0;std::cout << buffer << std::endl;}elsebreak;if (message == "quit"){close(sockfd);return 0;}}}return 0;
}
客戶端在直接進行連接的時候,會出現連接失敗,因核心原因是 服務器重啟時,原端口因 TCP TIME_WAIT 狀態被占用,導致無法重新綁定端口(監聽失敗)。
所以我們需要對服務器進行設置:在服務器的 socket
創建后、bind
前,添加 端口復用選項:
int opt = 1;
setsockopt(sockfd_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt));
// 防止偶發性的服務器無法進行立即重啟
進程組與守護進程
在操作系統中我們有前臺進程和后臺進程;
- 通過
jobs
指令可以查看后臺進程; fg + 任務號
:將后臺進程拿到前臺;
但前臺進程被暫停后,如果向前臺進程發送19號信息,即SIGSTOP
時,前臺進程會被自動移動到后
臺進程,此時bash命令行解釋器會被移動到前臺。
bg + 任務號
,將后臺暫停的進程繼續執行。
在設計服務器的時候,我們希望服務器是后臺進程,并且不受到用戶的登錄和退出的影響;
下面解釋如何做到:
進程組和會話
- 在操作系統中有一個進程組的概念,進程組是一個或多個進程的集合,進程組中有一個組長:PID==PGID就是組長;
- 組長負責創建一個進程組或者在進程組中創建進程;該組長進程執行完畢,并不會影響組內其他進程的執行;
一個進程組中的進程協作來完成任務,最常見的就是通過管道執行命令,管道中的所有命令都屬于一個進程組。
可以通過ps aj
來查看進程的相關ID信息:
- 在操作系統中又定義了session會話的概念,session指的是一個或多個進程組。
- 通常默認一個會話與一個終端進行關聯,在操作系統中會有一個初始會話,該會話與終端直接建立聯系,控制終端可以向初始會話中的進程發送信號,同時當控制終端退出的時候,內部的所有進程,進程組都會被退出,這就會導致我們的服務器也會退出。
但是好在,當我們創建一個新會話的時候,新會話默認沒有控制終端,這也就保證了新會話不受終端的登錄和退出的控制。
因此只要讓服務端自成一個新會話,就可以保證服務端持續運行。該進程不再與鍵盤關聯,不受到登錄和注銷的影響,這種進程就被稱為守護進程。下面看看守護進程如何實現。
守護進程
- 一個進程組的組長不能自成會話,也就不能當守護進程。
因此在自成會話的時候,需要時子進程,讓父進程直接退出,子進程作為孤兒進程自成會話。
我們通過pid_t setsid(void)
來讓一個進程自成會話。
- 一般我們會選擇將守護進程的一些信號進行忽略,防止收到信號影響;
- 并且一般會更改目錄,以及輸入輸出,將輸入輸出定向到
/dev/null
中。
現在讓我們來實現守護進程:
const std::string defaultdir = "/";
const std::string nullfile = "/dev/null";void Deamon(bool ischdir , bool isclose)
{// 1.忽略信號signal(SIGPIPE , SIG_IGN);signal(SIGPIPE , SIG_IGN);signal(SIGSTOP , SIG_IGN);// 2. 自成會話if(fork() > 0 ) exit(0); // 父進程直接退出setsid();if(ischdir)chdir(defaultdir.c_str());if(isclose) // 是否關閉文件{close(0);close(1);close(2);}else{int fd = open(nullfile.c_str() , O_RDWR);dup2(fd , 0);dup2(fd , 1);dup2(fd , 2);}
}
以上就是自己實現的守護進程接口。
實際上操作系統也提供了接口,讓一個進程自成會話int daemon(int nochdir , int noclose)
,在這里就不再介紹了。