導語:本文主要介紹如何從零開始搭建簡單的C++客戶端/服務器,并進行簡單的講解和基礎的壓力測試演示。該文章相對比較入門,主要面向了解計算機網(wǎng)絡但未接觸過網(wǎng)絡編程的同學。
本文主要分為四個部分:
搭建C/S:用C++搭建一個最簡單的,基于socket網(wǎng)絡編程的客戶端和服務器
socket庫函數(shù)淺析:基于上一節(jié)搭建的客戶端和服務器的代碼介紹相關的庫函數(shù)
搭建HTTP服務器:基于上一節(jié)的介紹和HTTP工作過程將最開始搭建的服務器改為HTTP服務器
壓力測試入門:優(yōu)化一下服務器,并使用ab工具對優(yōu)化前后的服務器進行壓力測試并對比結(jié)果
1. 搭建C/S
本節(jié)主要講述如何使用C++搭建一個簡單的socket服務器和客戶端。
為了能更加容易理解如何搭建,本節(jié)會省略許多細節(jié)和函數(shù)解釋,對于整個連接的過程的描述也會比較抽象,細節(jié)和解析會留到之后再講。
服務端和客戶端的預期功能
這里要實現(xiàn)的服務端的功能十分簡單,只需要把任何收到的數(shù)據(jù)原封不動地發(fā)回去即可,也就是所謂的ECHO服務器。
客戶端要做的事情也十分簡單,讀取用戶輸入的一個字符串并發(fā)送給服務端,然后把接收到的數(shù)據(jù)輸出出來即可。
服務端搭建
將上面的需求轉(zhuǎn)化一下就可以得到如下形式:
while(true)
{
buff = 接收到的數(shù)據(jù);
將buff的數(shù)據(jù)發(fā)回去;
}
當然,上面的偽代碼是省略掉網(wǎng)絡連接和斷開的過程。這個例子使用的連接形式為TCP連接,而在一個完整的TCP連接中,服務端和客戶端通信需要做三件事:
服務端與客戶端進行連接
服務端與客戶端之間傳輸數(shù)據(jù)
服務端與客戶端之間斷開連接
將這些加入偽代碼中,便可以得到如下偽代碼:
while(true)
{
與客戶端建立連接;
buff = 接收到從客戶端發(fā)來的數(shù)據(jù);
將buff的數(shù)據(jù)發(fā)回客戶端;
與客戶端斷開連接;
}
首先需要解決的就是,如何建立連接。
在socket編程中,服務端和客戶端是靠socket進行連接的。服務端在建立連接之前需要做的有:
創(chuàng)建socket(偽代碼中簡稱為socket()
)
將socket與指定的IP和端口(以下簡稱為port)綁定(偽代碼中簡稱為bind()
)
讓socket在綁定的端口處監(jiān)聽請求(等待客戶端連接到服務端綁定的端口)(偽代碼中簡稱為listen()
)
而客戶端發(fā)送連接請求并成功連接之后(這個步驟在偽代碼中簡稱為accept()
),服務端便會得到客戶端的套接字,于是所有的收發(fā)數(shù)據(jù)便可以在這個客戶端的套接字上進行了。
而收發(fā)數(shù)據(jù)其實就是:
在收發(fā)數(shù)據(jù)之后,就需要斷開與客戶端之間的連接。在socket編程中,只需要關閉客戶端的套接字即可斷開連接。(偽代碼中簡稱為close()
)
將其補充進去得到:
sockfd = socket(); // 創(chuàng)建一個socket,賦給sockfd
bind(sockfd, ip::port和一些配置); // 讓socket綁定端口,同時配置連接類型之類的
listen(sockfd); // 讓socket監(jiān)聽之前綁定的端口
while(true)
{
connfd = accept(sockfd); // 等待客戶端連接,直到連接成功,之后將客戶端的套接字返回出來
recv(connfd, buff); // 接收到從客戶端發(fā)來的數(shù)據(jù),并放入buff中
send(connfd, buff); // 將buff的數(shù)據(jù)發(fā)回客戶端
close(connfd); // 與客戶端斷開連接
}
這便是socket服務端的大致流程。詳細的C++代碼如下所示:
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <sys/unistd.h>
#include <sys/types.h>
#include <sys/errno.h>
#include <netinet/in.h>
#include <signal.h>
#define BUFFSIZE 2048
#define DEFAULT_PORT 16555 // 指定端口為16555
#define MAXLINK 2048
int sockfd, connfd; // 定義服務端套接字和客戶端套接字
void stopServerRunning(int p)
{
close(sockfd);
printf('Close Server\n');
exit(0);
}
int main()
{
struct sockaddr_in servaddr; // 用于存放ip和端口的結(jié)構(gòu)
char buff[BUFFSIZE]; // 用于收發(fā)數(shù)據(jù)
// 對應偽代碼中的sockfd = socket();
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sockfd)
{
printf('Create socket error(%d): %s\n', errno, strerror(errno));
return -1;
}
// END
// 對應偽代碼中的bind(sockfd, ip::port和一些配置);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(DEFAULT_PORT);
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)))
{
printf('Bind error(%d): %s\n', errno, strerror(errno));
return -1;
}
// END
// 對應偽代碼中的listen(sockfd);
if (-1 == listen(sockfd, MAXLINK))
{
printf('Listen error(%d): %s\n', errno, strerror(errno));
return -1;
}
// END
printf('Listening...\n');
while (true)
{
signal(SIGINT, stopServerRunning); // 這句用于在輸入Ctrl+C的時候關閉服務器
// 對應偽代碼中的connfd = accept(sockfd);
connfd = accept(sockfd, NULL, NULL);
if (-1 == connfd)
{
printf('Accept error(%d): %s\n', errno, strerror(errno));
return -1;
}
// END
bzero(buff, BUFFSIZE);
// 對應偽代碼中的recv(connfd, buff);
recv(connfd, buff, BUFFSIZE - 1, 0);
// END
printf('Recv: %s\n', buff);
// 對應偽代碼中的send(connfd, buff);
send(connfd, buff, strlen(buff), 0);
// END
// 對應偽代碼中的close(connfd);
close(connfd);
// END
}
return 0;
}
客戶端搭建
客戶端相對于服務端來說會簡單一些。它需要做的事情有:
其收發(fā)數(shù)據(jù)也是借助自身的套接字來完成的。
轉(zhuǎn)換為偽代碼如下:
sockfd = socket(); // 創(chuàng)建一個socket,賦給sockfd
connect(sockfd, ip::port和一些配置); // 使用socket向指定的ip和port發(fā)起連接
scanf('%s', buff); // 讀取用戶輸入
send(sockfd, buff); // 發(fā)送數(shù)據(jù)到服務端
recv(sockfd, buff); // 從服務端接收數(shù)據(jù)
close(sockfd); // 與服務器斷開連接
這便是socket客戶端的大致流程。詳細的C++代碼如下所示:
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <sys/unistd.h>
#include <sys/types.h>
#include <sys/errno.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define BUFFSIZE 2048
#define SERVER_IP '192.168.19.12' // 指定服務端的IP,記得修改為你的服務端所在的ip
#define SERVER_PORT 16555 // 指定服務端的port
int main()
{
struct sockaddr_in servaddr;
char buff[BUFFSIZE];
int sockfd;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(-1 == sockfd)
{
printf('Create socket error(%d): %s\n', errno, strerror(errno));
return -1;
}
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
inet_pton(AF_INET, SERVER_IP, &servaddr.sin_addr));
servaddr.sin_port = htons(SERVER_PORT);
if (-1 == connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)))
{
printf('Connect error(%d): %s\n', errno, strerror(errno));
return -1;
}
printf('Please input: ');
scanf('%s', buff);
send(sockfd, buff, strlen(buff), 0);
bzero(buff, sizeof(buff));
recv(sockfd, buff, BUFFSIZE - 1, 0);
printf('Recv: %s\n', buff);
close(sockfd);
return 0;
}
效果演示
將服務端TrainServer.cpp
和客戶端TrainClient.cpp
分別放到機子上進行編譯:
g++ TrainServer.cpp -o TrainServer.o
g++ TrainClient.cpp -o TrainClient.o
編譯后的文件列表如下所示:
$ ls
TrainClient.cpp TrainClient.o TrainServer.cpp TrainServer.o
接著,先啟動服務端:
$ ./TrainServer.o
Listening...
然后,再在另一個命令行窗口上啟動客戶端:
$ ./TrainClient.o
Please input:
隨便輸入一個字符串,例如說Re0_CppNetworkProgramming
:
$ ./TrainClient.o
Please input: Re0_CppNetworkProgramming
Recv: Re0_CppNetworkProgramming
此時服務端也收到了數(shù)據(jù)并顯示出來:
$ ./TrainServer.o
Listening...
Recv: Re0_CppNetworkProgramming
你可以在服務端啟動的時候多次打開客戶端并向服務端發(fā)送數(shù)據(jù),服務端每當收到請求都會處理并返回數(shù)據(jù)。
當且僅當服務端下按ctrl+c
的時候會關閉服務端。
2. socket庫函數(shù)淺析
本節(jié)會先從TCP連接入手,簡單回顧一下TCP連接的過程。然后再根據(jù)上一節(jié)的代碼對這個簡單客戶端/服務器的socket通信涉及到的庫函數(shù)進行介紹。
注意:本篇中所有函數(shù)都按工作在TCP連接的情況下,并且socket默認為阻塞的情況下講解。
TCP連接簡介
什么是TCP協(xié)議
在此之前,需要了解網(wǎng)絡的協(xié)議層模型。這里不使用OSI七層模型,而是直接通過網(wǎng)際網(wǎng)協(xié)議族進行講解。
在網(wǎng)際網(wǎng)協(xié)議族中,協(xié)議層從上往下如下圖所示:
這個協(xié)議層所表示的意義為:如果A機和B機的網(wǎng)絡都是使用(或可以看作是)網(wǎng)際網(wǎng)協(xié)議族的話,那么從機子A上發(fā)送數(shù)據(jù)到機子B所經(jīng)過的路線大致為:
A的應用層→A的傳輸層(TCP/UDP)→A的網(wǎng)絡層(IPv4,IPv6)→A的底層硬件(此時已經(jīng)轉(zhuǎn)化為物理信號了)→B的底層硬件→B的網(wǎng)絡層→B的傳輸層→B的應用層
而我們在使用socket(也就是套接字)編程的時候,其實際上便是工作于應用層和傳輸層之間,此時我們可以屏蔽掉底層細節(jié),將網(wǎng)絡傳輸簡化為:
A的應用層→A的傳輸層→B的傳輸層→B的應用層
而如果使用的是TCP連接的socket連接的話,每個數(shù)據(jù)包的發(fā)送的過程大致為:
其實不單是TCP,其他協(xié)議的單個數(shù)據(jù)發(fā)送過程大致也是如此。
TCP協(xié)議和與其處在同一層的UDP協(xié)議的區(qū)別主要在于其對于連接和應用層數(shù)據(jù)的處理和發(fā)送方式。
如上一節(jié)所述,要使用TCP連接收發(fā)數(shù)據(jù)需要做三件事:
建立連接
收發(fā)數(shù)據(jù)
斷開連接
下面將對這三點展開說明:
建立連接:TCP三次握手
在沒進行連接的情況下,客戶端的TCP狀態(tài)處于CLOSED
狀態(tài),服務端的TCP處于CLOSED
(未開啟監(jiān)聽)或者LISTEN
(開啟監(jiān)聽)狀態(tài)。
TCP中,服務端與客戶端建立連接的過程如下:
客戶端主動發(fā)起連接(在socket編程中則為調(diào)用connect
函數(shù)),此時客戶端向服務端發(fā)送一個SYN包
這個SYN包可以看作是一個小數(shù)據(jù)包,不過其中沒有任何實際數(shù)據(jù),僅有諸如TCP首部和TCP選項等協(xié)議包必須數(shù)據(jù)??梢钥醋魇强蛻舳私o服務端發(fā)送的一個信號
此時客戶端狀態(tài)從CLOSED
切換為SYN_SENT
服務端收到SYN包,并返回一個針對該SYN包的響應包(ACK包)和一個新的SYN包。
在socket編程中,服務端能收到SYN包的前提是,服務端已經(jīng)調(diào)用過listen
函數(shù)使其處于監(jiān)聽狀態(tài)(也就是說,其必須處于LISTEN
狀態(tài)),并且處于accept
函數(shù)等待連接的阻塞狀態(tài)。
此時服務端狀態(tài)從LISTEN
切換為SYN_RCVD
客戶端收到服務端發(fā)來的兩個包,并返回針對新的SYN包的ACK包。
此時客戶端狀態(tài)從SYN_SENT
切換至ESTABLISHED
,該狀態(tài)表示可以傳輸數(shù)據(jù)了。
服務端收到ACK包,成功建立連接,accept
函數(shù)返回出客戶端套接字。
此時服務端狀態(tài)從SYN_RCVD
切換至ESTABLISHED
收發(fā)數(shù)據(jù)
當連接建立之后,就可以通過客戶端套接字進行收發(fā)數(shù)據(jù)了。
斷開連接:TCP四次揮手
在收發(fā)數(shù)據(jù)之后,如果需要斷開連接,則斷開連接的過程如下:
雙方中有一方(假設為A,另一方為B)主動關閉連接(調(diào)用close
,或者其進程本身被終止等情況),則其向B發(fā)送FIN包
此時A從ESTABLISHED
狀態(tài)切換為FIN_WAIT_1
狀態(tài)
B接收到FIN包,并發(fā)送ACK包
此時B從ESTABLISHED
狀態(tài)切換為CLOSE_WAIT
狀態(tài)
A接收到ACK包
此時A從FIN_WAIT_1
狀態(tài)切換為FIN_WAIT_2
狀態(tài)
一段時間后,B調(diào)用自身的close
函數(shù),發(fā)送FIN包
此時B從CLOSE_WAIT
狀態(tài)切換為LAST_ACK
狀態(tài)
A接收到FIN包,并發(fā)送ACK包
此時A從FIN_WAIT_2
狀態(tài)切換為TIME_WAIT
狀態(tài)
B接收到ACK包,關閉連接
此時B從LAST_ACK
狀態(tài)切換為CLOSED
狀態(tài)
A等待一段時間(兩倍的最長生命周期)后,關閉連接
此時A從TIME_WAIT
狀態(tài)切換為CLOSED
狀態(tài)
socket函數(shù)
根據(jù)上節(jié)可以知道,socket函數(shù)用于創(chuàng)建套接字。其實更嚴謹?shù)闹v是創(chuàng)建一個套接字描述符(以下簡稱sockfd)。
套接字描述符本質(zhì)上類似于文件描述符,文件通過文件描述符供程序進行讀寫,而套接字描述符本質(zhì)上也是提供給程序可以對其緩存區(qū)進行讀寫,程序在其寫緩存區(qū)寫入數(shù)據(jù),寫緩存區(qū)的數(shù)據(jù)通過網(wǎng)絡通信發(fā)送至另一端的相同套接字的讀緩存區(qū),另一端的程序使用相同的套接字在其讀緩存區(qū)上讀取數(shù)據(jù),這樣便完成了一次網(wǎng)絡數(shù)據(jù)傳輸。
而socket
函數(shù)的參數(shù)便是用于設置這個套接字描述符的屬性。
該函數(shù)的原型如下:
#include <sys/socket.h>
int socket(int family, int type, int protocol);
family參數(shù)
該參數(shù)指明要創(chuàng)建的sockfd的協(xié)議族,一般比較常用的有兩個:
AF_INET
:IPv4協(xié)議族
AF_INET6
:IPv6協(xié)議族
type參數(shù)
該參數(shù)用于指明套接字類型,具體有:
SOCK_STREAM
:字節(jié)流套接字,適用于TCP或SCTP協(xié)議
SOCK_DGRAM
:數(shù)據(jù)報套接字,適用于UDP協(xié)議
SOCK_SEQPACKET
:有序分組套接字,適用于SCTP協(xié)議
SOCK_RAW
:原始套接字,適用于繞過傳輸層直接與網(wǎng)絡層協(xié)議(IPv4/IPv6)通信
protocol參數(shù)
該參數(shù)用于指定協(xié)議類型。
如果是TCP協(xié)議的話就填寫IPPROTO_TCP
,UDP和SCTP協(xié)議類似。
也可以直接填寫0,這樣的話則會默認使用family
參數(shù)和type
參數(shù)組合制定的默認協(xié)議
(參照上面type參數(shù)的適用協(xié)議)
返回值
socket
函數(shù)在成功時會返回套接字描述符,失敗則返回-1。
失敗的時候可以通過輸出errno
來詳細查看具體錯誤類型。
關于errno
通常一個內(nèi)核函數(shù)運行出錯的時候,它會定義全局變量errno
并賦值。
當我們引入errno.h
頭文件時便可以使用這個變量。并利用這個變量查看具體出錯原因。
一共有兩種查看的方法:
bind函數(shù)
根據(jù)上節(jié)可以知道,bind函數(shù)用于將套接字與一個ip::port
綁定。或者更應該說是把一個本地協(xié)議地址賦予一個套接字。
該函數(shù)的原型如下:
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
這個函數(shù)的參數(shù)表比較簡單:第一個是套接字描述符,第二個是套接字地址結(jié)構(gòu)體,第三個是套接字地址結(jié)構(gòu)體的長度。其含義就是將第二個的套接字地址結(jié)構(gòu)體賦給第一個的套接字描述符所指的套接字。
接下來著重講一下套接字地址結(jié)構(gòu)體
套接字地址結(jié)構(gòu)體
在bind函數(shù)的參數(shù)表中出現(xiàn)了一個名為sockaddr
的結(jié)構(gòu)體,這個便是用于存儲將要賦給套接字的地址結(jié)構(gòu)的通用套接字地址結(jié)構(gòu)。其定義如下:
#include <sys/socket.h>
struct sockaddr
{
uint8_t sa_len;
sa_family_t sa_family; // 地址協(xié)議族
char sa_data[14]; // 地址數(shù)據(jù)
};
當然,我們一般不會直接使用這個結(jié)構(gòu)來定義套接字地址結(jié)構(gòu)體,而是使用更加特定化的IPv4套接字地址結(jié)構(gòu)體或IPv6套接字地址結(jié)構(gòu)體。這里只講前者。
IPv4套接字地址結(jié)構(gòu)體的定義如下:
#include <netinet/in.h>
struct in_addr
{
in_addr_t s_addr; // 32位IPv4地址
};
struct sockaddr_in
{
uint8_t sin_len; // 結(jié)構(gòu)長度,非必需
sa_family_t sin_family; // 地址族,一般為AF_****格式,常用的是AF_INET
in_port_t sin_port; // 16位TCP或UDP端口號
struct in_addr sin_addr; // 32位IPv4地址
char sin_zero[8]; // 保留數(shù)據(jù)段,一般置零
};
值得注意的是,一般而言一個sockaddr_in
結(jié)構(gòu)對我們來說有用的字段就三個:
sin_family
sin_addr
sin_port
可以看到在第一節(jié)的代碼中也是只賦值了這三個成員:
#define DEFAULT_PORT 16555
// ...
struct sockaddr_in servaddr; // 定義一個IPv4套接字地址結(jié)構(gòu)體
// ...
bzero(&servaddr, sizeof(servaddr)); // 將該結(jié)構(gòu)體的所有數(shù)據(jù)置零
servaddr.sin_family = AF_INET; // 指定其協(xié)議族為IPv4協(xié)議族
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 指定IP地址為通配地址
servaddr.sin_port = htons(DEFAULT_PORT); // 指定端口號為16555
// 調(diào)用bind,注意第二個參數(shù)使用了類型轉(zhuǎn)換,第三個參數(shù)直接取其sizeof即可
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)))
{
printf('Bind error(%d): %s\n', errno, strerror(errno));
return -1;
}
其中有三個細節(jié)需要注意:
在指定IP地址的時候,一般就是使用像上面那樣的方法指定為通配地址,此時就交由內(nèi)核選擇IP地址綁定。指定特定IP的操作在講connect函數(shù)的時候會提到。
在指定端口的時候,可以直接指定端口號為0,此時表示端口號交由內(nèi)核選擇(也就是進程不指定端口號)。但一般而言對于服務器來說,不指定端口號的情況是很罕見的,因為服務器一般都需要暴露一個端口用于讓客戶端知道并作為連接的參數(shù)。
注意到不管是賦值IP還是端口,都不是直接賦值,而是使用了類似htons()
或htonl()
的函數(shù),這便是字節(jié)排序函數(shù)。
字節(jié)排序函數(shù)
首先,不同的機子上對于多字節(jié)變量的字節(jié)存儲順序是不同的,有大端字節(jié)序和小端字節(jié)序兩種。
那這就意味著,將機子A的變量原封不動傳到機子B上,其值可能會發(fā)生變化(本質(zhì)上數(shù)據(jù)沒有變化,但如果兩個機子的字節(jié)序不一樣的話,解析出來的值便是不一樣的)。這顯然是不好的。
故我們需要引入一個通用的規(guī)范,稱為網(wǎng)絡字節(jié)序。引入網(wǎng)絡字節(jié)序之后的傳遞規(guī)則就變?yōu)椋?/p>
機子A先將變量由自身的字節(jié)序轉(zhuǎn)換為網(wǎng)絡字節(jié)序
發(fā)送轉(zhuǎn)換后的數(shù)據(jù)
機子B接到轉(zhuǎn)換后的數(shù)據(jù)之后,再將其由網(wǎng)絡字節(jié)序轉(zhuǎn)換為自己的字節(jié)序
其實就是很常規(guī)的統(tǒng)一標準中間件的做法。
在Linux中,位于<netinet/in.h>
中有四個用于主機字節(jié)序和網(wǎng)絡字節(jié)序之間相互轉(zhuǎn)換的函數(shù):
#include <netinet/in.h>
uint16_t htons(uint16_t host16bitvalue); //host to network, 16bit
uint32_t htonl(uint32_t host32bitvalue); //host to network, 32bit
uint16_t ntohs(uint16_t net16bitvalue); //network to host, 16bit
uint32_t ntohl(uint32_t net32bitvalue); //network to host, 32bit
返回值
若成功則返回0,否則返回-1并置相應的errno
。
比較常見的錯誤是錯誤碼EADDRINUSE
('Address already in use',地址已使用)。
listen函數(shù)
listen函數(shù)的作用就是開啟套接字的監(jiān)聽狀態(tài),也就是將套接字從CLOSE
狀態(tài)轉(zhuǎn)換為LISTEN
狀態(tài)。
該函數(shù)的原型如下:
#include <sys/socket.h>
int listen(int sockfd, int backlog);
其中,sockfd
為要設置的套接字,backlog
為服務器處于LISTEN
狀態(tài)下維護的隊列長度和的最大值。
關于backlog
這是一個可調(diào)參數(shù)。
其意義為,服務器套接字處于LISTEN
狀態(tài)下所維護的未完成連接隊列(SYN隊列)和已完成連接隊列(Accept隊列)的長度和的最大值。
↑ 這個是原本的意義,現(xiàn)在的backlog
僅指Accept隊列的最大長度,SYN隊列的最大長度由系統(tǒng)的另一個變量決定。
這兩個隊列用于維護與客戶端的連接,其中:
backlog調(diào)參
backlog
是由程序員決定的,不過最后的隊列長度其實是min(backlog, /proc/sys/net/core/somaxconn , net.ipv4.tcp_max_syn_backlog )
,后者直接讀取對應位置文件就有了。
不過由于后者是可以修改的,故這里討論的backlog
實際上是這兩個值的最小值。
至于如何調(diào)參,可以參考這篇博客:
https://ylgrgyq./2017/05/18/tcp-backlog/
事實上backlog
僅僅是與Accept隊列的最大長度相關的參數(shù),實際的隊列最大長度視不同的操作系統(tǒng)而定。例如說MacOS上使用傳統(tǒng)的Berkeley算法基于backlog
參數(shù)進行計算,而Linux2.4.7上則是直接等于backlog+3
。
返回值
若成功則返回0,否則返回-1并置相應的errno
。
connect函數(shù)
該函數(shù)用于客戶端跟綁定了指定的ip和port并且處于LISTEN
狀態(tài)的服務端進行連接。
在調(diào)用connect函數(shù)的時候,調(diào)用方(也就是客戶端)便會主動發(fā)起TCP三次握手。
該函數(shù)的原型如下:
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
其中第一個參數(shù)為客戶端套接字,第二個參數(shù)為用于指定服務端的ip和port的套接字地址結(jié)構(gòu)體,第三個參數(shù)為該結(jié)構(gòu)體的長度。
操作上比較類似于服務端使用bind函數(shù)(雖然做的事情完全不一樣),唯一的區(qū)別在于指定ip這塊。服務端調(diào)用bind函數(shù)的時候無需指定ip,但客戶端調(diào)用connect函數(shù)的時候則需要指定服務端的ip。
在客戶端的代碼中,令套接字地址結(jié)構(gòu)體指定ip的代碼如下:
inet_pton(AF_INET, SERVER_IP, &servaddr.sin_addr);
這個就涉及到ip地址的表達格式與數(shù)值格式相互轉(zhuǎn)換的函數(shù)。
IP地址格式轉(zhuǎn)換函數(shù)
IP地址一共有兩種格式:
顯然,當我們需要將一個IP賦進套接字地址結(jié)構(gòu)體中,就需要將其轉(zhuǎn)換為數(shù)值格式。
在<arpa/inet.h>
中提供了兩個函數(shù)用于IP地址格式的相互轉(zhuǎn)換:
#include <arpa/inet.h>
int inet_pton(int family, const char *strptr, void *addrptr);
const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len);
其中:
inet_pton()
函數(shù)用于將IP地址從表達格式轉(zhuǎn)換為數(shù)值格式
第一個參數(shù)指定協(xié)議族(AF_INET
或AF_INET6
)
第二個參數(shù)指定要轉(zhuǎn)換的表達格式的IP地址
第三個參數(shù)指定用于存儲轉(zhuǎn)換結(jié)果的指針
對于返回結(jié)果而言:
若轉(zhuǎn)換成功則返回1
若表達格式的IP地址格式有誤則返回0
若出錯則返回-1
inet_ntop()
函數(shù)用于將IP地址從數(shù)值格式轉(zhuǎn)換為表達格式
第一個參數(shù)指定協(xié)議族
第二個參數(shù)指定要轉(zhuǎn)換的數(shù)值格式的IP地址
第三個參數(shù)指定用于存儲轉(zhuǎn)換結(jié)果的指針
第四個參數(shù)指定第三個參數(shù)指向的空間的大小,用于防止緩存區(qū)溢出
對于返回結(jié)果而言
返回值
若成功則返回0,否則返回-1并置相應的errno
。
其中connect函數(shù)會出錯的幾種情況:
若客戶端在發(fā)送SYN包之后長時間沒有收到響應,則返回ETIMEOUT
錯誤
若客戶端在發(fā)送SYN包之后收到的是RST包的話,則會立刻返回ECONNREFUSED
錯誤
若客戶端在發(fā)送SYN包的時候在中間的某一臺路由器上發(fā)生ICMP錯誤,則會發(fā)生EHOSTUNREACH
或ENETUNREACH
錯誤
事實上跟處理未響應一樣,為了排除偶然因素,客戶端遇到這個問題的時候會保存內(nèi)核信息,隔一段時間之后再重發(fā)SYN包,在多次發(fā)送失敗之后才會報錯
路由器發(fā)生ICMP錯誤的原因是,路由器上根據(jù)目標IP查找轉(zhuǎn)發(fā)表但查不到針對目標IP應該如何轉(zhuǎn)發(fā),則會發(fā)生ICMP錯誤
可能的原因是目標服務端的IP地址不可達,或者路由器配置錯誤,也有可能是因為電波干擾等隨機因素導致數(shù)據(jù)包錯誤,進而導致路由無法轉(zhuǎn)發(fā)
由于connect函數(shù)在發(fā)送SYN包之后就會將自身的套接字從CLOSED
狀態(tài)置為SYN_SENT
狀態(tài),故當connect報錯之后需要主動將套接字狀態(tài)置回CLOSED
。此時需要通過調(diào)用close函數(shù)主動關閉套接字實現(xiàn)。
故原版的客戶端代碼需要做一個修改:
if (-1 == connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)))
{
printf('Connect error(%d): %s\n', errno, strerror(errno));
close(sockfd); // 新增代碼,當connect出錯時需要關閉套接字
return -1;
}
accept函數(shù)
根據(jù)上一節(jié)所述,該函數(shù)用于跟客戶端建立連接,并返回客戶端套接字。
更準確的說,accept函數(shù)由TCP服務器調(diào)用,用于從Accept隊列中pop出一個已完成的連接。若Accept隊列為空,則accept函數(shù)所在的進程阻塞。
該函數(shù)的原型如下:
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
其中第一個參數(shù)為服務端自身的套接字,第二個參數(shù)用于接收客戶端的套接字地址結(jié)構(gòu)體,第三個參數(shù)用于接收第二個參數(shù)的結(jié)構(gòu)體的長度。
返回值
當accept函數(shù)成功拿到一個已完成連接時,其會返回該連接對應的客戶端套接字描述符,用于后續(xù)的數(shù)據(jù)傳輸。
若發(fā)生錯誤則返回-1并置相應的errno
。
recv函數(shù)&send函數(shù)
recv函數(shù)用于通過套接字接收數(shù)據(jù),send函數(shù)用于通過套接字發(fā)送數(shù)據(jù)
這兩個函數(shù)的原型如下:
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buff, size_t nbytes, int flags);
ssize_t send(int sockfd, const void *buff, size_t nbytes, int flags);
其中:
第一個參數(shù)為要讀寫的套接字
第二個參數(shù)指定要接收數(shù)據(jù)的空間的指針(recv)或要發(fā)送的數(shù)據(jù)(send)
第三個參數(shù)指定最大讀取的字節(jié)數(shù)(recv)或發(fā)送的數(shù)據(jù)的大?。╯end)
第四個參數(shù)用于設置一些參數(shù),默認為0
目前用不到第四個參數(shù),故暫時不做展開
事實上,去掉第四個參數(shù)的情況下,recv跟read函數(shù)類似,send跟write函數(shù)類似。這兩個函數(shù)的本質(zhì)也是一種通過描述符進行的IO,只是在這里的描述符為套接字描述符。
返回值
在recv函數(shù)中:
在send函數(shù)中:
close函數(shù)
根據(jù)第一節(jié)所述,該函數(shù)用于斷開連接。或者更具體的講,該函數(shù)用于關閉套接字,并終止TCP連接。
該函數(shù)的原型如下:
#include <unistd.h>
int close(int sockfd);
返回值
同樣的,若close成功則返回0,否則返回-1并置errno
。
常見的錯誤為關閉一個無效的套接字。
3. 搭建HTTP服務器
本節(jié)將會將最開始的簡單服務器改為可以接收并處理HTTP請求的HTTP服務器。
在改裝之前,首先需要明白HTTP服務器能做什么。
所謂HTTP服務器,通俗點說就是可以使用像http://192.168.19.12:16555/
這樣的URL進行服務器請求,并且能得到一個合法的返回。
其實之前搭的服務器已經(jīng)可以處理這種HTTP請求了,只是請求的返回不合法罷了(畢竟只是把發(fā)送的數(shù)據(jù)再回傳一遍)。在這里可以做個試驗,看看現(xiàn)階段的服務器是如何處理HTTP請求的:
首先,開啟服務器:
$ ./TrainServer.o
Listening...
之后,另開一個命令行,使用curl
指令發(fā)送一個HTTP請求(其實就是類似瀏覽器打開http://192.168.19.12:16555/
的頁面一樣):
$ curl -v 'http://192.168.19.12:16555/'
* About to connect() to 192.168.19.12 port 16555 (#0)
* Trying 192.168.19.12... connected
* Connected to 192.168.19.12 (192.168.19.12) port 16555 (#0)
> GET / HTTP/1.1
> User-Agent: curl/7.19.7 (x86_64-redhat-linux-gnu) libcurl/7.19.7 NSS/3.27.1 zlib/1.2.3 libidn/1.18 libssh2/1.4.2
> Host: 192.168.19.12:16555
> Accept: */*
>
GET / HTTP/1.1
User-Agent: curl/7.19.7 (x86_64-redhat-linux-gnu) libcurl/7.19.7 NSS/3.27.1 zlib/1.2.3 libidn/1.18 libssh2/1.4.2
Host: 192.168.19.12:16555
Accept: */*
* Connection #0 to host 192.168.19.12 left intact
* Closing connection #0
其中:
GET / HTTP/1.1
User-Agent: curl/7.19.7 (x86_64-redhat-linux-gnu) libcurl/7.19.7 NSS/3.27.1 zlib/1.2.3 libidn/1.18 libssh2/1.4.2
Host: 192.168.19.12:16555
Accept: */*
便是接收到的返回數(shù)據(jù),我們可以通過服務器自己輸出的日志確認這一點:
$ ./TrainServer.o
Listening...
Recv: GET / HTTP/1.1
User-Agent: curl/7.19.7 (x86_64-redhat-linux-gnu) libcurl/7.19.7 NSS/3.27.1 zlib/1.2.3 libidn/1.18 libssh2/1.4.2
Host: 192.168.19.12:16555
Accept: */*
(注意其中的Recv:
是程序自己的輸出)
可以看到,當我們通過http://192.168.19.12:16555/
訪問服務器的時候,其實就相當于發(fā)這一長串東西給服務器。
事實上這一串東西就是HTTP請求串,其格式如下:
方法名 URL 協(xié)議版本 //請求行
字段名:字段值 //消息報頭
字段名:字段值 //消息報頭
...
字段名:字段值 //消息報頭
請求正文 //可選
每一行都以\r\n
結(jié)尾,表示一個換行。
于是對應的就有一個叫做HTTP返回串的東西,這個也是有格式規(guī)定的:
協(xié)議版本 狀態(tài)碼 狀態(tài)描述 //狀態(tài)行
字段名:字段值 //消息報頭
字段名:字段值 //消息報頭
...
字段名:字段值 //消息報頭
響應正文 //可選
其中,狀態(tài)碼有如下的幾種:
1xx:指示信息,表示請求已接收,繼續(xù)處理
2xx:成功,表示請求已被成功接收、理解、接受
3xx:重定向,要完成請求必須進行更進一步的操作
4xx:客戶端錯誤,請求有語法錯誤或請求無法實現(xiàn)
5xx:服務器端錯誤,服務器未能實現(xiàn)合法的請求
比較常見的就有200(OK),404(Not Found),502(Bad Gateway)。
顯然我們需要返回一個成功的HTTP返回串,故這里就需要使用200,于是第一行就可以是:
HTTP/1.1 200 OK
至于字段名及其對應的字段值則按需加就行了,具體的可以上網(wǎng)查有哪些選項。
這里為了簡潔就只加一個就行了:
Connection: close
這個表示該連接為短連接,換句話說就是傳輸一個來回之后就關閉連接。
最后,正文可以隨便寫點上面,例如Hello
什么的。于是完成的合法返回串就搞定了:
HTTP/1.1 200 OK
Connection: close
Hello
在代碼中,我們可以寫一個函數(shù)用于在buff中寫入這個返回串:
void setResponse(char *buff)
{
bzero(buff, sizeof(buff));
strcat(buff, 'HTTP/1.1 200 OK\r\n');
strcat(buff, 'Connection: close\r\n');
strcat(buff, '\r\n');
strcat(buff, 'Hello\n');
}
然后在main()
中的recv()
之后,send()
之前調(diào)用該函數(shù)就可以了。
setResponse(buff);
接著把更新好的HTTP服務器放到機子上運行,再使用curl
試一遍:
$ curl -v 'http://192.168.19.12:16555/'
* About to connect() to 192.168.19.12 port 16555 (#0)
* Trying 192.168.19.12... connected
* Connected to 192.168.19.12 (192.168.19.12) port 16555 (#0)
> GET / HTTP/1.1
> User-Agent: curl/7.19.7 (x86_64-redhat-linux-gnu) libcurl/7.19.7 NSS/3.27.1 zlib/1.2.3 libidn/1.18 libssh2/1.4.2
> Host: 192.168.19.12:16555
> Accept: */*
>
< HTTP/1.1 200 OK
< Connection: close
<
Hello
* Closing connection #0
可以得到正確的返回串頭和正文了。
于是,一個簡單的HTTP服務器便搭好了,它的功能是,只要訪問該服務器就會返回Hello
。
4. 壓力測試入門
由于在不同機器上進行壓力測試的結(jié)果不同,故將本次及之后的實驗機器的配置貼出來,以供比對:
CPU:4核64位 Intel(R) Xeon(R) CPU E5-2630 v2 @ 2.60GHz
內(nèi)存:10GB
操作系統(tǒng):Tencent tlinux release 1.2 (Final)
介紹了這么多,我們一直都只關注服務器能不能跑,卻沒有關注過服務器能力強不強。
怎樣才算強呢?一般而言搭一個能正確響應請求的服務器是不難的,但搭建一個可以在大量請求下仍能正確響應請求的服務器就很難了,這里的大量請求一般指的有:
于是要怎么進行壓力測試呢?由于我們的服務器是HTTP服務器,故這個時候就可以直接使用Apache Bench壓力測試工具了。
由于這個工具的測試方式是模擬大量的HTTP請求,故無法適用于之前的裸socket服務器,所以只能測試現(xiàn)在的HTTP服務器。
使用方法很簡答,直接運行以下指令即可:
ab -c 1 -n 10000 'http://192.168.19.12:16555/'
這個指令中,-c
后面跟著的數(shù)字表示請求并發(fā)數(shù),-n
后面跟著的數(shù)字表示總請求數(shù)。于是上面的指令表示的就是【并發(fā)數(shù)為1,一共10000條請求】,其實就是相當于我們直接curl
10000次。
執(zhí)行之后的效果如下:
$ ab -c 1 -n 10000 'http://192.168.19.12:16555/'
This is ApacheBench, Version 2.3 <$Revision: 655654 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www./
Licensed to The Apache Software Foundation, http://www./
Benchmarking 192.168.19.12 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests
Server Software:
Server Hostname: 192.168.19.12
Server Port: 16555
Document Path: /
Document Length: 6 bytes
Concurrency Level: 1
Time taken for tests: 3.620 seconds
Complete requests: 10000
Failed requests: 0
Write errors: 0
Total transferred: 440000 bytes
HTML transferred: 60000 bytes
Requests per second: 2762.46 [#/sec] (mean)
Time per request: 0.362 [ms] (mean)
Time per request: 0.362 [ms] (mean, across all concurrent requests)
Transfer rate: 118.70 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.0 0 0
Processing: 0 0 12.1 0 670
Waiting: 0 0 12.1 0 670
Total: 0 0 12.1 0 670
Percentage of the requests served within a certain time (ms)
50% 0
66% 0
75% 0
80% 0
90% 0
95% 0
98% 0
99% 0
100% 670 (longest request)
其中比較重要的有:
Failed requests:失敗請求數(shù)。
Requests per second:每秒處理的請求數(shù),也就是吞吐率。
Transfer rate:傳輸速率,表示每秒收到多少的數(shù)據(jù)量。
最下面的表:表示百分之xx的請求數(shù)的響應時間的分布,可以比較直觀的看出請求響應時間分布。
在這次壓力測試中,撇開其他數(shù)據(jù)不管,至少失敗請求數(shù)是0,已經(jīng)算是能夠用的了(在并發(fā)數(shù)為1的情況下)。
那么,更高的請求量呢?例如10000并發(fā),100000請求數(shù)呢:
ab -c 10000 -n 100000 -r 'http://192.168.19.12:16555/'
這里加上-r
是為了讓其在出錯的時候也繼續(xù)壓測(這么大數(shù)據(jù)量肯定會有請求錯誤的)
結(jié)果如下(省略部分輸出,用...
表示省略的輸出):
$ ab -c 10000 -n 100000 -r 'http://192.168.19.12:16555/'
...
Complete requests: 100000
Failed requests: 34035
(Connect: 0, Receive: 11345, Length: 11345, Exceptions: 11345)
Write errors: 0
Total transferred: 4133096 bytes
HTML transferred: 563604 bytes
Requests per second: 3278.15 [#/sec] (mean)
Time per request: 3050.501 [ms] (mean)
Time per request: 0.305 [ms] (mean, across all concurrent requests)
Transfer rate: 132.31 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 481 1061.9 146 7392
Processing: 31 1730 3976.7 561 15361
Waiting: 0 476 319.3 468 10064
Total: 175 2210 3992.2 781 15361
Percentage of the requests served within a certain time (ms)
50% 781
66% 873
75% 1166
80% 1783
90% 4747
95% 15038
98% 15076
99% 15087
100% 15361 (longest request)
可以看出,這個時候的失敗請求數(shù)已經(jīng)飆到一個難以忍受的地步了(34%
的失敗率啊。。),而且請求響應時長也十分的長(甚至有到15秒的),這顯然已經(jīng)足夠證明在這種并發(fā)量和請求數(shù)的情況下,我們的服務器宛如一個土豆。
一個優(yōu)化的Tip
那么在當前階段下要怎么優(yōu)化這個服務器呢?注意到服務器端在每接收到一個請求的時候都會將收到的內(nèi)容在屏幕上打印出來。要知道這種與輸出設備交互的IO是很慢的,于是這便是一個要優(yōu)化掉的點。
考慮到日志是必須的(雖然這僅僅是將收到的內(nèi)容打印出來,不算嚴格意義上的日志),我們不能直接去掉日志打印,故我們可以嘗試將日志打印轉(zhuǎn)為文件輸出。
首先,先寫一個用于在文件中打日志的類:
#define LOG_BUFFSIZE 65536
class Logger
{
char buff[LOG_BUFFSIZE];
int buffLen;
FILE *fp;
public:
Logger()
{
bzero(buff, sizeof(buff));
buffLen = 0;
fp = fopen('TrainServer.log', 'a');
}
void Flush()
{
fputs(buff, fp);
bzero(buff, sizeof(buff));
buffLen = 0;
}
void Log(const char *str, int len)
{
if (buffLen + len > LOG_BUFFSIZE - 10)
{
Flush();
}
for (int i = 0; i < len; i++)
{
buff[buffLen] = str[i];
buffLen++;
}
}
~Logger()
{
if (buffLen != 0)
{
Flush();
}
fclose(fp);
}
}logger;
這里使用了一個長的字符串作為日志緩沖區(qū),每次寫日志的時候往日志緩沖區(qū)中寫,直到緩沖區(qū)快滿了或者進程終止的時候才把緩沖區(qū)的內(nèi)容一次性寫入文件中。這樣便能減少文件讀寫次數(shù)。
那么在打日志的位置便可以直接調(diào)用Log()
方法:
// 替換掉printf('Recv: %s\n', buff);
logger.Log('Recv: ', 6);
logger.Log(buff, strlen(buff));
接著我們將服務器部署上去,然后用ab
指令發(fā)送一個請求(并發(fā)數(shù)1,請求總數(shù)1),可以看到目錄下就生成了日志文件:
$ ls
TrainClient.cpp TrainClient.o TrainServer.cpp TrainServer.log TrainServer.o
打開日志可以看到這個內(nèi)容跟之前的屏幕輸出一致。統(tǒng)計行數(shù)可以得到單次成功的請求所記錄的日志一共有5行:
$ cat TrainServer.log
Recv: GET / HTTP/1.0
Host: 192.168.19.12:16555
User-Agent: ApacheBench/2.3
Accept: */*
$ cat TrainServer.log | wc -l
5
接著我們測試一下在一定規(guī)模的數(shù)據(jù)下日志是否能正常工作。這個時候?qū)⒄埱罅考哟螅?/p>
ab -c 100 -n 1000 'http://192.168.19.12:16555/'
結(jié)果如下(省略部分輸出,用...
表示省略的輸出):
$ ab -c 1 -n 10000 'http://192.168.19.12:16555/'
...
Complete requests: 10000
Failed requests: 0
Write errors: 0
Total transferred: 440000 bytes
HTML transferred: 60000 bytes
Requests per second: 15633.89 [#/sec] (mean)
Time per request: 0.064 [ms] (mean)
Time per request: 0.064 [ms] (mean, across all concurrent requests)
Transfer rate: 671.77 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.0 0 0
Processing: 0 0 0.0 0 0
Waiting: 0 0 0.0 0 0
Total: 0 0 0.0 0 0
Percentage of the requests served within a certain time (ms)
50% 0
66% 0
75% 0
80% 0
90% 0
95% 0
98% 0
99% 0
100% 0 (longest request)
可以看到這10000次請求沒有失敗請求,故如果日志正確記錄的話應該會有50000行。
于是我們查看一下日志行數(shù):
$ cat TrainServer.log | wc -l
50000
一切正常。必要的話還可以用cat
或者head
隨機檢查日志內(nèi)容。
接著就可以試一下改良后的服務器的性能了,還是一萬并發(fā)十萬請求:
$ ab -c 10000 -n 100000 -r 'http://192.168.19.12:16555/'
...
Complete requests: 100000
Failed requests: 1164
(Connect: 0, Receive: 388, Length: 388, Exceptions: 388)
Write errors: 0
Total transferred: 4471368 bytes
HTML transferred: 609732 bytes
Requests per second: 5503.42 [#/sec] (mean)
Time per request: 1817.053 [ms] (mean)
Time per request: 0.182 [ms] (mean, across all concurrent requests)
Transfer rate: 240.31 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 1149 1572.6 397 7430
Processing: 36 362 972.8 311 15595
Waiting: 0 229 250.7 217 15427
Total: 193 1511 1845.6 780 16740
Percentage of the requests served within a certain time (ms)
50% 780
66% 1476
75% 1710
80% 1797
90% 3695
95% 3825
98% 7660
99% 7817
100% 16740 (longest request)