一.簡單的TCP網(wǎng)絡(luò)程序
1.服務(wù)端創(chuàng)建套接字
我們將TCP服務(wù)器封裝成一個類,當我們定義出一個服務(wù)器對象后需要馬上對服務(wù)器進行初始化,而初始化TCP服務(wù)器要做的第一件事就是創(chuàng)建套接字。
TCP服務(wù)器在調(diào)用socket函數(shù)創(chuàng)建套接字時,參數(shù)設(shè)置如下:
- 協(xié)議家族選擇
AF_INET
,因為我們要進行的是網(wǎng)絡(luò)通信。 - 創(chuàng)建套接字時所需的服務(wù)類型應(yīng)該是
SOCK_STREAM
,因為我們編寫的是TCP服務(wù)器,SOCK_STREAM
提供的就是一個有序的、可靠的、全雙工的、基于連接的流式服務(wù)。 - 協(xié)議類型默認設(shè)置為0即可。
如果創(chuàng)建套接字后獲得的文件描述符是小于0的,說明套接字創(chuàng)建失敗,此時也就沒必要進行后續(xù)操作了,直接終止程序即可。
class TcpServer
{
public:
void InitServer()
{
//創(chuàng)建套接字
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
}
~TcpServer()
{
if (_sock >= 0){
close(_sock);
}
}
private:
int _sock; //套接字
};
說明一下:
- 實際TCP服務(wù)器創(chuàng)建套接字的做法與UDP服務(wù)器是一樣的,只不過創(chuàng)建套接字時TCP需要的是流式服務(wù),而UDP需要的是用戶數(shù)據(jù)報服務(wù)。
- 當析構(gòu)服務(wù)器時,可以將服務(wù)器對應(yīng)的文件描述符進行關(guān)閉。
2.服務(wù)端綁定
套接字創(chuàng)建完畢后我們實際只是在系統(tǒng)層面上打開了一個文件,該文件還沒有與網(wǎng)絡(luò)關(guān)聯(lián)起來,因此創(chuàng)建完套接字后我們還需要調(diào)用bind函數(shù)進行綁定操作。
綁定的步驟如下:
- 定義一個
struct sockaddr_in
結(jié)構(gòu)體,將服務(wù)器網(wǎng)絡(luò)相關(guān)的屬性信息填充到該結(jié)構(gòu)體當中,比如協(xié)議家族、IP地址、端口號等。 - 填充服務(wù)器網(wǎng)絡(luò)相關(guān)的屬性信息時,協(xié)議家族對應(yīng)就是
AF_INET
,端口號就是當前TCP服務(wù)器程序的端口號。在設(shè)置端口號時,需要調(diào)用htons函數(shù)將端口號由主機序列轉(zhuǎn)為網(wǎng)絡(luò)序列。 - 在設(shè)置服務(wù)器的IP地址時,我們可以設(shè)置為本地環(huán)回
127.0.0.1
,表示本地通信。也可以設(shè)置為公網(wǎng)IP地址,表示網(wǎng)絡(luò)通信。 - 如果使用的是云服務(wù)器,那么在設(shè)置服務(wù)器的IP地址時,不需要顯示綁定IP地址,直接將IP地址設(shè)置為
INADDR_ANY
即可,此時服務(wù)器就可以從本地任何一張網(wǎng)卡當中讀取數(shù)據(jù)。此外,由于INADDR_ANY
本質(zhì)就是0,因此在設(shè)置時不需要進行網(wǎng)絡(luò)字節(jié)序的轉(zhuǎn)換。 - 填充完服務(wù)器網(wǎng)絡(luò)相關(guān)的屬性信息后,需要調(diào)用bind函數(shù)進行綁定。綁定實際就是將文件與網(wǎng)絡(luò)關(guān)聯(lián)起來,如果綁定失敗也沒必要進行后續(xù)操作了,直接終止程序即可。
由于TCP服務(wù)器初始化時需要服務(wù)器的端口號,因此在服務(wù)器類當中需要引入端口號,當實例化服務(wù)器對象時就需要給傳入一個端口號。而由于我當前使用的是云服務(wù)器,因此在綁定TCP服務(wù)器的IP地址時不需要綁定公網(wǎng)IP地址,直接綁定INADDR_ANY
即可,因此我這里沒有在服務(wù)器類當中引入IP地址。
class TcpServer
{
public:
TcpServer(int port)
: _sock(-1)
, _port(port)
{}
void InitServer()
{
//創(chuàng)建套接字
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//綁定
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(_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
}
~TcpServer()
{
if (_sock >= 0){
close(_sock);
}
}
private:
int _sock; //監(jiān)聽套接字
int _port; //端口號
};
當定義好struct sockaddr_in
結(jié)構(gòu)體后,最好先用memset函數(shù)對該結(jié)構(gòu)體進行清空,也可以用bzero函數(shù)進行清空。bzero函數(shù)也可以對特定的一塊內(nèi)存區(qū)域進行清空,bzero函數(shù)的函數(shù)原型如下:
void bzero(void *s, size_t n);
說明一下:
- TCP服務(wù)器綁定時的步驟與UDP服務(wù)器是完全一樣的,沒有任何區(qū)別。
3.服務(wù)端監(jiān)聽
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)。
listen函數(shù)
設(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è)置。
服務(wù)器監(jiān)聽
TCP服務(wù)器在創(chuàng)建完套接字和綁定后,需要再進一步將套接字設(shè)置為監(jiān)聽狀態(tài),監(jiān)聽是否有新的連接到來。如果監(jiān)聽失敗也沒必要進行后續(xù)操作了,因為監(jiān)聽失敗也就意味著TCP服務(wù)器無法接收客戶端發(fā)來的連接請求,因此監(jiān)聽失敗我們直接終止程序即可。
#define BACKLOG 5
class TcpServer
{
public:
void InitServer()
{
//創(chuàng)建套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//綁定
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){
std::cerr << "bind error" << std::endl;
exit(3);
}
//監(jiān)聽
if (listen(_listen_sock, BACKLOG) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
private:
int _listen_sock; //監(jiān)聽套接字
int _port; //端口號
};
說明一下:
- 初始化TCP服務(wù)器時創(chuàng)建的套接字并不是普通的套接字,而應(yīng)該叫做監(jiān)聽套接字。為了表明寓意,我們將代碼中套接字的名字由sock改為_listen_sock 。
- 在初始化TCP服務(wù)器時,只有創(chuàng)建套接字成功、綁定成功、監(jiān)聽成功,此時TCP服務(wù)器的初始化才算完成。
4.服務(wù)端獲取連接
TCP服務(wù)器初始化后就可以開始運行了,但TCP服務(wù)器在與客戶端進行網(wǎng)絡(luò)通信之前,服務(wù)器需要先獲取到客戶端的連接請求。
accept函數(shù)
獲取連接的函數(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è)置。
accept函數(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ù)。監(jiān)聽套接字的任務(wù)只是不斷獲取新連接,而真正為這些連接提供服務(wù)的套接字是accept函數(shù)返回的套接字,而不是監(jiān)聽套接字。
服務(wù)端獲取連接
服務(wù)端在獲取連接時需要注意:
- accept函數(shù)獲取連接時可能會失敗,但TCP服務(wù)器不會因為獲取某個連接失敗而退出,因此服務(wù)端獲取連接失敗后應(yīng)該繼續(xù)獲取連接。
- 如果要將獲取到的連接對應(yīng)客戶端的IP地址和端口號信息進行輸出,需要調(diào)用inet_ntoa函數(shù)將整數(shù)IP轉(zhuǎn)換成字符串IP,調(diào)用ntohs函數(shù)將端口號由網(wǎng)絡(luò)序列轉(zhuǎn)換成主機序列。
- inet_ntoa函數(shù)在底層實際做了兩個工作,一是將網(wǎng)絡(luò)序列轉(zhuǎn)換成主機序列,二是將主機序列的整數(shù)IP轉(zhuǎn)換成字符串風(fēng)格的點分十進制的IP。
class TcpServer
{
public:
void Start()
{
for (;;){
//獲取連接
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout<<"get a new link->"<<sock<<" ["<<client_ip<<"]:"<<client_port<<std::endl;
}
}
private:
int _listen_sock; //監(jiān)聽套接字
int _port; //端口號
};
服務(wù)端接收連接測試
現(xiàn)在我們可以做一下簡單的測試,看看當前服務(wù)器能否成功接收請求連接。在運行服務(wù)端時需要傳入一個端口號作為服務(wù)端的端口號,然后我們用該端口號構(gòu)造一個服務(wù)端對象,對服務(wù)端進行初始化后啟動服務(wù)端即可。
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2){
Usage(argv[0]);
exit(1);
}
int port = atoi(argv[1]);
TcpServer* svr = new TcpServer(port);
svr->InitServer();
svr->Start();
return 0;
}
編譯代碼后,以./tcp_server 端口號
的方式運行服務(wù)端。
服務(wù)端運行后,通過netstat
命令可以查看到一個程序名為tcp_server的服務(wù)程序,它綁定的端口就是8081,而由于服務(wù)器綁定的是INADDR_ANY
,因此該服務(wù)器的本地IP地址是0.0.0.0
,這就意味著該TCP服務(wù)器可以讀取本地任何一張網(wǎng)卡里面的數(shù)據(jù)。此外,最重要的是當前該服務(wù)器所處的狀態(tài)是LISTEN
狀態(tài),表明當前服務(wù)器可以接收外部的請求連接。
雖然現(xiàn)在還沒有編寫客戶端相關(guān)的代碼,但是我們可以使用telnet
命令遠程登錄到該服務(wù)器,因為telnet
底層實際采用的就是TCP協(xié)議。
使用telnet
命令連接當前TCP服務(wù)器后可以看到,此時服務(wù)器接收到了一個連接,為該連接提供服務(wù)的套接字對應(yīng)的文件描述符就是4。因為0、1、2是默認打開的,其分別對應(yīng)標準輸入流、標準輸出流和標準錯誤流,而3號文件描述符在初始化服務(wù)器時分配給了監(jiān)聽套接字,因此當?shù)谝粋€客戶端發(fā)起連接請求時,為該客戶端提供服務(wù)的套接字對應(yīng)的文件描述符就是4。
如果此時我們再用其他窗口繼續(xù)使用telnet
命令,向該TCP服務(wù)器發(fā)起請求連接,此時為該客戶端提供服務(wù)的套接字對應(yīng)的文件描述符就是5。
當然,也可以直接用瀏覽器來訪問這個TCP服務(wù)器,因為瀏覽器常見的應(yīng)用層協(xié)議是http或https,其底層對應(yīng)的也是TCP協(xié)議,因此瀏覽器也可以向當前這個TCP服務(wù)器發(fā)起請求連接。
說明一下:
- 至于這里為什么瀏覽器一次會向我們的TCP服務(wù)器發(fā)起兩次請求這個問題,這里就不作討論了,我們只是要證明當前TCP服務(wù)器能夠正常接收外部的請求連接。
5.服務(wù)端處理請求
現(xiàn)在TCP服務(wù)器已經(jīng)能夠獲取連接請求了,下面當然就是要對獲取到的連接進行處理。但此時為客戶端提供服務(wù)的不是監(jiān)聽套接字,因為監(jiān)聽套接字獲取到一個連接后會繼續(xù)獲取下一個請求連接,為對應(yīng)客戶端提供服務(wù)的套接字實際是accept函數(shù)返回的套接字,下面就將其稱為“服務(wù)套接字”。
為了讓通信雙方都能看到對應(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ù)端和客戶端能夠正常通信了。
read函數(shù)
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函數(shù)
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ù)端處理請求
需要注意的是,服務(wù)端讀取數(shù)據(jù)是服務(wù)套接字中讀取的,而寫入數(shù)據(jù)的時候也是寫入進服務(wù)套接字的。也就是說這里為客戶端提供服務(wù)的套接字,既可以讀取數(shù)據(jù)也可以寫入數(shù)據(jù),這就是TCP全雙工的通信的體現(xiàn)。
在從服務(wù)套接字中讀取客戶端發(fā)來的數(shù)據(jù)時,如果調(diào)用read函數(shù)后得到的返回值為0,或者讀取出錯了,此時就應(yīng)該直接將服務(wù)套接字對應(yīng)的文件描述符關(guān)閉。因為文件描述符本質(zhì)就是數(shù)組的下標,因此文件描述符的資源是有限的,如果我們一直占用,那么可用的文件描述符就會越來越少,因此服務(wù)完客戶端后要及時關(guān)閉對應(yīng)的文件描述符,否則會導(dǎo)致文件描述符泄漏。
class TcpServer
{
public:
void Service(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (true){
ssize_t size = read(sock, buffer, sizeof(buffer)-1);
if (size > 0){ //讀取成功
buffer[size] = '\0';
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
write(sock, buffer, size);
}
else if (size == 0){ //對端關(guān)閉連接
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else{ //讀取失敗
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock); //歸還文件描述符
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
void Start()
{
for (;;){
//獲取連接
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link [" << client_ip << "]:" << client_port << std::endl;
//處理請求
Service(sock, client_ip, client_port);
}
}
private:
int _listen_sock; //監(jiān)聽套接字
int _port; //端口號
};
6.客戶端創(chuàng)建套接字
同樣的,我們將客戶端也封裝成一個類,當我們定義出一個客戶端對象后也需要對其進行初始化,而初始化客戶端唯一需要做的就是創(chuàng)建套接字。而客戶端在調(diào)用socket函數(shù)創(chuàng)建套接字時,參數(shù)設(shè)置與服務(wù)端創(chuàng)建套接字時是一樣的。
客戶端不需要進行綁定和監(jiān)聽:
- 服務(wù)端要進行綁定是因為服務(wù)端的IP地址和端口號必須要眾所周知,不能隨意改變。而客戶端雖然也需要IP地址和端口號,但是客戶端并不需要我們進行綁定操作,客戶端連接服務(wù)端時系統(tǒng)會自動指定一個端口號給客戶端。
- 服務(wù)端需要進行監(jiān)聽是因為服務(wù)端需要通過監(jiān)聽來獲取新連接,但是不會有人主動連接客戶端,因此客戶端是不需要進行監(jiān)聽操作的。
此外,客戶端必須要知道它要連接的服務(wù)端的IP地址和端口號,因此客戶端除了要有自己的套接字之外,還需要知道服務(wù)端的IP地址和端口號,這樣客戶端才能夠通過套接字向指定服務(wù)器進行通信。
class TcpClient
{
public:
TcpClient(std::string server_ip, int server_port)
: _sock(-1)
, _server_ip(server_ip)
, _server_port(server_port)
{}
void InitClient()
{
//創(chuàng)建套接字
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
}
~TcpClient()
{
if (_sock >= 0){
close(_sock);
}
}
private:
int _sock; //套接字
std::string _server_ip; //服務(wù)端IP地址
int _server_port; //服務(wù)端端口號
};
7.客戶端連接服務(wù)器
由于客戶端不需要綁定,也不需要監(jiān)聽,因此當客戶端創(chuàng)建完套接字后就可以向服務(wù)端發(fā)起連接請求。
connect函數(shù)
發(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è)置。
客戶端連接服務(wù)器
需要注意的是,客戶端不是不需要進行綁定,而是不需要我們自己進行綁定操作,當客戶端向服務(wù)端發(fā)起連接請求時,系統(tǒng)會給客戶端隨機指定一個端口號進行綁定。因為通信雙方都必須要有IP地址和端口號,否則無法唯一標識通信雙方。也就是說,如果connect函數(shù)調(diào)用成功了,客戶端本地會隨機給該客戶端綁定一個端口號發(fā)送給對端服務(wù)器。
此外,調(diào)用connect函數(shù)向服務(wù)端發(fā)起連接請求時,需要傳入服務(wù)端對應(yīng)的網(wǎng)絡(luò)信息,否則connect函數(shù)也不知道該客戶端到底是要向哪一個服務(wù)端發(fā)起連接請求。
class TcpClient
{
public:
void Start()
{
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(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){ //connect success
std::cout << "connect success..." << std::endl;
Request(); //發(fā)起請求
}
else{ //connect error
std::cerr << "connect failed..." << std::endl;
exit(3);
}
}
private:
int _sock; //套接字
std::string _server_ip; //服務(wù)端IP地址
int _server_port; //服務(wù)端端口號
};
8.客戶端發(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ù)進行打印,以確定雙方通信無誤。
class TcpClient
{
public:
void Request()
{
std::string msg;
char buffer[1024];
while (true){
std::cout << "Please Enter# ";
getline(std::cin, msg);
write(_sock, msg.c_str(), msg.size());
ssize_t size = read(_sock, buffer, sizeof(buffer)-1);
if (size > 0){
buffer[size] = '\0';
std::cout << "server echo# " << buffer << std::endl;
}
else if (size == 0){
std::cout << "server close!" << std::endl;
break;
}
else{
std::cerr << "read error!" << std::endl;
break;
}
}
}
void Start()
{
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(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){ //connect success
std::cout << "connect success..." << std::endl;
Request(); //發(fā)起請求
}
else{ //connect error
std::cerr << "connect failed..." << std::endl;
exit(3);
}
}
private:
int _sock; //套接字
std::string _server_ip; //服務(wù)端IP地址
int _server_port; //服務(wù)端端口號
};
在運行客戶端程序時我們就需要攜帶上服務(wù)端對應(yīng)的IP地址和端口號,然后我們就可以通過服務(wù)端的IP地址和端口號構(gòu)造出一個客戶端對象,對客戶端進行初始后啟動客戶端即可。
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << "server_ip server_port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 3){
Usage(argv[0]);
exit(1);
}
std::string server_ip = argv[1];
int server_port = atoi(argv[2]);
TcpClient* clt = new TcpClient(server_ip, server_port);
clt->InitClient();
clt->Start();
return 0;
}
9.服務(wù)器測試
現(xiàn)在服務(wù)端和客戶端均已編寫完畢,下面我們進行測試。測試時我們先啟動服務(wù)端,然后通過netstat
命令進行查看,此時我們就能看到一個名為tcp_server的服務(wù)進程,該進程當前處于監(jiān)聽狀態(tài)。
然后再通過./tcp_client IP地址 端口號
的形式運行客戶端,此時客戶端就會向服務(wù)端發(fā)起連接請求,服務(wù)端獲取到請求后就會為該客戶端提供服務(wù)。
當客戶端向服務(wù)端發(fā)送消息后,服務(wù)端可以通過打印的IP地址和端口號識別出對應(yīng)的客戶端,而客戶端也可以通過服務(wù)端響應(yīng)回來的消息來判斷服務(wù)端是否收到了自己發(fā)送的消息。
如果此時客戶端退出了,那么服務(wù)端在調(diào)用read函數(shù)時得到的返回值就是0,此時服務(wù)端也就知道客戶端退出了,進而會終止對該客戶端的服務(wù)。
注意: 此時是服務(wù)端對該客戶端的服務(wù)終止了,而不是服務(wù)器終止了,此時服務(wù)器依舊在運行,它在等待下一個客戶端的連接請求。
10.單執(zhí)行流服務(wù)器的弊端
當我們僅用一個客戶端連接服務(wù)端時,這一個客戶端能夠正常享受到服務(wù)端的服務(wù)。
但在這個客戶端正在享受服務(wù)端的服務(wù)時,我們讓另一個客戶端也連接服務(wù)器,此時雖然在客戶端顯示連接是成功的,但這個客戶端發(fā)送給服務(wù)端的消息既沒有在服務(wù)端進行打印,服務(wù)端也沒有將該數(shù)據(jù)回顯給該客戶端。
只有當?shù)谝粋€客戶端退出后,服務(wù)端才會將第二個客戶端發(fā)來是數(shù)據(jù)進行打印,并回顯該第二個客戶端。
單執(zhí)行流的服務(wù)器
通過實驗現(xiàn)象可以看到,這服務(wù)端只有服務(wù)完一個客戶端后才會服務(wù)另一個客戶端。因為我們目前所寫的是一個單執(zhí)行流版的服務(wù)器,這個服務(wù)器一次只能為一個客戶端提供服務(wù)。
當服務(wù)端調(diào)用accept函數(shù)獲取到連接后就給該客戶端提供服務(wù),但在服務(wù)端提供服務(wù)期間可能會有其他客戶端發(fā)起連接請求,但由于當前服務(wù)器是單執(zhí)行流的,只能服務(wù)完當前客戶端后才能繼續(xù)服務(wù)下一個客戶端。
客戶端為什么會顯示連接成功?
當服務(wù)端在給第一個客戶端提供服務(wù)期間,第二個客戶端向服務(wù)端發(fā)起的連接請求時是成功的,只不過服務(wù)端沒有調(diào)用accept函數(shù)將該連接獲取上來罷了。
實際在底層會為我們維護一個連接隊列,服務(wù)端沒有accept的新連接就會放到這個連接隊列當中,而這個連接隊列的最大長度就是通過listen函數(shù)的第二個參數(shù)來指定的,因此服務(wù)端雖然沒有獲取第二個客戶端發(fā)來的連接請求,但是在第二個客戶端那里顯示是連接成功的。
如何解決?
單執(zhí)行流的服務(wù)器一次只能給一個客戶端提供服務(wù),此時服務(wù)器的資源并沒有得到充分利用,因此服務(wù)器一般是不會寫成單執(zhí)行流的。要解決這個問題就需要將服務(wù)器改為多執(zhí)行流的,此時就要引入多進程或多線程。
二.多進程版的TCP網(wǎng)絡(luò)程序
我們可以將當前的單執(zhí)行流服務(wù)器改為多進程版的服務(wù)器。
當服務(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)建后會繼承父進程的文件描述符表。比如父進程打開了一個文件,該文件對應(yīng)的文件描述符是3,此時父進程創(chuàng)建的子進程的3號文件描述符也會指向這個打開的文件,而如果子進程再創(chuàng)建一個子進程,那么子進程創(chuàng)建的子進程的3號文件描述符也同樣會指向這個打開的文件。
但當父進程創(chuàng)建子進程后,父子進程之間會保持獨立性,此時父進程文件描述符表的變化不會影響子進程。最典型的代表就是匿名管道,父子進程在使用匿名管道進行通信時,父進程先調(diào)用pipe函數(shù)得到兩個文件描述符,一個是管道讀端的文件描述符,一個是管道寫端的文件描述符,此時父進程創(chuàng)建出來的子進程就會繼承這兩個文件描述符,之后父子進程一個關(guān)閉管道的讀端,另一個關(guān)閉管道的寫端,這時父子進程文件描述符表的變化是不會相互影響的,此后父子進程就可以通過這個管道進行單向通信了。
對于套接字文件也是一樣的,父進程創(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ù)。
1.捕捉SIGCHLD信號
實際當子進程退出時會給父進程發(fā)送SIGCHLD信號,如果父進程將SIGCHLD信號進行捕捉,并將該信號的處理動作設(shè)置為忽略,此時父進程就只需專心處理自己的工作,不必關(guān)心子進程了。
該方式實現(xiàn)起來非常簡單,也是比較推薦的一種做法。
class TcpServer
{
public:
void Start()
{
signal(SIGCHLD, SIG_IGN); //忽略SIGCHLD信號
for (;;){
//獲取連接
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
pid_t id = fork();
if (id == 0){ //child
//處理請求
Service(sock, client_ip, client_port);
exit(0); //子進程提供完服務(wù)退出
}
}
}
private:
int _listen_sock; //監(jiān)聽套接字
int _port; //端口號
};
代碼測試
重新編譯程序運行服務(wù)端后,可以通過以下監(jiān)控腳本對服務(wù)進程進行監(jiān)控。
while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done
此時可以看到,一開始沒有客戶端連接該服務(wù)器,此時服務(wù)進程只有一個,該服務(wù)進程就是不斷獲取新連接的進程,而獲取到新連接后也是由該進程創(chuàng)建子進程為對應(yīng)客戶端提供服務(wù)的。
此時我們運行一個客戶端,讓該客戶端連接服務(wù)器,此時服務(wù)進程就會調(diào)用fork函數(shù)創(chuàng)建出一個子進程,由該子進程為這個客戶端提供服務(wù)。
如果再有一個客戶端連接服務(wù)器,此時服務(wù)進程會再創(chuàng)建出一個子進程,讓該子進程為這個客戶端提供服務(wù)。
最重要的是,由于這兩個客戶端分別由兩個不同的執(zhí)行流提供服務(wù),因此這兩個客戶端可以同時享受到服務(wù),它們發(fā)送給服務(wù)端的數(shù)據(jù)都能夠在服務(wù)端輸出,并且服務(wù)端也會對它們的數(shù)據(jù)進行響應(yīng)。
當客戶端一個個退出后,在服務(wù)端對應(yīng)為之提供服務(wù)的子進程也會相繼退出,但無論如何服務(wù)端都至少會有一個服務(wù)進程,這個服務(wù)進程的任務(wù)就是不斷獲取新連接。
2.讓孫子進程提供服務(wù)
我們也可以讓服務(wù)端創(chuàng)建出來的子進程再次進行fork,讓孫子進程為客戶端提供服務(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)用accept函數(shù)獲取到新連接后,會讓孫子進程為該連接提供服務(wù),此時服務(wù)進程已經(jīng)將文件描述符表繼承給了爸爸進程,而爸爸進程又會調(diào)用fork函數(shù)創(chuàng)建出孫子進程,然后再將文件描述符表繼承給孫子進程。
而父子進程創(chuàng)建后,它們各自的文件描述符表是獨立的,不會相互影響。因此服務(wù)進程在調(diào)用fork函數(shù)后,服務(wù)進程就不需要再關(guān)心剛才從accept函數(shù)獲取到的文件描述符了,此時服務(wù)進程就可以調(diào)用close函數(shù)將該文件描述符進行關(guān)閉。
同樣的,對于爸爸進程和孫子進程來說,它們是不需要關(guān)心從服務(wù)進程(爺爺進程)繼承下來的監(jiān)聽套接字的,因此爸爸進程可以將監(jiān)聽套接字關(guān)掉。
關(guān)閉文件描述符的必要性:
- 對于服務(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 TcpServer
{
public:
void Start()
{
for (;;){
//獲取連接
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
pid_t id = fork();
if (id == 0){ //child
close(_listen_sock); //child關(guān)閉監(jiān)聽套接字
if (fork() > 0){
exit(0); //爸爸進程直接退出
}
//處理請求
Service(sock, client_ip, client_port); //孫子進程提供服務(wù)
exit(0); //孫子進程提供完服務(wù)退出
}
close(sock); //father關(guān)閉為連接提供服務(wù)的套接字
waitpid(id, nullptr, 0); //等待爸爸進程(會立刻等待成功)
}
}
private:
int _listen_sock; //監(jiān)聽套接字
int _port; //端口號
};
服務(wù)器測試
重新編譯程序運行客戶端后,繼續(xù)使用監(jiān)控腳本對服務(wù)進程進行實時監(jiān)控。
while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done
此時沒有客戶端連接服務(wù)器,因此也是只監(jiān)控到了一個服務(wù)進程,該服務(wù)進程正在等待客戶端的請求連接。
此時我們運行一個客戶端,讓該客戶端連接當前這個服務(wù)器,此時服務(wù)進程會創(chuàng)建出爸爸進程,爸爸進程再創(chuàng)建出孫子進程,之后爸爸進程就會立刻退出,而由孫子進程為客戶端提供服務(wù)。因此這時我們只看到了兩個服務(wù)進程,其中一個是一開始用于獲取連接的服務(wù)進程,還有一個就是孫子進程,該進程為當前客戶端提供服務(wù),它的PPID為1,表明這是一個孤兒進程。
當我們運行第二個客戶端連接服務(wù)器時,此時就又會創(chuàng)建出一個孤兒進程為該客戶端提供服務(wù)。
此時這兩個客戶端是由兩個不同的孤兒進程提供服務(wù)的,因此它們也是能夠同時享受到服務(wù)的,可以看到這兩個客戶端發(fā)送給服務(wù)端的數(shù)據(jù)都能夠在服務(wù)端輸出,并且服務(wù)端也會對它們的數(shù)據(jù)進行響應(yīng)。
當客戶端全部退出后,對應(yīng)為客戶端提供服務(wù)的孤兒進程也會跟著退出,這時這些孤兒進程會被系統(tǒng)回收,而最終剩下那個獲取連接的服務(wù)進程。
三.多線程版的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)的客戶端。
各個線程共享同一張文件描述符表
文件描述符表維護的是進程與文件之間的對應(yīng)關(guān)系,因此一個進程對應(yīng)一張文件描述符表。而主線程創(chuàng)建出來的新線程依舊屬于這個進程,因此創(chuàng)建線程時并不會為該線程創(chuàng)建獨立的文件描述符表,所有的線程看到的都是同一張文件描述符表。
因此當服務(wù)進程(主線程)調(diào)用accept函數(shù)獲取到一個文件描述符后,其他創(chuàng)建的新線程是能夠直接訪問這個文件描述符的。
需要注意的是,雖然新線程能夠直接訪問主線程accept上來的文件描述符,但此時新線程并不知道它所服務(wù)的客戶端對應(yīng)的是哪一個文件描述符,因此主線程創(chuàng)建新線程后需要告訴新線程對應(yīng)應(yīng)該訪問的文件描述符的值,也就是告訴每個新線程在服務(wù)客戶端時,應(yīng)該對哪一個套接字進行操作。
參數(shù)結(jié)構(gòu)體
實際新線程在為客戶端提供服務(wù)時就是調(diào)用Service函數(shù),而調(diào)用Service函數(shù)時是需要傳入三個參數(shù)的,分別是客戶端對應(yīng)的套接字、IP地址和端口號。因此主線程創(chuàng)建新線程時需要給新線程傳入三個參數(shù),而實際在調(diào)用pthread_create函數(shù)創(chuàng)建新線程時,只能傳入一個類型為void*
的參數(shù)。
這時我們可以設(shè)計一個參數(shù)結(jié)構(gòu)體Param,此時這三個參數(shù)就可以放到Param結(jié)構(gòu)體當中,當主線程創(chuàng)建新線程時就可以定義一個Param對象,將客戶端對應(yīng)的套接字、IP地址和端口號設(shè)計進這個Param對象當中,然后將Param對象的地址作為新線程執(zhí)行例程的參數(shù)進行傳入。
此時新線程在執(zhí)行例程當中再將這個void*
類型的參數(shù)強轉(zhuǎn)為Param*類
型,然后就能夠拿到客戶端對應(yīng)的套接字,IP地址和端口號,進而調(diào)用Service函數(shù)為對應(yīng)客戶端提供服務(wù)。
class Param
{
public:
Param(int sock, std::string ip, int port)
: _sock(sock)
, _ip(ip)
, _port(port)
{}
~Param()
{}
public:
int _sock;
std::string _ip;
int _port;
};
文件描述符關(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)聽套接字當中獲取新連接了。
Service函數(shù)定義為靜態(tài)成員函數(shù)
由于調(diào)用pthread_create函數(shù)創(chuàng)建線程時,新線程的執(zhí)行例程是一個參數(shù)為void*
,返回值為void*
的函數(shù)。如果我們要將這個執(zhí)行例程定義到類內(nèi),就需要將其定義為靜態(tài)成員函數(shù),否則這個執(zhí)行例程的第一個參數(shù)是隱藏的this指針。
在線程的執(zhí)行例程當中會調(diào)用Service函數(shù),由于執(zhí)行例程是靜態(tài)成員函數(shù),靜態(tài)成員函數(shù)無法調(diào)用非靜態(tài)成員函數(shù),因此我們需要將Service函數(shù)定義為靜態(tài)成員函數(shù)。恰好Service函數(shù)內(nèi)部進行的操作都是與類無關(guān)的,因此我們直接在Service函數(shù)前面加上一個static
即可。
class TcpServer
{
public:
static void* HandlerRequest(void* arg)
{
pthread_detach(pthread_self()); //分離線程
//int sock = *(int*)arg;
Param* p = (Param*)arg;
Service(p->_sock, p->_ip, p->_port); //線程為客戶端提供服務(wù)
delete p; //釋放參數(shù)占用的堆空間
return nullptr;
}
void Start()
{
for (;;){
//獲取連接
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
Param* p = new Param(sock, client_ip, client_port);
pthread_t tid;
pthread_create(&tid, nullptr, HandlerRequest, p);
}
}
private:
int _listen_sock; //監(jiān)聽套接字
int _port; //端口號
};
代碼測試
此時我們再重新編譯服務(wù)端代碼,由于代碼當中用到了多線程,因此編譯時需要攜帶上-pthread
選項。此外,由于我們現(xiàn)在要監(jiān)測的是一個個的線程,因此在監(jiān)控時使用的不再是ps -axj
命令,而是ps -aL
命令。
while :; do ps -aL|head -1&&ps -aL|grep tcp_server;echo "####################";sleep 1;done
運行服務(wù)端,通過監(jiān)控可以看到,此時只有一個服務(wù)線程,該服務(wù)線程就是主線程,它現(xiàn)在在等待客戶端的連接到來。
當一個客戶端連接到服務(wù)端后,此時主線程就會為該客戶端構(gòu)建一個參數(shù)結(jié)構(gòu)體,然后創(chuàng)建一個新線程,將該參數(shù)結(jié)構(gòu)體的地址作為參數(shù)傳遞給這個新線程,此時該新線程就能夠從這個參數(shù)結(jié)構(gòu)體當中提取出對應(yīng)的參數(shù),然后調(diào)用Service函數(shù)為該客戶端提供服務(wù),因此在監(jiān)控當中顯示了兩個線程。
當?shù)诙€客戶端發(fā)來連接請求時,主線程會進行相同的操作,最終再創(chuàng)建出一個新線程為該客戶端提供服務(wù),此時服務(wù)端當中就有了三個線程。
由于為這兩個客戶端提供服務(wù)的也是兩個不同的執(zhí)行流,因此這兩個客戶端可以同時享受服務(wù)端提供的服務(wù),它們發(fā)送給服務(wù)端的消息也都能夠在服務(wù)端進行打印,并且這兩個客戶端也都能夠收到服務(wù)端的回顯數(shù)據(jù)。
此時無論有多少個客戶端發(fā)來連接請求,在服務(wù)端都會創(chuàng)建出相應(yīng)數(shù)量的新線程為對應(yīng)客戶端提供服務(wù),而當客戶端一個個退出后,為其提供服務(wù)的新線程也就會相繼退出,最終就只剩下最初的主線程仍在等待新連接的到來。
四.線程池版的TCP網(wǎng)絡(luò)程序
單純多線程存在的問題
當前多線程版的服務(wù)器存在的問題:
- 每當有新連接到來時,服務(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)答。
解決思路
針對這兩個問題,對應(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)。
在博主的另一篇博客當中詳細介紹并實現(xiàn)了線程池,這里就直接將線程池的代碼接入到當前的TCP服務(wù)器,因此下面只會講解線程池接入的方法,如果對線程池的實現(xiàn)有疑問的可以去閱讀那篇博客。
#define NUM 5
//線程池
template<class T>
class ThreadPool
{
private:
bool IsEmpty()
{
return _task_queue.size() == 0;
}
void LockQueue()
{
pthread_mutex_lock(&_mutex);
}
void UnLockQueue()
{
pthread_mutex_unlock(&_mutex);
}
void Wait()
{
pthread_cond_wait(&_cond, &_mutex);
}
void WakeUp()
{
pthread_cond_signal(&_cond);
}
public:
ThreadPool(int num = NUM)
: _thread_num(num)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
~ThreadPool()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
//線程池中線程的執(zhí)行例程
static void* Routine(void* arg)
{
pthread_detach(pthread_self());
ThreadPool* self = (ThreadPool*)arg;
//不斷從任務(wù)隊列獲取任務(wù)進行處理
while (true){
self->LockQueue();
while (self->IsEmpty()){
self->Wait();
}
T task;
self->Pop(task);
self->UnLockQueue();
task.Run(); //處理任務(wù)
}
}
void ThreadPoolInit()
{
pthread_t tid;
for (int i = 0; i < _thread_num; i++){
pthread_create(&tid, nullptr, Routine, this); //注意參數(shù)傳入this指針
}
}
//往任務(wù)隊列塞任務(wù)(主線程調(diào)用)
void Push(const T& task)
{
LockQueue();
_task_queue.push(task);
UnLockQueue();
WakeUp();
}
//從任務(wù)隊列獲取任務(wù)(線程池中的線程調(diào)用)
void Pop(T& task)
{
task = _task_queue.front();
_task_queue.pop();
}
private:
std::queue<T> _task_queue; //任務(wù)隊列
int _thread_num; //線程池中線程的數(shù)量
pthread_mutex_t _mutex;
pthread_cond_t _cond;
};
服務(wù)類新增線程池成員
現(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ù)隊列。
這實際也是一個生產(chǎn)者消費者模型,其中服務(wù)進程就作為了任務(wù)的生產(chǎn)者,而后端線程池當中的若干線程就不斷從任務(wù)隊列當中獲取任務(wù)進行處理,它們承擔(dān)的就是消費者的角色,其中生產(chǎn)者和消費者的交易場所就是線程池當中的任務(wù)隊列。。
class TcpServer
{
public:
TcpServer(int port)
: _listen_sock(-1)
, _port(port)
, _tp(nullptr)
{}
void InitServer()
{
//創(chuàng)建套接字
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//綁定
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){
std::cerr << "bind error" << std::endl;
exit(3);
}
//監(jiān)聽
if (listen(_listen_sock, BACKLOG) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
_tp = new ThreadPool<Task>(); //構(gòu)造線程池對象
}
void Start()
{
_tp->ThreadPoolInit(); //初始化線程池
for (;;){
//獲取連接
struct sockaddr_in peer;
memset(&peer, '\0', sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){
std::cerr << "accept error, continue next" << std::endl;
continue;
}
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
Task task(sock, client_ip, client_port); //構(gòu)造任務(wù)
_tp->Push(task); //將任務(wù)Push進任務(wù)隊列
}
}
private:
int _listen_sock; //監(jiān)聽套接字
int _port; //端口號
ThreadPool<Task>* _tp; //線程池
};
設(shè)計任務(wù)類
現(xiàn)在我們要做的就是設(shè)計一個任務(wù)類,該任務(wù)類當中需要包含客戶端對應(yīng)的套接字、IP地址、端口號,表示該任務(wù)是為哪一個客戶端提供服務(wù),對應(yīng)操作的套接字是哪一個。
此外,任務(wù)類當中需要包含一個Run方法,當線程池中的線程拿到任務(wù)后就會直接調(diào)用這個Run方法對該任務(wù)進行處理,而實際處理這個任務(wù)的方法就是服務(wù)類當中的Service函數(shù),服務(wù)端就是通過調(diào)用Service函數(shù)為客戶端提供服務(wù)的。
我們可以直接拿出服務(wù)類當中的Service函數(shù),將其放到任務(wù)類當中作為任務(wù)類當中的Run方法,但這實際不利于軟件分層。我們可以給任務(wù)類新增一個仿函數(shù)成員,當執(zhí)行任務(wù)類當中的Run方法處理任務(wù)時就可以以回調(diào)的方式處理該任務(wù)。
class Task
{
public:
Task()
{}
Task(int sock, std::string client_ip, int client_port)
: _sock(sock)
, _client_ip(client_ip)
, _client_port(client_port)
{}
~Task()
{}
//任務(wù)處理函數(shù)
void Run()
{
_handler(_sock, _client_ip, _client_port); //調(diào)用仿函數(shù)
}
private:
int _sock; //套接字
std::string _client_ip; //IP地址
int _client_port; //端口號
Handler _handler; //處理方法
};
注意: 當任務(wù)隊列當中有任務(wù)時,線程池當中的線程會先定義出一個Task對象,然后將這個Task對象作為輸出型參數(shù)調(diào)用任務(wù)隊列的Pop函數(shù),從任務(wù)隊列當中獲取任務(wù),因此Task類除了提供帶參的構(gòu)造函數(shù)以外,還需要提供一個無參的構(gòu)造函數(shù),方便我們可以定義無參對象。
設(shè)計Handler類
此時需要再設(shè)計一個Handler類,在Handler類當中對()
操作符進行重載,將()
操作符的執(zhí)行動作重載為執(zhí)行Service函數(shù)的代碼。
class Handler
{
public:
Handler()
{}
~Handler()
{}
void operator()(int sock, std::string client_ip, int client_port)
{
char buffer[1024];
while (true){
ssize_t size = read(sock, buffer, sizeof(buffer)-1);
if (size > 0){ //讀取成功
buffer[size] = '\0';
std::cout << client_ip << ":" << client_port << "# " << buffer << std::endl;
write(sock, buffer, size);
}
else if (size == 0){ //對端關(guān)閉連接
std::cout << client_ip << ":" << client_port << " close!" << std::endl;
break;
}
else{ //讀取失敗
std::cerr << sock << " read error!" << std::endl;
break;
}
}
close(sock); //歸還文件描述符
std::cout << client_ip << ":" << client_port << " service done!" << std::endl;
}
};
實際我們可以讓服務(wù)器處理不同的任務(wù),當前服務(wù)器只是在進行字符串的回顯處理,而實際要怎么處理這個任務(wù)完全是由任務(wù)類當中的handler成員來決定的。
如果想要讓服務(wù)器處理其他任務(wù),只需要修改Handler類當中對()的重載函數(shù)就行了,而服務(wù)器的初始化、啟動服務(wù)器以及線程池的代碼都是不需要更改的,這就叫做把通信功能和業(yè)務(wù)邏輯在軟件上做解耦。
代碼測試
此時我們再重新編譯服務(wù)端代碼,并用以下監(jiān)控腳本查看服務(wù)端的各個線程。
while :; do ps -aL|head -1&&ps -aL|grep tcp_server;echo "####################";sleep 1;done
運行服務(wù)端后,就算沒有客戶端發(fā)來連接請求,此時在服務(wù)端就已經(jīng)有了6個線程,其中有一個是接收新連接的服務(wù)線程,而其余的5個是線程池當中為客戶端提供服務(wù)的線程。
此時當客戶端連接服務(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ù)不會隨著客戶端連接的增多而增多,這些線程也不會因為客戶端的退出而退出。
五.守護進程
1.進程知識補充
進程組
- 進程組就是一個或多個進程的集合,每個進程除了有一個PID外,還屬于一個進程組。
- 每一個進程組,都有一個唯一的標識PGID,屬于同一個進程組的進程其PGID相同。
- 進程組中的第一個進程作為組長進程,將其PID作為進程組的PGID
如下,我們同時啟動了3個后臺進程,它們屬于同一進程組,進程組中的第一個進程PID=14378的進程作為組長進程
任務(wù):啟動一個進程就是啟動一個任務(wù)
-
前臺任務(wù):通過終端啟動,并且在啟動后一直占據(jù)終端
-
后臺任務(wù):啟動時與終端無關(guān),或者通過終端啟動后轉(zhuǎn)入后臺運行(即釋放終端),不影響用戶繼續(xù)在終端中工作
在我們每次登錄XShell后,bash會默認占據(jù)前臺任務(wù),也就是命令行解釋器shell(即占用終端的控制權(quán));當把進程任務(wù)自動切換為前臺任務(wù)時,shell自動切換為后臺任務(wù),我們輸入的命令就無效了
任務(wù)管理命令(Shell中控制進程組的方式):
-
jobs
:查看所有任務(wù) -
fg
:把任務(wù)提到前臺,此時這個任務(wù)就會變成前臺任務(wù),shell自動切換為后臺任務(wù),命令行解釋器失效 -
ctrl+z
:暫停前臺任務(wù) -
bg
:讓暫停的任務(wù)在后臺繼續(xù)運行
會話
Linux是多用戶多任務(wù)的分時系統(tǒng),所以必須要支持多個用戶同時使用一個操作系統(tǒng)。當一個用戶登錄一次系統(tǒng)就形成一次會話。在一個會話中,用戶可以與系統(tǒng)進行交互,執(zhí)行命令、操作文件、啟動程序等。
比如,我們先啟動3個后臺進程,在啟動3個前臺進程,后獲取這些進程的信息
- 這些進程分別屬于2個進程組,它們與同一個終端關(guān)聯(lián),屬于同一個會話
- 啟動進程就是啟動任務(wù),在每一次登錄系統(tǒng)會為我們創(chuàng)建一次會話,會話里至少有bash任務(wù)進行命令行解釋
- 命令行里可以啟動多個任務(wù),每個任務(wù)最終以進程組的形式在會話里存在。
- 所以一個會話里可能存在很多進程組
- 大小概念:會話 >= 進程組 >= 進程
2.守護進程
概念
- 進程組分為:前臺任務(wù)和后臺任務(wù)
- 如果把后臺任務(wù)提到前臺,則老的前臺任務(wù)無法運行
- 在會話中只能有一個前臺任務(wù)在運行,所以當我們在命令行啟動一個進程的時候,bash就無法運行了
- 如果登錄就是創(chuàng)建一個會話,bash任務(wù)啟動我們的進程就是在當前會話中創(chuàng)建新的前后臺任務(wù),那么我們?nèi)绻顺瞿兀烤蜁N毀會話,可能會影響會話內(nèi)部的所有任務(wù)!
我們之前的服務(wù)器都是這樣的:
每一個服務(wù)端進程組與bash進程組同屬于一個會話,再次登錄Xshell啟動服務(wù)端就會創(chuàng)建新的會話
可是一般的網(wǎng)絡(luò)服務(wù)器,為了不受到用戶的登錄注銷的影響,就必須讓服務(wù)端自成進程組,自成會話,使其與終端的狀態(tài)無關(guān),可以一直運行的進程,這樣的進程就稱作守護進程
創(chuàng)建守護進程
我們用的setsid
函數(shù)自己實現(xiàn)守護進程,不使用linux自帶生成守護進程的接口daemon
creates a session and sets the process group ID: 創(chuàng)建會話并設(shè)置進程組ID
這個函數(shù)的使用關(guān)鍵:調(diào)用的進程不能是組長進程
守護進程的創(chuàng)建步驟:
- 讓調(diào)用進程忽略掉異常信號
- fork()創(chuàng)建子進程,讓父進程直接退出,自己不再是組長進程
- 調(diào)用
setsid
,新建會話, 子進程自成進程組,成為會話的首進程 - 將標準輸入、輸出和錯誤重定向到/dev/null中
- 調(diào)用close關(guān)閉文件描述符,防止守護進程與終端或其他進程的關(guān)聯(lián)
- 調(diào)用
setsid
,新建會話, 自己成為會話的首進程
dev/null是linux下的特殊文件,會對寫入的內(nèi)容進行丟棄,通常被用作丟棄不需要的輸出或測試程序在遇到寫入錯誤時的行為。
#pragma once
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "log.hpp"
#include "err.hpp"
// 1. setsid();
// 2. setsid(), 調(diào)用進程,不能是組長! 我們怎么保證自己不是組長呢?
// 3. 守護進程, 忽略異常信號 b. 0, 1, 2要特殊處理 c. 進程的工作路徑可能要更改
// 守護進程的本質(zhì): 是孤兒進程的一種
void Daemon()
{
// 1. 忽略信號
signal(SIGPIPE,SIG_IGN);
signal(SIGCHLD,SIG_IGN);
// 2. 讓自己不要成為組長
if(fork()>0) // 父進程直接退出
exit(0);
// 3. 新建會話, 自己成為會話的首進程
pid_t ret=setsid();
if((int)ret==-1)
{
logMessage(Fatal,"deamon error, code: %d, error string: %s",errno, strerror(errno));
exit(SET_ERR);
}
// 4. 可選: 可以更改守護進程的工作路徑
// chdir("/");
// 5. 處理后續(xù)對于0,1,2的問題 --- /dev/null 文件就像垃圾桶
int fd=open("/dev/null",O_RDWR);
if(fd<0)
{
logMessage(Fatal,"open error, code: %d, error string: %s",errno,strerror(errno));
exit(SET_ERR);
}
dup2(fd,0);
dup2(fd,1);
dup2(fd,2);
close(fd);
}
給服務(wù)端加上該代碼
運行結(jié)果:
文章來源:http://www.zghlxwxcb.cn/news/detail-742066.html
-
?
表示該進程與終端無關(guān),我們已經(jīng)讓此服務(wù)端以守護進程的方式運行 - 該進程的PPID為1,說明OS領(lǐng)養(yǎng)了守護進程,守護進程本質(zhì)是孤兒進程的一種
本文到此結(jié)束,碼文不易,還請多多支持哦?。?!文章來源地址http://www.zghlxwxcb.cn/news/detail-742066.html
到了這里,關(guān)于網(wǎng)絡(luò)編程套接字(2)——簡單的TCP網(wǎng)絡(luò)程序的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!