目錄
1、實現(xiàn)一個TCP網(wǎng)絡(luò)程序(單進程版)
????????1.1、服務(wù)端serverTcp.cc文件
?????????????????服務(wù)端創(chuàng)建套接字
?????????????????服務(wù)端綁定
?????????????????服務(wù)端監(jiān)聽
?????????????????服務(wù)端獲取連接
?????????????????服務(wù)端提供服務(wù)
?????????????????服務(wù)端main函數(shù)命令行參數(shù)
?????????????????服務(wù)端serverTcp.cc總代碼
????????1.2、客戶端clientTcp.cc文件
?????????????????客戶端main函數(shù)命令行參數(shù)
?????????????????客戶端創(chuàng)建套接字
?????????????????客戶端的bind、listen、accept問題
?????????????????客戶端連接服務(wù)器
?????????????????客戶端發(fā)起請求
?????????????????客戶端clinetTcp.cc總代碼
????????1.3、服務(wù)器測試
????????1.4、單執(zhí)行流服務(wù)器的問題
2、多進程版的TCP網(wǎng)絡(luò)程序
????????捕捉SIGCHLD信號
????????讓孫子進程提供服務(wù)
3、多線程版的TCP網(wǎng)絡(luò)程序
4、線程池版的TCP網(wǎng)絡(luò)程序
????????線程池變形
5、總代碼gitee鏈接
1、實現(xiàn)一個TCP網(wǎng)絡(luò)程序(單進程版)
1.1、服務(wù)端serverTcp.cc文件
我們把服務(wù)器封裝成一個ServerTcp類,該類里主要有如下幾個任務(wù):
- 服務(wù)端創(chuàng)建套接字
- 服務(wù)端綁定
- 服務(wù)端監(jiān)聽
- 服務(wù)端獲取鏈接
- 服務(wù)端提供服務(wù)
- 服務(wù)端main函數(shù)命令行參數(shù)
下面依次演示:
服務(wù)端創(chuàng)建套接字
我們把服務(wù)器封裝成一個ServerTcp類,當我們定義出一個服務(wù)器對象后需要馬上初始化服務(wù)器,而初始化服務(wù)器首先要創(chuàng)建套接字。創(chuàng)建套接字的函數(shù)叫做socket函數(shù),再回顧下其函數(shù)原型:
int socket(int domain, int type, int protocol);
這里TCP服務(wù)器在調(diào)用socket函數(shù)創(chuàng)建套接字時,參數(shù)設(shè)置如下:
- domain:協(xié)議家族選擇AF_INET,因為我們要進行的是網(wǎng)絡(luò)通信。
- type:創(chuàng)建套接字時所需的服務(wù)器類型應(yīng)該是SOCK_STREAM,因為我們編寫的是TCP服務(wù)器,SOCK_STREAM提供的就是一個有序的、可靠的、全雙工的、基于連接的流式服務(wù)。注意我UDP是用戶數(shù)據(jù)報服務(wù)。
- protocol:協(xié)議類型默認設(shè)置為0即可。
若socket創(chuàng)建失敗,則復(fù)用logMessage函數(shù)打印相關(guān)日志信息,并直接exit退出程序。
class ServerTcp { public: // 構(gòu)造函數(shù) + 析構(gòu)函數(shù) public: // 初始化 void init() { // 1、創(chuàng)建socket sock_ = socket(AF_INET, SOCK_STREAM, 0); if (sock_ < 0) { logMessage(FATAL, "socket: %s", strerror(errno)); // 創(chuàng)建失敗,打印日志 exit(SOCKET_ERR); } logMessage(DEBUG, "socket: %s, %d", strerror(errno), sock_); } private: int sock_; // socket uint16_t port_; // port string ip_; // ip };
服務(wù)端綁定
- 當套接字已經(jīng)創(chuàng)建成功了,但作為一款服務(wù)器來講,如果只是把套接字創(chuàng)建好了,那我們也只是在系統(tǒng)層面上打開了一個文件,操作系統(tǒng)將來并不知道是要將數(shù)據(jù)寫入到磁盤還是刷到網(wǎng)卡,此時該文件還沒有與網(wǎng)絡(luò)關(guān)聯(lián)起來。所以我們需要調(diào)用bind函數(shù)進行綁定操作。
綁定的步驟如下:
- 1、綁定網(wǎng)絡(luò)信息,先填充基本信息到struc sockaddr_in結(jié)構(gòu)體。
- 定義struc sockaddr_in結(jié)構(gòu)體對象local,復(fù)用memset函數(shù)對local進行初始化。將協(xié)議家族、端口號、IP地址等信息填充到該結(jié)構(gòu)體變量當中。注意協(xié)議家族這里設(shè)定的是PF_INET。
- 服務(wù)器的端口號是要發(fā)給對方的,在發(fā)送到網(wǎng)絡(luò)之前要復(fù)用htons主機轉(zhuǎn)網(wǎng)絡(luò)函數(shù)把端口號port_轉(zhuǎn)成網(wǎng)絡(luò)序列,才能向外發(fā)送。
- ip地址默認是字符串風格點分十進制的,這里復(fù)用inet_aton函數(shù)將字符串IP轉(zhuǎn)換成整數(shù)IP(inet_addr除了做轉(zhuǎn)換,還會自動給我們做主機轉(zhuǎn)網(wǎng)絡(luò))。注意若ip地址是空的,那就用INADDR_ANY這個宏,否則再用inet_addr函數(shù)。這個宏就是0,因此在設(shè)置時不需要進行網(wǎng)絡(luò)字節(jié)序的轉(zhuǎn)換。
- 2、綁定網(wǎng)絡(luò)信息,上述local臨時變量(struc sockaddr_in結(jié)構(gòu)體對象)是在用戶棧上開辟的,要將其寫入內(nèi)核中。復(fù)用bind函數(shù)完成綁定操作。bind成功與否均復(fù)用logMessage函數(shù)打印相關(guān)日志信息。
- 由于bind函數(shù)提供的是通用參數(shù)類型,因此在傳入結(jié)構(gòu)體地址時還需要將struct sockaddr_in*強轉(zhuǎn)為struct sockaddr*類型后再進行傳入。
class ServerTcp { public: // 構(gòu)造函數(shù) + 析構(gòu)函數(shù) public: // 初始化 void init() { // 1、創(chuàng)建socket // 2、bind綁定 // 2.1、填充服務(wù)器信息 struct sockaddr_in local; // 用戶棧 memset(&local, 0, sizeof(local)); local.sin_family = PF_INET; local.sin_port = htons(port_); ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr)); // 2.2、將本地socket信息,寫入sock_對應(yīng)的內(nèi)核區(qū)域 if (bind(sock_, (const struct sockaddr *)&local, sizeof(local)) == -1) { logMessage(FATAL, "bind: %s", strerror(errno)); // 綁定失敗,打印日志 exit(BIND_ERR); } logMessage(DEBUG, "bind: %s, %d", strerror(errno), sock_); } private: int sock_;// socket uint16_t port_; // port string ip_; // ip };
服務(wù)端監(jiān)聽
listen接口說明
- UDP服務(wù)器的初始化操作只有兩步,第一步就是創(chuàng)建套接字,第二步就是綁定。而TCP服務(wù)器是面向連接的,客戶端在正式向TCP服務(wù)器發(fā)送數(shù)據(jù)之前,需要先與TCP服務(wù)器建立連接,然后才能與服務(wù)器進行通信。
- 因此TCP服務(wù)器需要時刻注意是否有客戶端發(fā)來連接請求,此時就需要將TCP服務(wù)器創(chuàng)建的套接字設(shè)置為監(jiān)聽狀態(tài)。
設(shè)置套接字為監(jiān)聽狀態(tài)的函數(shù)叫做listen,該函數(shù)的函數(shù)原型如下:
int listen(int sockfd, int backlog);
參數(shù)說明:
- sockfd:需要設(shè)置為監(jiān)聽狀態(tài)的套接字對應(yīng)的文件描述符。
- backlog:全連接隊列的最大長度。如果有多個客戶端同時發(fā)來連接請求,此時未被服務(wù)器處理的連接就會放入連接隊列,該參數(shù)代表的就是這個全連接隊列的最大長度,一般不要設(shè)置太大,設(shè)置為5或10即可。
返回值說明:
- 監(jiān)聽成功返回0,監(jiān)聽失敗返回-1,同時錯誤碼會被設(shè)置。
代碼邏輯如下
- TCP是面向連接的,所以要讓TCP服務(wù)器時刻注意是否有客戶端發(fā)來連接請求,此時就需要將TCP服務(wù)器創(chuàng)建的套接字設(shè)置為監(jiān)聽狀態(tài)。監(jiān)聽失敗就打印日志信息,并直接退出。因為監(jiān)聽失敗就意味著TCP服務(wù)器無法接受客戶端發(fā)來的連接請求。
class ServerTcp { public: // 構(gòu)造函數(shù) + 析構(gòu)函數(shù) public: // 初始化 void init() { // 1、創(chuàng)建socket // 2、bind綁定 // 3、監(jiān)聽socket if (listen(sock_, 5) < 0) { logMessage(FATAL, "listen: %s", strerror(errno)); // 監(jiān)聽失敗,打印日志 exit(LISTEN_ERR); } logMessage(DEBUG, "listen: %s, %d", strerror(errno), sock_); } private: int sock_; // socket uint16_t port_; // port string ip_; // ip };
初始化TCP服務(wù)器時創(chuàng)建的套接字并不是普通的套接字,而應(yīng)該叫做監(jiān)聽套接字。為了表明寓意,我們將代碼中套接字的名字由sock_改為listensock_。
服務(wù)端獲取連接
accept接口說明
- TCP服務(wù)器初始化后就可以開始運行了,但TCP服務(wù)器在與客戶端進行網(wǎng)絡(luò)通信之前,服務(wù)器需要先獲取到客戶端的連接請求。究竟是誰連接我的。
獲取連接的函數(shù)叫做accept,該函數(shù)的函數(shù)原型如下:
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
參數(shù)說明:
- sockfd:特定的監(jiān)聽套接字,表示從該監(jiān)聽套接字中獲取連接。
- addr:對端網(wǎng)絡(luò)相關(guān)的屬性信息,包括協(xié)議家族、IP地址、端口號等。
- addrlen:調(diào)用時傳入期望讀取的addr結(jié)構(gòu)體的長度,返回時代表實際讀取到的addr結(jié)構(gòu)體的長度,這是一個輸入輸出型參數(shù)。
返回值說明:
- 獲取連接成功返回接收到的套接字的文件描述符,獲取連接失敗返回-1,同時錯誤碼會被設(shè)置。
調(diào)用accept函數(shù)獲取連接時,是從監(jiān)聽套接字當中獲取的。如果accept函數(shù)獲取連接成功,此時會返回接收到的套接字對應(yīng)的文件描述符。監(jiān)聽套接字與accept函數(shù)返回的套接字的作用:
- 監(jiān)聽套接字:用于獲取客戶端發(fā)來的連接請求。accept函數(shù)會不斷從監(jiān)聽套接字當中獲取新連接。
- accept函數(shù)返回的套接字:用于為本次accept獲取到的連接提供服務(wù)(為用戶提供網(wǎng)絡(luò)服務(wù),主要是進行IO)。監(jiān)聽套接字的任務(wù)只是不斷獲取新連接,而真正為這些連接提供服務(wù)的套接字是accept函數(shù)返回的套接字,而不是監(jiān)聽套接字。
代碼邏輯如下
- 定義struct sockaddr_in的對象peer,定義len為peer的字節(jié)數(shù)
- 復(fù)用accept函數(shù)獲取連接。若返回值<0說明連接失敗,但是TCP服務(wù)器不會因為某個連接失敗而退出,因此服務(wù)端獲取連接失敗后應(yīng)該繼續(xù)獲取連接。
- 獲取連接成功后,要獲取客戶端的基本信息,將客戶端的IP地址和端口號信息進行輸出,需要調(diào)用inet_ntoa函數(shù)將整數(shù)IP轉(zhuǎn)換成字符串IP,調(diào)用ntohs函數(shù)將端口號由網(wǎng)絡(luò)序列轉(zhuǎn)換成主機序列。
class ServerTcp { public: // 構(gòu)造函數(shù) + 析構(gòu)函數(shù) public: // 初始化 void init() { // 1、創(chuàng)建socket // 2、bind綁定 // 3、監(jiān)聽socket } // 啟動服務(wù)端 void loop() { while (true) { // 4、獲取連接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 獲取連接失敗 continue; } // 4.1、獲取客戶端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); } } private: int listensock_;// socket uint16_t port_; // port string ip_; // ip };
服務(wù)端接受連接測試
- 這里我們客戶端還沒有寫,但是我們可以先允許服務(wù)端,然后在windows下的瀏覽器上用當前云服務(wù)器ip(124.71.25.237)+端口號(8080)進行訪問測試
- 瀏覽器常見的應(yīng)用層協(xié)議是http或https,其底層對應(yīng)的也是TCP協(xié)議,因此瀏覽器也可以向當前這個TCP服務(wù)器發(fā)起請求連接。測試如下:
注意:
- 至于這里為什么瀏覽器一次會向我們的TCP服務(wù)器發(fā)起兩次請求這個問題,這里就不作討論了,我們只是要證明當前TCP服務(wù)器能夠正常接收外部的請求連接。
服務(wù)端提供服務(wù)
read接口說明
- 現(xiàn)在TCP服務(wù)器已經(jīng)能夠獲取連接請求了,下面當然就是要對獲取到的連接進行處理。為了讓通信雙方都能看到對應(yīng)的現(xiàn)象,我們這里就實現(xiàn)一個簡單的回聲TCP服務(wù)器,服務(wù)端在為客戶端提供服務(wù)時就簡單的將客戶端發(fā)來的數(shù)據(jù)進行輸出,并且將客戶端發(fā)來的數(shù)據(jù)重新發(fā)回給客戶端即可。當客戶端拿到服務(wù)端的響應(yīng)數(shù)據(jù)后再將該數(shù)據(jù)進行打印輸出,此時就能確保服務(wù)端和客戶端能夠正常通信了。
TCP服務(wù)器讀取數(shù)據(jù)的函數(shù)叫做read,該函數(shù)的函數(shù)原型如下:
ssize_t read(int fd, void *buf, size_t count);
參數(shù)說明:
- fd:特定的文件描述符,表示從該文件描述符中讀取數(shù)據(jù)。
- buf:數(shù)據(jù)的存儲位置,表示將讀取到的數(shù)據(jù)存儲到該位置。
- count:數(shù)據(jù)的個數(shù),表示從該文件描述符中讀取數(shù)據(jù)的字節(jié)數(shù)。
返回值說明:
- 如果返回值大于0,則表示本次實際讀取到的字節(jié)個數(shù)。
- 如果返回值等于0,則表示對端已經(jīng)把連接關(guān)閉了。
- 如果返回值小于0,則表示讀取時遇到了錯誤。
read返回值為0表示對端連接關(guān)閉。這實際和本地進程間通信中的管道通信是類似的,當使用管道進行通信時,可能會出現(xiàn)如下情況:
- 寫端進程不寫,讀端進程一直讀,此時讀端進程就會被掛起,因為此時數(shù)據(jù)沒有就緒。
- 讀端進程不讀,寫端進程一直寫,此時當管道被寫滿后寫端進程就會被掛起,因為此時空間沒有就緒。
- 寫端進程將數(shù)據(jù)寫完后將寫端關(guān)閉,此時當讀端進程將管道當中的數(shù)據(jù)讀完后就會讀到0。
- 讀端進程將讀端關(guān)閉,此時寫端進程就會被操作系統(tǒng)殺掉,因為此時寫端進程寫入的數(shù)據(jù)不會被讀取。
這里的寫端就對應(yīng)客戶端,如果客戶端將連接關(guān)閉了,那么此時服務(wù)端將套接字當中的信息讀完后就會讀取到0,因此如果服務(wù)端調(diào)用read函數(shù)后得到的返回值為0,此時服務(wù)端就不必再為該客戶端提供服務(wù)了。
write接口說明
- TCP服務(wù)器寫入數(shù)據(jù)的函數(shù)叫做write,該函數(shù)的函數(shù)原型如下:
ssize_t write(int fd, const void *buf, size_t count);
參數(shù)說明:
- fd:特定的文件描述符,表示將數(shù)據(jù)寫入該文件描述符對應(yīng)的套接字。
- buf:需要寫入的數(shù)據(jù)。
- count:需要寫入數(shù)據(jù)的字節(jié)個數(shù)。
返回值說明:
- 寫入成功返回實際寫入的字節(jié)數(shù),寫入失敗返回-1,同時錯誤碼會被設(shè)置。
當服務(wù)端調(diào)用read函數(shù)收到客戶端的數(shù)據(jù)后,就可以再調(diào)用write函數(shù)將該數(shù)據(jù)再響應(yīng)給客戶端。
代碼邏輯如下
- 注意:服務(wù)端讀取數(shù)據(jù)是服務(wù)套接字中讀取的,而寫入數(shù)據(jù)的時候也是寫入進服務(wù)套接字的。也就是說這里為客戶端提供服務(wù)的套接字,既可以讀取數(shù)據(jù)也可以寫入數(shù)據(jù),這就是TCP全雙工的通信的體現(xiàn)。
- 這里我們把服務(wù)端提供服務(wù)的過程封裝成一個transService函數(shù),其內(nèi)部完成的主要功能是完成大小寫轉(zhuǎn)化
- 首先,調(diào)用read函數(shù)讀取客戶端發(fā)來的數(shù)據(jù),這里且假定讀取的是字符串。read函數(shù)返回值為s。
- 若返回值s > 0,說明讀取成功,在內(nèi)部首先調(diào)用strcasecmp函數(shù)判斷客戶端是否需要服務(wù)端提供服務(wù),若不需要(quit),則打印日志并退出,若需要,在內(nèi)部完成大小寫轉(zhuǎn)化的功能。轉(zhuǎn)化完成后調(diào)用write函數(shù)將結(jié)果返回給客戶端
- 若返回值s = 0或s < 0,此時就應(yīng)該直接將服務(wù)套接字對應(yīng)的文件描述符關(guān)閉。因為文件描述符本質(zhì)就是數(shù)組的下標,因此文件描述符的資源是有限的,如果我們一直占用,那么可用的文件描述符就會越來越少,因此服務(wù)完客戶端后要及時關(guān)閉對應(yīng)的文件描述符,否則會導(dǎo)致文件描述符泄漏。
class ServerTcp { public: // 構(gòu)造函數(shù) + 析構(gòu)函數(shù) public: // 初始化 void init() { // 1、創(chuàng)建socket // 2、bind綁定 // 3、監(jiān)聽socket } // 啟動服務(wù)端 void loop() { while (true) { // 4、獲取連接 // 4.1、獲取客戶端基本信息 // 5、提供服務(wù),echo ( 小寫 -> 大寫 ) // 5.0 v0版本 transService(serviceSock, peerIp, peerPort); } } // 大小寫轉(zhuǎn)化服務(wù) // TCP && UDP: 支持全雙工 void transService(int sock, const string &clientIp, uint16_t clientPort) { assert(socket >= 0); assert(!clientIp.empty()); assert(clientPort >= 1024); char inbuffer[BUFFER_SIZE]; while (true) { ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1); // 我們認為讀取到的都是字符串 if (s > 0) // 讀取成功 { inbuffer[s] = '\0'; // read success if (strcasecmp(inbuffer, "quit") == 0) // strcasecmp是忽略大小寫比較的函數(shù) { // 客戶端輸入退出 logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; } logMessage(DEBUG, "trans before: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 可以進行大小寫轉(zhuǎn)化了 for (int i = 0; i < s; i++) { if (isalpha(inbuffer[i]) && islower(inbuffer[i])) inbuffer[i] = toupper(inbuffer[i]); } logMessage(DEBUG, "trans after: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 進行寫回操作 write(sock, inbuffer, strlen(inbuffer)); } else if (s == 0) // 對方關(guān)閉 { logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; } else // 讀取出錯 { logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno)); break; } } // 只要走到這里,一定是client退出了,服務(wù)到此結(jié)束 close(sock); // 如果一個進程對應(yīng)的文件fd,打開了沒有被歸還,則文件描述符泄露 logMessage(DEBUG, "server close %d done", sock); } private: int listensock_; // 監(jiān)聽套接字socket uint16_t port_; // port string ip_; // ip };
服務(wù)端main函數(shù)命令行參數(shù)
將來我們的服務(wù)端在啟動的時候,在命令行中一定是按照如下格式輸入的:
./ServerTcp local_port local_ip
我們需要給main函數(shù)加上命令行參數(shù),內(nèi)部代碼邏輯如下:
- 利用命令行參數(shù)的形式,若main函數(shù)中argc != 2 && argc != 3,則復(fù)用提示信息函數(shù)Usage,并exit退出進程
- 定義port端口為命令行的第二個參數(shù)(下標為1的參數(shù))
- 若argc == 3,則定義ip地址為命令行的第三個參數(shù)(下標為2的參數(shù))
- 將端口號和ip地址傳入ServerTcp服務(wù)器的類里,調(diào)用init和start函數(shù)
static void Usage(string proc) { cerr << "Usage:\n\t" << proc << "port ip" << endl; cerr << "Example:\n\t" << proc << "8080 127.0.0.1\n" << endl; } // ./ServerTcp local_port local_ip int main(int argc, char *argv[]) { if (argc != 2 && argc != 3) { Usage(argv[0]); exit(USAGE_ERR); } uint16_t port = atoi(argv[1]); string ip; if (argc == 3) ip = argv[2]; ServerTcp svr(port, ip); svr.init(); svr.loop(); return 0; }
服務(wù)端serverTcp.cc總代碼
ServerTcp類的成員變量如下:
- listensock_
- port_
- ip_
ServerTcp類的成員函數(shù)如下:
- ServerTcp構(gòu)造函數(shù)
- ServerTcp析構(gòu)函數(shù)
- init初始化函數(shù)
- loop啟動服務(wù)器函數(shù)
總代碼如下:
#include "utli.hpp" class ServerTcp { public: ServerTcp(uint16_t port, const string &ip = "") : port_(port), ip_(ip), listensock_(-1) {} ~ServerTcp() {} public: // 初始化 void init() { // 1、創(chuàng)建socket listensock_ = socket(AF_INET, SOCK_STREAM, 0); if (listensock_ < 0) { logMessage(FATAL, "socket: %s", strerror(errno)); // 創(chuàng)建失敗,打印日志 exit(SOCKET_ERR); } logMessage(DEBUG, "socket: %s, %d", strerror(errno), listensock_); // 2、bind綁定 // 2.1、填充服務(wù)器信息 struct sockaddr_in local; // 用戶棧 memset(&local, 0, sizeof(local)); local.sin_family = PF_INET; local.sin_port = htons(port_); ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr)); // 2.2、將本地socket信息,寫入listensock_對應(yīng)的內(nèi)核區(qū)域 if (bind(listensock_, (const struct sockaddr *)&local, sizeof(local)) == -1) { logMessage(FATAL, "bind: %s", strerror(errno)); // 綁定失敗,打印日志 exit(BIND_ERR); } logMessage(DEBUG, "bind: %s, %d", strerror(errno), listensock_); // 3、監(jiān)聽socket if (listen(listensock_, 5) < 0) { logMessage(FATAL, "listen: %s", strerror(errno)); // 監(jiān)聽失敗,打印日志 exit(LISTEN_ERR); } logMessage(DEBUG, "listen: %s, %d", strerror(errno), listensock_); // 允許別人連接你了 } // 啟動服務(wù)端 void loop() { while (true) { // 4、獲取連接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 獲取連接失敗 continue; } // 4.1、獲取客戶端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服務(wù),echo ( 小寫 -> 大寫 ) // 5.0 v0版本 —— 單進程 transService(serviceSock, peerIp, peerPort); } } // 大小寫轉(zhuǎn)化服務(wù) // TCP && UDP: 支持全雙工 void transService(int sock, const string &clientIp, uint16_t clientPort) { assert(socket >= 0); assert(!clientIp.empty()); assert(clientPort >= 1024); char inbuffer[BUFFER_SIZE]; while (true) { ssize_t s = read(sock, inbuffer, sizeof(inbuffer) - 1); // 我們認為讀取到的都是字符串 if (s > 0) // 讀取成功 { inbuffer[s] = '\0'; // read success if (strcasecmp(inbuffer, "quit") == 0) // strcasecmp是忽略大小寫比較的函數(shù) { // 客戶端輸入退出 logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; } logMessage(DEBUG, "trans before: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 可以進行大小寫轉(zhuǎn)化了 for (int i = 0; i < s; i++) { if (isalpha(inbuffer[i]) && islower(inbuffer[i])) inbuffer[i] = toupper(inbuffer[i]); } logMessage(DEBUG, "trans after: %s[%d]>>> %s", clientIp.c_str(), clientPort, inbuffer); // 進行寫回操作 write(sock, inbuffer, strlen(inbuffer)); } else if (s == 0) // 對方關(guān)閉 { logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; } else // 讀取出錯 { logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno)); break; } } // 只要走到這里,一定是client退出了,服務(wù)到此結(jié)束 close(sock); // 如果一個進程對應(yīng)的文件fd,打開了沒有被歸還,則文件描述符泄露 logMessage(DEBUG, "server close %d done", sock); } private: int listensock_; // 監(jiān)聽套接字socket uint16_t port_; // port string ip_; // ip }; static void Usage(string proc) { cerr << "Usage:\n\t" << proc << "port ip" << endl; cerr << "Example:\n\t" << proc << "8080 127.0.0.1\n" << endl; } // ./ServerTcp local_port local_ip int main(int argc, char *argv[]) { if (argc != 2 && argc != 3) { Usage(argv[0]); exit(USAGE_ERR); } uint16_t port = atoi(argv[1]); string ip; if (argc == 3) ip = argv[2]; ServerTcp svr(port, ip); svr.init(); svr.loop(); return 0; }
1.2、客戶端clientTcp.cc文件
這里我們不像服務(wù)端udpServer.cc一樣進行封裝成類了。其內(nèi)部主要框架邏輯如下:
- main函數(shù)采用命令行參數(shù)
- 客戶端創(chuàng)建套接字
- 通訊過程(啟動客戶端)
下面依次演示
客戶端main函數(shù)命令行參數(shù)
客戶端在啟動的時候必須要知道服務(wù)端的ip和port,才能進行連接服務(wù)端。未來的客戶端程序一定是這樣運行的:
./clientTcp serverIp serverPort
- 如果命令行參數(shù)個數(shù)argc != 3,復(fù)用Usage函數(shù)輸出相關(guān)提示信息,并退出程序
- 定義string類型的serverIp變量保存命令行的第二個參數(shù)
- 定義serverPort變量保存命令行中的第三個參數(shù)
static void Usage(string proc) { cerr << "Usage:\n\t" << proc << "serverIp serverPort" << endl; cerr << "Example:\n\t" << proc << "127.0.0.1 8080\n" << endl; } // ./clientTcp serverIp serverPort int main(int argc, char* argv[]) { if (argc != 3) { Usage(argv[0]); exit(USAGE_ERR); } string serverIp = argv[1]; uint16_t serverPort = atoi(argv[2]); return 0; }
客戶端創(chuàng)建套接字
客戶端創(chuàng)建套接字時選擇的協(xié)議家族也是AF_INET,需要的服務(wù)類型也是SOCK_STREAM。與服務(wù)端不同的是,客戶端在初始化時只需要創(chuàng)建套接字就行了,而不需要進行綁定操作。
int main() { ... // 1、創(chuàng)建socket int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { cerr << "socket: " << strerror(errno) << endl; exit(SOCKET_ERR); } ... close(sock); return 0; }
客戶端的bind、listen、accept問題
客戶端需不需要自己進行bind綁定呢?
- 不需要。所謂的“不需要”,指的是:客戶端不需要用戶自己bind端口信息!因為OS會自動給你綁定。(這個問題和udp的一樣)
客戶端需不需要自己進行l(wèi)isten監(jiān)聽呢?
- 不需要。監(jiān)聽本來就是等著別人來連你,作為客戶端,你是要主動連接別人的,而不是等著服務(wù)端自動向你連接的,這屬實反客為主了。
- 而服務(wù)端需要進行監(jiān)聽是因為服務(wù)端需要通過監(jiān)聽來獲取新連接,但是不會有人主動連接客戶端,因此客戶端是不需要進行監(jiān)聽操作的。
客戶端需不需要自己進行accept獲取呢?
- 不需要,因為都沒有l(wèi)isten,都沒有人來連你,當然不用accpet
客戶端連接服務(wù)器
connect接口說明
- 客戶端創(chuàng)建完套接字后需要向服務(wù)器發(fā)送鏈接請求。發(fā)起連接請求的函數(shù)叫做connect,該函數(shù)的函數(shù)原型如下:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
參數(shù)說明:
- sockfd:特定的套接字,表示通過該套接字發(fā)起連接請求。
- addr:對端網(wǎng)絡(luò)相關(guān)的屬性信息,包括協(xié)議家族、IP地址、端口號等。
- addrlen:傳入的addr結(jié)構(gòu)體的長度。
返回值說明:
- 連接或綁定成功返回0,連接失敗返回-1,同時錯誤碼會被設(shè)置。
代碼邏輯如下
- 定義struct sockaddr_in類型的結(jié)構(gòu)體指針server,復(fù)用memset函數(shù)對其清零
- 填寫服務(wù)器對應(yīng)的信息,將協(xié)議家族、端口號、IP地址等信息填充到該結(jié)構(gòu)體變量當中。
- 注意要復(fù)用htons主機轉(zhuǎn)網(wǎng)絡(luò)函數(shù)把端口號轉(zhuǎn)成網(wǎng)絡(luò)序列,才能向外發(fā)送。
- 注意要復(fù)用inet_aton函數(shù)將字符串IP轉(zhuǎn)換成整數(shù)IP
- 復(fù)用connect函數(shù)向服務(wù)器發(fā)送連接請求
int main(int argc, char *argv[]) { // 1、創(chuàng)建socket // 2、connect, 向服務(wù)器發(fā)起連接請求 // 2.1、先填充需要連接的遠端主機的基本信息 struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverPort); inet_aton(serverIp.c_str(), &server.sin_addr); // 2.2、發(fā)起請求,connect 回自動幫我們進行bind if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0) { cerr << "connect: " << strerror(errno) << endl; exit(CONN_ERR); } cout << "info: connect success: " << sock << endl; return 0; }
客戶端發(fā)起請求
- 由于我們實現(xiàn)的是一個簡單的回聲服務(wù)器,因此當客戶端連接到服務(wù)端后,客戶端就可以向服務(wù)端發(fā)送數(shù)據(jù)了,這里我們可以讓客戶端將用戶輸入的數(shù)據(jù)發(fā)送給服務(wù)端,發(fā)送時調(diào)用write函數(shù)向套接字當中寫入數(shù)據(jù)即可。
- 當客戶端將數(shù)據(jù)發(fā)送給服務(wù)端后,由于服務(wù)端讀取到數(shù)據(jù)后還會進行回顯,因此客戶端在發(fā)送數(shù)據(jù)后還需要調(diào)用read函數(shù)讀取服務(wù)端的響應(yīng)數(shù)據(jù),然后將該響應(yīng)數(shù)據(jù)進行打印,以確定雙方通信無誤。
int main(int argc, char *argv[]) { // 1、創(chuàng)建socket // 2、connect, 向服務(wù)器發(fā)起連接請求 // 2.1、先填充需要連接的遠端主機的基本信息 struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverPort); inet_aton(serverIp.c_str(), &server.sin_addr); // 2.2、發(fā)起請求,connect 回自動幫我們進行bind if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0) { cerr << "connect: " << strerror(errno) << endl; exit(CONN_ERR); } cout << "info: connect success: " << sock << endl; string message; while (!quit) { message.clear(); cout << "請輸入你的消息>>> "; getline(cin, message); if (strcasecmp(message.c_str(), "quit") == 0) quit = true; ssize_t s = write(sock, message.c_str(), message.size()); if (s > 0) { message.resize(1024); ssize_t s = read(sock, (char*)(message.c_str()), 1024); if (s > 0) message[s] = 0; cout << "Server Echo>>> " << message << endl; } else if (s <= 0) { break; } } return 0; }
客戶端clinetTcp.cc總代碼
clientTcp.cc文件的內(nèi)部主要框架邏輯如下:
- main函數(shù)使用命令行參數(shù):
- 客戶端創(chuàng)建套接字
- 連接過程
總代碼如下:
#include "utli.hpp" volatile bool quit = false; static void Usage(string proc) { cerr << "Usage:\n\t" << proc << "serverIp serverPort" << endl; cerr << "Example:\n\t" << proc << "127.0.0.1 8080\n" << endl; } // ./clientTcp serverIp serverPort int main(int argc, char *argv[]) { if (argc != 3) { Usage(argv[0]); exit(USAGE_ERR); } string serverIp = argv[1]; uint16_t serverPort = atoi(argv[2]); // 1、創(chuàng)建socket int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { cerr << "socket: " << strerror(errno) << endl; exit(SOCKET_ERR); } // 2、connect, 向服務(wù)器發(fā)起連接請求 // 2.1、先填充需要連接的遠端主機的基本信息 struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverPort); inet_aton(serverIp.c_str(), &server.sin_addr); // 2.2、發(fā)起請求,connect 回自動幫我們進行bind if (connect(sock, (const struct sockaddr *)&server, sizeof(server)) != 0) { cerr << "connect: " << strerror(errno) << endl; exit(CONN_ERR); } cout << "info: connect success: " << sock << endl; string message; while (!quit) { message.clear(); cout << "請輸入你的消息>>> "; getline(cin, message); if (strcasecmp(message.c_str(), "quit") == 0) quit = true; ssize_t s = write(sock, message.c_str(), message.size()); if (s > 0) { message.resize(1024); ssize_t s = read(sock, (char*)(message.c_str()), 1024); if (s > 0) message[s] = 0; cout << "Server Echo>>> " << message << endl; } else if (s <= 0) { break; } } close(sock); return 0; }
1.3、服務(wù)器測試
現(xiàn)在服務(wù)端和客戶端均已寫好,先運行服務(wù)端,再運行客戶端。我們使用如下指令輔助我們觀察現(xiàn)象:
[xzy@ecs-333953 tcp]$ sudo netstat -ntp | grep -E 'serverTcp|clientTcp'
如上我服務(wù)器的端口是8081,它已經(jīng)和端口43914的客戶端相互建立起了連接:
現(xiàn)在就可以讓客戶端向服務(wù)端發(fā)送消息了,當客戶端向服務(wù)端發(fā)送消息后,服務(wù)端可以通過打印的IP地址和端口號識別出對應(yīng)的客戶端,而客戶端也可以通過服務(wù)端響應(yīng)回來的消息來判斷服務(wù)端是否收到了自己發(fā)送的消息。
當我客戶端發(fā)送quit退出動作時,服務(wù)端識別后,確認客戶端退出,并關(guān)閉對應(yīng)的socket。如果我強制ctrl -c退出客戶端,OS會自動幫我們關(guān)掉對應(yīng)的文件描述符,此時服務(wù)端也就知道客戶端退出了,進而會終止對客戶端的服務(wù)。
1.4、單執(zhí)行流服務(wù)器的問題
當我們僅用一個客戶端連接服務(wù)器時,這一個客戶端能夠正常享受到服務(wù)端的服務(wù):
但當此客戶端1正常享受服務(wù)端的服務(wù)時,我們讓另一個客戶端2也連接此服務(wù)器, 此時發(fā)現(xiàn)兩個客戶端都是可以正常連接的,但是客戶端2發(fā)給服務(wù)端的消息并沒有在服務(wù)端進行打印,服務(wù)端也沒有將該數(shù)據(jù)回顯給客戶端2。相反我客戶端1和服務(wù)端是能夠正常通信的:
但是當客戶端1退出后,服務(wù)端才將客戶端2發(fā)來的數(shù)據(jù)進行打印,并回顯到客戶端2上:
單進程的服務(wù)器
- 通過實驗現(xiàn)象可以看到,這服務(wù)端只有服務(wù)完一個客戶端后才會服務(wù)另一個客戶端。因為我們目前所寫的是一個單執(zhí)行流版的服務(wù)器,這個服務(wù)器一次只能為一個客戶端提供服務(wù),一旦進入transService函數(shù),主執(zhí)行流就無法進行向后執(zhí)行,只能提供完畢服務(wù)之后才能進行accept。
- 當服務(wù)端調(diào)用accept函數(shù)獲取到連接后就給該客戶端提供服務(wù),但在服務(wù)端提供服務(wù)期間可能會有其他客戶端發(fā)起連接請求,但由于當前服務(wù)器是單執(zhí)行流的,只能服務(wù)完當前客戶端后才能繼續(xù)服務(wù)下一個客戶端。
?解決辦法
- ?單執(zhí)行流的服務(wù)器一次只能給一個客戶端提供服務(wù),此時服務(wù)器的資源并沒有得到充分利用,因此服務(wù)器一般是不會寫成單執(zhí)行流的。要解決這個問題就需要將服務(wù)器改為多執(zhí)行流的,此時就要引入多進程或多線程。
2、多進程版的TCP網(wǎng)絡(luò)程序
- 當服務(wù)端調(diào)用accept函數(shù)獲取到新連接后不是由當前執(zhí)行流為該連接提供服務(wù),而是當前執(zhí)行流調(diào)用fork函數(shù)創(chuàng)建子進程,然后讓子進程為父進程獲取到的連接提供服務(wù)。
- 由于父子進程是兩個不同的執(zhí)行流,當父進程調(diào)用fork創(chuàng)建出子進程后,父進程就可以繼續(xù)從監(jiān)聽套接字當中獲取新連接,而不用關(guān)心獲取上來的連接是否服務(wù)完畢
- 父進程創(chuàng)建的子進程會繼承父進程的套接字文件,此時子進程就能夠?qū)μ囟ǖ奶捉幼治募M行讀寫操作,進而完成對對應(yīng)客戶端的服務(wù)。
等待子進程問題
當父進程創(chuàng)建出子進程后,父進程是需要等待子進程退出的,否則子進程會變成僵尸進程,進而造成內(nèi)存泄漏。因此服務(wù)端創(chuàng)建子進程后需要調(diào)用wait或waitpid函數(shù)對子進程進行等待。
阻塞式等待與非阻塞式等待:
- 如果服務(wù)端采用阻塞的方式等待子進程,那么服務(wù)端還是需要等待服務(wù)完當前客戶端,才能繼續(xù)獲取下一個連接請求,此時服務(wù)端仍然是以一種串行的方式為客戶端提供服務(wù)。
- 如果服務(wù)端采用非阻塞的方式等待子進程,雖然在子進程為客戶端提供服務(wù)期間服務(wù)端可以繼續(xù)獲取新連接,但此時服務(wù)端就需要將所有子進程的PID保存下來,并且需要不斷花費時間檢測子進程是否退出。
總之,服務(wù)端要等待子進程退出,無論采用阻塞式等待還是非阻塞式等待,都不盡人意。此時我們可以考慮讓服務(wù)端不等待子進程退出。不等待子進程退出的方式如下:
- 捕捉SIGCHLD信號,將其處理動作設(shè)置為忽略。
- 讓父進程創(chuàng)建子進程,子進程再創(chuàng)建孫子進程,最后讓孫子進程為客戶端提供服務(wù)。
捕捉SIGCHLD信號
實際當子進程退出時會給父進程發(fā)送SIGCHLD信號,如果父進程將SIGCHLD信號進行捕捉,并將該信號的處理動作設(shè)置為忽略,此時父進程就只需專心處理自己的工作,不必關(guān)心子進程了。
class ServerTcp { public: // 構(gòu)造 + 析構(gòu) public: // 初始化 // 啟動服務(wù)端 void loop() { signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信號 while (true) { // 4、獲取連接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 獲取連接失敗 continue; } // 4.1、獲取客戶端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服務(wù),echo ( 小寫 -> 大寫 ) // 5.0 v0版本 —— 單進程 // transService(serviceSock, peerIp, peerPort); // 5.1 v1版本 —— 多進程 pid_t id = fork(); assert(id != -1); if (id == 0) { close(listensock_); // 建議關(guān)掉 // 子進程 transService(serviceSock, peerIp, peerPort); exit(0); // 子進程退出進入僵尸 } // 父進程 close(serviceSock); // 一定要做 } } private: int listensock_; // 監(jiān)聽套接字socket uint16_t port_; // port string ip_; // ip };
測試結(jié)果:
我們使用如下的監(jiān)控腳本輔助我們觀察現(xiàn)象:
[xzy@ecs-333953 tcp]$ ps -axj | head -1 && ps axj | grep serverTcp
- 當我們讓客戶端1連接服務(wù)器后,服務(wù)器進程會調(diào)用fork函數(shù)創(chuàng)建出一個子進程并提供服務(wù);當我們讓客戶端2連接服務(wù)器后,服務(wù)器進程同樣會調(diào)用fork函數(shù)創(chuàng)建出一個子進程并提供服務(wù)。所以我們會看到3個進程在運行的狀態(tài):
- 如下我們還應(yīng)該看到客戶端1和客戶端2各自向服務(wù)端發(fā)送信息,且都能正常收到服務(wù)端的回復(fù)。
現(xiàn)在我們讓客戶端一個一個退出,并用如下的監(jiān)控腳本觀察進程數(shù)量的變化:
[xzy@ecs-333953 tcp]$ while :; do ps -axj | head -1 && ps axj | grep serverTcp ; sleep 1 ;done
當客戶端一個一個推出后,服務(wù)端為之提供的子進程也會相機退出,單無論如何服務(wù)端都至少會有一個服務(wù)進程,此進程的任務(wù)就是不斷獲取新連接。
讓孫子進程提供服務(wù)
我們可以讓服務(wù)端沖斷爺爺進程,服務(wù)端創(chuàng)建的子進程(爸爸進程)繼續(xù)fork創(chuàng)建子進程(孫子進程),讓孫子進程為客戶端提供服務(wù),過程如下:
- 爺爺進程:在服務(wù)端調(diào)用accept函數(shù)獲取客戶端連接請求的進程。
- 爸爸進程:由爺爺進程調(diào)用fork函數(shù)創(chuàng)建出來的進程。
- 孫子進程:由爸爸進程調(diào)用fork函數(shù)創(chuàng)建出來的進程,該進程調(diào)用Service函數(shù)為客戶端提供服務(wù)。
不需要等待孫子進程退出
- 我們讓爸爸進程創(chuàng)建完孫子進程后立刻退出,此時服務(wù)進程(爺爺進程)調(diào)用wait/waitpid函數(shù)等待爸爸進程就能立刻等待成功,此后服務(wù)進程就能繼續(xù)調(diào)用accept函數(shù)獲取其他客戶端的連接請求。
- 而由于爸爸進程創(chuàng)建完孫子進程后就立刻退出了,因此實際為客戶端提供服務(wù)的孫子進程就變成了孤兒進程,該進程就會被系統(tǒng)領(lǐng)養(yǎng),當孫子進程為客戶端提供完服務(wù)退出后系統(tǒng)會回收孫子進程,所以服務(wù)進程(爺爺進程)是不需要等待孫子進程退出的。
關(guān)閉對應(yīng)的文件描述符
- 對于服務(wù)進程來說,當它調(diào)用fork函數(shù)后就必須將從accept函數(shù)獲取的文件描述符關(guān)掉。因為服務(wù)進程會不斷調(diào)用accept函數(shù)獲取新的文件描述符(服務(wù)套接字),如果服務(wù)進程不及時關(guān)掉不用的文件描述符,最終服務(wù)進程中可用的文件描述符就會越來越少。
- 而對于爸爸進程和孫子進程來說,還是建議關(guān)閉從服務(wù)進程繼承下來的監(jiān)聽套接字。實際就算它們不關(guān)閉監(jiān)聽套接字,最終也只會導(dǎo)致這一個文件描述符泄漏,但一般還是建議關(guān)上。因為孫子進程在提供服務(wù)時可能會對監(jiān)聽套接字進行某種誤操作,此時就會對監(jiān)聽套接字當中的數(shù)據(jù)造成影響。
代碼如下:
class ServerTcp { public: // 構(gòu)造 + 析構(gòu) public: // 初始化 // 啟動服務(wù)端 void loop() { signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信號 while (true) { // 4、獲取連接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 獲取連接失敗 continue; } // 4.1、獲取客戶端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服務(wù),echo ( 小寫 -> 大寫 ) // 5.0 v0版本 —— 單進程 // transService(serviceSock, peerIp, peerPort); // 5.1 v1版本 —— 多進程 ———— 捕捉SIGCHLD信號 // 5.1 v1.1版本 —— 多進程 ———— 讓孫子進程提供服務(wù) // 爺爺進程 pid_t id = fork(); if (id == 0) { // 爸爸進程 close(listensock_); // 建議關(guān)掉 if (fork() > 0) // 又進行了一次fork,讓爸爸進程直接終止 exit(0); // 孫子進程 ———— 沒有爸爸 ———— 孤兒進程 ———— 被系統(tǒng)領(lǐng)養(yǎng) ———— 回收問題就交給了系統(tǒng)來回收 transService(serviceSock, peerIp, peerPort); exit(0); } close(serviceSock); // 一定要做 // 爸爸進程直接終止,立馬得到退出碼,釋放僵尸狀態(tài) pid_t ret = waitpid(id, nullptr, 0); // 就用阻塞式 assert(ret > 0); (void)ret; } } private: int listensock_; // 監(jiān)聽套接字socket uint16_t port_; // port string ip_; // ip };
測試結(jié)果:
3、多線程版的TCP網(wǎng)絡(luò)程序
- 創(chuàng)建進程的成本是很高的,創(chuàng)建進程時需要創(chuàng)建該進程對應(yīng)的進程控制塊(task_struct)、進程地址空間(mm_struct)、頁表等數(shù)據(jù)結(jié)構(gòu)。而創(chuàng)建線程的成本比創(chuàng)建進程的成本會小得多,因為線程本質(zhì)是在進程地址空間內(nèi)運行,創(chuàng)建出來的線程會共享該進程的大部分資源,因此在實現(xiàn)多執(zhí)行流的服務(wù)器時最好采用多線程進行實現(xiàn)。
當服務(wù)進程調(diào)用accept函數(shù)獲取到一個新連接后,就可以直接創(chuàng)建一個線程,讓該線程為對應(yīng)客戶端提供服務(wù)。
- 當然,主線程(服務(wù)進程)創(chuàng)建出新線程后,也是需要等待新線程退出的,否則也會造成類似于僵尸進程這樣的問題。但對于線程來說,如果不想讓主線程等待新線程退出,可以讓創(chuàng)建出來的新線程調(diào)用pthread_detach函數(shù)進行線程分離,當這個線程退出時系統(tǒng)會自動回收該線程所對應(yīng)的資源。此時主線程(服務(wù)進程)就可以繼續(xù)調(diào)用accept函數(shù)獲取新連接,而讓新線程去服務(wù)對應(yīng)的客戶端。
文件描述符關(guān)閉的問題:
由于此時所有線程看到的都是同一張文件描述符表,因此當某個線程要對這張文件描述符表做某種操作時,不僅要考慮當前線程,還要考慮其他線程。
- 對于主線程accept上來的文件描述符,主線程不能對其進行關(guān)閉操作,該文件描述符的關(guān)閉操作應(yīng)該又新線程來執(zhí)行。因為是新線程為客戶端提供服務(wù)的,只有當新線程為客戶端提供的服務(wù)結(jié)束后才能將該文件描述符關(guān)閉。
- 對于監(jiān)聽套接字,雖然創(chuàng)建出來的新線程不必關(guān)心監(jiān)聽套接字,但新線程不能將監(jiān)聽套接字對應(yīng)的文件描述符關(guān)閉,否則主線程就無法從監(jiān)聽套接字當中獲取新連接了。
代碼邏輯如下:
- 我們使用pthread_create創(chuàng)建線程,讓新線程內(nèi)部執(zhí)行為客戶端提供服務(wù)transService的操作。所以我們需要在線程執(zhí)行函數(shù)threadRoutine里傳入客戶端ip,port,sock。
- 為了能夠讓線程執(zhí)行函數(shù)threadRoutine獲得ip,port,sock這三個參數(shù),我們在pthread_create的最后一個參數(shù)傳入一個ThreadData結(jié)構(gòu)體,該結(jié)構(gòu)體內(nèi)部包含了這三個參數(shù)
- 注意我線程函數(shù)是在ServerTcp類內(nèi)部的成員函數(shù),而成員函數(shù)有默認的this指針,為了避免pthread_create傳參出錯,我們需要給線程執(zhí)行函數(shù)threadRoutine設(shè)置為static靜態(tài)成員函數(shù)。
- 一旦設(shè)置了靜態(tài)函數(shù),也就意味著此線程執(zhí)行函數(shù)內(nèi)部無法直接訪問ServerTcp類的提供服務(wù)transService函數(shù)。為了避免這一現(xiàn)象的產(chǎn)生,我們對ThreadData結(jié)構(gòu)體內(nèi)部多定義一個this_變量,將來在ServerTcp類創(chuàng)建ThreadData結(jié)構(gòu)體指針時,給最后一個參數(shù)傳入ServerTcp類的this指針。這樣我將來在線程執(zhí)行函數(shù)內(nèi)部就可以通過此ThreadData結(jié)構(gòu)體指針訪問this_成員變量,再通過this_成員變量訪問ServerTcp類的成員函數(shù)transService。即可完成線程為對應(yīng)客戶端提供服務(wù)。
class ServerTcp; // 聲明一下 class ThreadData { public: ThreadData(uint16_t port, string ip, int sock, ServerTcp *ts) : clientport_(port), clientip_(ip), sock_(sock), this_(ts) { } public: uint16_t clientport_; string clientip_; int sock_; ServerTcp *this_; }; class ServerTcp { public: // 構(gòu)造 + 析構(gòu) public: // 初始化 // 線程執(zhí)行函數(shù) static void *threadRoutine(void *args) { pthread_detach(pthread_self()); // 設(shè)置線程分離 ThreadData *td = static_cast<ThreadData *>(args); td->this_->transService(td->sock_, td->clientip_, td->clientport_); delete td; return nullptr; } // 啟動服務(wù)端 void loop() { signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信號 while (true) { // 4、獲取連接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 獲取連接失敗 continue; } // 4.1、獲取客戶端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服務(wù),echo ( 小寫 -> 大寫 ) // 5.0 v0版本 —— 單進程 // 5.1 v1版本 —— 多進程 // 5.1 v1.1版本 —— 多進程 // 5.2 v2版本 —— 多線程 // 這里不需要古納比文件描述符,因為多線程是會共享文件描述符表的 ThreadData *td = new ThreadData(peerPort, peerIp, serviceSock, this); pthread_t tid; pthread_create(&tid, nullptr, threadRoutine, (void *)td); } } private: int listensock_; // 監(jiān)聽套接字socket uint16_t port_; // port string ip_; // ip };
測試結(jié)果:
我們使用如下監(jiān)控腳本輔助我們觀察現(xiàn)象:
[xzy@ecs-333953 tcp]$ while :; do ps -aL | head -1 && ps -aL | grep serverTcp ; sleep 1 ;done
- 上述四個客戶端提供服務(wù)的也是兩個不同的執(zhí)行流,因此這四個客戶端可以同時享受服務(wù)端提供的服務(wù),它們發(fā)送給服務(wù)端的消息也都能夠在服務(wù)端進行打印,并且這四個客戶端也都能夠收到服務(wù)端的回顯數(shù)據(jù)。?
- 當客戶端一個個退出后,為其提供服務(wù)的新線程也就會相繼退出,最終就只剩下最初的主線程仍在等待新連接的到來。
4、線程池版的TCP網(wǎng)絡(luò)程序
當前多線程版本存在的問題:
- 每當有新連接到來時,服務(wù)端的主線程都會重新為該客戶端創(chuàng)建為其提供服務(wù)的新線程,而當服務(wù)結(jié)束后又會將該新線程銷毀。這樣做不僅麻煩,而且效率低下,每當連接到來的時候服務(wù)端才創(chuàng)建對應(yīng)提供服務(wù)的線程。
- 如果有大量的客戶端連接請求,此時服務(wù)端要為每一個客戶端創(chuàng)建對應(yīng)的服務(wù)線程。計算機當中的線程越多,CPU的壓力就越大,因為CPU要不斷在這些線程之間來回切換,此時CPU在調(diào)度線程的時候,線程和線程之間切換的成本就會變得很高。此外,一旦線程太多,每一個線程再次被調(diào)度的周期就變長了,而線程是為客戶端提供服務(wù)的,線程被調(diào)度的周期變長,客戶端也遲遲得不到應(yīng)答。
解決辦法:
- 可以在服務(wù)端預(yù)先創(chuàng)建一批線程,當有客戶端請求連接時就讓這些線程為客戶端提供服務(wù),此時客戶端一來就有線程為其提供服務(wù),而不是當客戶端來了才創(chuàng)建對應(yīng)的服務(wù)線程。
- 當某個線程為客戶端提供完服務(wù)后,不要讓該線程退出,而是讓該線程繼續(xù)為下一個客戶端提供服務(wù),如果當前沒有客戶端連接請求,則可以讓該線程先進入休眠狀態(tài),當有客戶端連接到來時再將該線程喚醒。
- 服務(wù)端創(chuàng)建的這一批線程的數(shù)量不能太多,此時CPU的壓力也就不會太大。此外,如果有客戶端連接到來,但此時這一批線程都在給其他客戶端提供服務(wù),這時服務(wù)端不應(yīng)該再創(chuàng)建線程,而應(yīng)該讓這個新來的連接請求在全連接隊列進行排隊,等服務(wù)端這一批線程中有空閑線程后,再將該連接請求獲取上來并為其提供服務(wù)。
線程池:
- 我們需要在服務(wù)端引入線程池,因為線程池的存在就是為了避免處理短時間任務(wù)時創(chuàng)建與銷毀線程的代價,此外,線程池還能夠保證內(nèi)核充分利用,防止過分調(diào)度。
- 其中在線程池里面有一個任務(wù)隊列,當有新的任務(wù)到來的時候,就可以將任務(wù)Push到線程池當中,在線程池當中我們默認創(chuàng)建了5個線程,這些線程不斷檢測任務(wù)隊列當中是否有任務(wù),如果有任務(wù)就拿出任務(wù),然后調(diào)用該任務(wù)對應(yīng)的Run函數(shù)對該任務(wù)進行處理,如果線程池當中沒有任務(wù)那么當前線程就會進入休眠狀態(tài)。
- 我先前已經(jīng)介紹并實現(xiàn)了線程池,這里就直接將線程池的代碼接入到當前的TCP服務(wù)器,因此下面只會講解線程池接入的方法,如果對線程池的實現(xiàn)有疑問的可以去閱讀那篇博客。
我們從先前寫的線程池取出我們需要的內(nèi)容放到此tcp目錄下:
代碼邏輯如下:
現(xiàn)在服務(wù)端引入了線程池,因此在服務(wù)類當中需要新增一個指向線程池的指針成員:
- 當實例化服務(wù)器對象時,先將這個線程池指針先初始化為空。
- 當服務(wù)器初始化完畢后,再實際構(gòu)造這個線程池對象,在構(gòu)造線程池對象時可以指定線程池當中線程的個數(shù),也可以不指定,此時默認線程的個數(shù)為5。
- 在啟動服務(wù)器之前對線程池進行初始化,此時就會將線程池當中的若干線程創(chuàng)建出來,而這些線程創(chuàng)建出來后就會不斷檢測任務(wù)隊列,從任務(wù)隊列當中拿出任務(wù)進行處理。
現(xiàn)在當服務(wù)進程調(diào)用accept函數(shù)獲取到一個連接請求后,就會根據(jù)該客戶端的套接字、IP地址以及端口號構(gòu)建出一個任務(wù),然后調(diào)用線程池提供的Push接口將該任務(wù)塞入任務(wù)隊列。
class ServerTcp { public: ServerTcp(uint16_t port, const string &ip = "") : port_(port), ip_(ip), listensock_(-1), tp_(nullptr) { } ~ServerTcp() { } public: // 初始化 void init() { // 1、創(chuàng)建socket listensock_ = socket(AF_INET, SOCK_STREAM, 0); if (listensock_ < 0) { logMessage(FATAL, "socket: %s", strerror(errno)); // 創(chuàng)建失敗,打印日志 exit(SOCKET_ERR); } logMessage(DEBUG, "socket: %s, %d", strerror(errno), listensock_); // 2、bind綁定 // 2.1、填充服務(wù)器信息 struct sockaddr_in local; // 用戶棧 memset(&local, 0, sizeof(local)); local.sin_family = PF_INET; local.sin_port = htons(port_); ip_.empty() ? (local.sin_addr.s_addr = INADDR_ANY) : (inet_aton(ip_.c_str(), &local.sin_addr)); // 2.2、將本地socket信息,寫入listensock_對應(yīng)的內(nèi)核區(qū)域 if (bind(listensock_, (const struct sockaddr *)&local, sizeof(local)) == -1) { logMessage(FATAL, "bind: %s", strerror(errno)); // 綁定失敗,打印日志 exit(BIND_ERR); } logMessage(DEBUG, "bind: %s, %d", strerror(errno), listensock_); // 3、監(jiān)聽socket if (listen(listensock_, 5) < 0) { logMessage(FATAL, "listen: %s", strerror(errno)); // 監(jiān)聽失敗,打印日志 exit(LISTEN_ERR); } logMessage(DEBUG, "listen: %s, %d", strerror(errno), listensock_); // 允許別人連接你了 // 4、加載線程池 tp_ = ThreadPool<Task>::getInstance(); } // 啟動服務(wù)端 void loop() { // 啟動線程池 tp_->start(); logMessage(DEBUG, "thread pool start success, thread num: %d", tp_->threadNum()); signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信號 while (true) { // 4、獲取連接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 獲取連接失敗 continue; } // 4.1、獲取客戶端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服務(wù),echo ( 小寫 -> 大寫 ) // 5.0 v0版本 —— 單進程 // 5.1 v1版本 —— 多進程 // 5.1 v1.1版本 —— 多進程 // 5.2 v2版本 —— 多線程 // 5.3 v3版本 —— 線程池 // 5.3.1 構(gòu)建任務(wù) // 5.3 v3.1 // Task t(serviceSock, peerIp, peerPort, std::bind(&ServerTcp::transService, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3)); // tp_->push(t); // 5.3 v3.2 Task t(serviceSock, peerIp, peerPort, transService); tp_->push(t); } } private: int listensock_; // 監(jiān)聽套接字socket uint16_t port_; // port string ip_; // ip // 引入線程池 ThreadPool<Task> *tp_; };
設(shè)計任務(wù)類
- 該任務(wù)類當中需要包含客戶端對應(yīng)的套接字、IP地址、端口號,表示該任務(wù)是為哪一個客戶端提供服務(wù),對應(yīng)操作的套接字是哪一個。
#pragma once #include <iostream> #include <string> #include <functional> #include <pthread.h> #include "log.hpp" class Task { public: using callBack_t = std::function<void(int, std::string, uint16_t)>; // 等價于typedef std::function<void (int, std::string, uint16_t)> callBack_t; private: int sock_; // 給用戶提供IO服務(wù)的sock uint16_t port_; // client port std::string ip_; // client ip callBack_t func_; // 回調(diào)方法 public: Task() : sock_(-1), port_(-1) { } Task(int sock, std::string ip, uint16_t port, callBack_t func) : sock_(sock), ip_(ip), port_(port), func_(func) { } void operator()() { logMessage(DEBUG, "線程ID[%p]處理%s:%d的請求 開始啦...", pthread_self(), ip_.c_str(), port_); func_(sock_, ip_, port_); logMessage(DEBUG, "線程ID[%p]處理%s:%d的請求 結(jié)束啦...", pthread_self(), ip_.c_str(), port_); } ~Task() { } };
代碼測試:
我們使用如下監(jiān)控腳本輔助我們觀察現(xiàn)象:
[xzy@ecs-333953 tcp]$ while :; do ps -aL | head -1 && ps -aL | grep serverTcp ; sleep 1 ;done
- 當客戶端連接服務(wù)器后,服務(wù)端的主線程就會獲取該客戶端的連接請求,并將其封裝為一個任務(wù)對象后塞入任務(wù)隊列,此時線程池中的5個線程就會有一個線程從任務(wù)隊列當中獲取到該任務(wù),并執(zhí)行該任務(wù)的處理函數(shù)為客戶端提供服務(wù)。
- 當?shù)诙€客戶端發(fā)起連接請求時,服務(wù)端也會將其封裝為一個任務(wù)類塞到任務(wù)隊列,然后線程池當中的線程再從任務(wù)隊列當中獲取到該任務(wù)進行處理,此時也是不同的執(zhí)行流為這兩個客戶端提供的服務(wù),因此這兩個客戶端也是能夠同時享受服務(wù)的。
- 與之前不同的是,無論現(xiàn)在有多少客戶端發(fā)來請求,在服務(wù)端都只會有線程池當中的5個線程為之提供服務(wù),線程池當中的線程個數(shù)不會隨著客戶端連接的增多而增多,這些線程也不會因為客戶端的退出而退出。
線程池變形
注意:
- 我們設(shè)置了對應(yīng)的任務(wù)是死循環(huán),那么線程池提供服務(wù),就顯得不太合適。一般我們給線程池拋入的任務(wù)都是短任務(wù),現(xiàn)在對代碼進行修改。
我們更新線程池的容量為15個。先來看如下這個函數(shù)(popen):
#include <stdio.h> FILE *popen(const char *command, const char *type); int pclose(FILE *stream);
popen函數(shù)介紹
- 作用:創(chuàng)建一個連接到另一個進程的管道,然后讀其輸出或向其輸入端發(fā)送數(shù)據(jù)。
- 原理:創(chuàng)建一個管道,fork一個子進程,關(guān)閉未使用的管道端(讀端或者寫端),執(zhí)行一個shell運行命令,然后等待命令終止。
參數(shù)說明:
- commmand:是一個指向以 NULL 結(jié)束的 shell 命令字符串的指針。這行命令將被傳到 bin/sh 并使用 -c 標志,shell 將執(zhí)行這個命令。
- type:只能是讀和寫的一種,如果是 “r” 則文件指針連接到command的標準輸出,則返回的文件指針是可讀的;如果是 “w” 則文件指針連接到command的標準輸入,則返回的文件指針是可寫的。
- stream:popen返回的文件指針。
代碼思想:
- 我們下面要進行的操作就是讓線程服務(wù)客戶端,更換一個服務(wù)。先前的服務(wù)是進行大小寫轉(zhuǎn)化transService,現(xiàn)在來更換一個execCommand。我們只需要改變提供服務(wù)的接口即可,代碼主邏輯不用動,完成了代碼解耦。
代碼邏輯:
- 定義command數(shù)組,復(fù)用read函數(shù)把客戶端讀到的數(shù)據(jù)輸入到此command數(shù)組里,將command當成字符串
- 復(fù)用popen函數(shù),以只讀的方式將數(shù)據(jù)輸出到文件指針fp中。
- 復(fù)用fgets函數(shù)將fp文件的內(nèi)容讀取到定義的line數(shù)組里,并復(fù)用write函數(shù)將line數(shù)組里的內(nèi)容全部寫回到sock里
void execCommand(int sock, const string &clientIp, uint16_t clientPort) { assert(socket >= 0); assert(!clientIp.empty()); assert(clientPort >= 1024); char command[BUFFER_SIZE]; while (true) { ssize_t s = read(sock, command, sizeof(command) - 1); // 我們認為讀取到的都是字符串 if (s > 0) // 讀取成功 { command[s] = '\0'; // 當成字符串 logMessage(DEBUG, "[%s:%d] exec [%s]", clientIp.c_str(), clientPort, command); // 考慮安全 string safe = command; if (string::npos != (safe.find("rm")) || string::npos != safe.find("unlink")) { break; } FILE *fp = popen(command, "r"); if (fp == nullptr) { logMessage(WARINING, "exec %s failed, because: %s", command, strerror(errno)); break; } char line[1024]; while (fgets(line, sizeof(line) - 1, fp) != nullptr) { write(sock, line, strlen(line)); } // dup2(sock, fp->_fileno); // 把本來應(yīng)該顯示到fp文件的重定向到網(wǎng)絡(luò)sock里 // fflush(fp); // 把數(shù)據(jù)刷到對端 pclose(fp); logMessage(DEBUG, "[%s:%d] exec [%s] ... done", clientIp.c_str(), clientPort, command); } else if (s == 0) // 對方關(guān)閉 { logMessage(DEBUG, "client quit -- %s[%d]", clientIp.c_str(), clientPort); break; } else // 讀取出錯 { logMessage(DEBUG, "%s[%d] - read: %s", clientIp.c_str(), clientPort, strerror(errno)); break; } } // 只要走到這里,一定是client退出了,服務(wù)到此結(jié)束 close(sock); // 如果一個進程對應(yīng)的文件fd,打開了沒有被歸還,則文件描述符泄露 logMessage(DEBUG, "server close %d done", sock); } class ServerTcp { public: // 構(gòu)造 + 析構(gòu) public: // 初始化 void init() {} // 啟動服務(wù)端 void loop() { // 啟動線程池 tp_->start(); logMessage(DEBUG, "thread pool start success, thread num: %d", tp_->threadNum()); signal(SIGCHLD, SIG_IGN); // 忽略SIGCHLD信號 while (true) { // 4、獲取連接 struct sockaddr_in peer; socklen_t len = sizeof(peer); int serviceSock = accept(listensock_, (struct sockaddr *)&peer, &len); if (serviceSock < 0) { logMessage(WARINING, "accept: %s[%d]", strerror(errno), serviceSock); // 獲取連接失敗 continue; } // 4.1、獲取客戶端基本信息 uint16_t peerPort = ntohs(peer.sin_port); string peerIp = inet_ntoa(peer.sin_addr); logMessage(DEBUG, "accept: %s | %s:[%d], socket fd: %d", strerror(errno), peerIp.c_str(), peerPort, serviceSock); // 5、提供服務(wù),echo ( 小寫 -> 大寫 ) // 5.0 v0版本 —— 單進程 // transService(serviceSock, peerIp, peerPort); // 5.1 v1版本 —— 多進程 // 5.1 v1.1版本 —— 多進程 // 5.2 v2版本 —— 多線程 // 5.3 v3版本 —— 線程池 // 5.3.1 構(gòu)建任務(wù) // 5.3 v3.1 // 5.3 v3.2 // Task t(serviceSock, peerIp, peerPort, transService); // tp_->push(t); // 5.3 v3.3 Task t(serviceSock, peerIp, peerPort, execCommand); tp_->push(t); } } private: int listensock_; // 監(jiān)聽套接字socket uint16_t port_; // port string ip_; // ip // 引入線程池 ThreadPool<Task> *tp_; };
測試結(jié)果:
- 我們看到的現(xiàn)象是當客戶端連接服務(wù)器后,輸入ls,pwd等指令時,服務(wù)端提供服務(wù)將結(jié)果寫回到客戶端:
文章來源:http://www.zghlxwxcb.cn/news/detail-798796.html
5、總代碼gitee鏈接
本篇博文所有設(shè)計的代碼鏈接如下:文章來源地址http://www.zghlxwxcb.cn/news/detail-798796.html
- gitee傳送門:TCP套接字源碼
到了這里,關(guān)于網(wǎng)絡(luò)編程套接字( TCP )的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!