从零开始的C++网络编程
導語:本文主要介紹如何從零開始搭建簡單的C++客戶端/服務器,并進行簡單的講解和基礎的壓力測試演示。該文章相對比較入門,主要面向了解計算機網絡但未接觸過網絡編程的同學。
本文主要分為四個部分:
搭建C/S:用C++搭建一個最簡單的,基于socket網絡編程的客戶端和服務器
socket庫函數淺析:基于上一節搭建的客戶端和服務器的代碼介紹相關的庫函數
搭建HTTP服務器:基于上一節的介紹和HTTP工作過程將最開始搭建的服務器改為HTTP服務器
壓力測試入門:優化一下服務器,并使用ab工具對優化前后的服務器進行壓力測試并對比結果
1. 搭建C/S
本節主要講述如何使用C++搭建一個簡單的socket服務器和客戶端。
為了能更加容易理解如何搭建,本節會省略許多細節和函數解釋,對于整個連接的過程的描述也會比較抽象,細節和解析會留到之后再講。
服務端和客戶端的預期功能
這里要實現的服務端的功能十分簡單,只需要把任何收到的數據原封不動地發回去即可,也就是所謂的ECHO服務器。
客戶端要做的事情也十分簡單,讀取用戶輸入的一個字符串并發送給服務端,然后把接收到的數據輸出出來即可。
服務端搭建
將上面的需求轉化一下就可以得到如下形式:
while(true) {buff?=?接收到的數據;將buff的數據發回去; }當然,上面的偽代碼是省略掉網絡連接和斷開的過程。這個例子使用的連接形式為TCP連接,而在一個完整的TCP連接中,服務端和客戶端通信需要做三件事:
服務端與客戶端進行連接
服務端與客戶端之間傳輸數據
服務端與客戶端之間斷開連接
將這些加入偽代碼中,便可以得到如下偽代碼:
while(true) {與客戶端建立連接;buff?=?接收到從客戶端發來的數據;將buff的數據發回客戶端;與客戶端斷開連接; }首先需要解決的就是,如何建立連接。
在socket編程中,服務端和客戶端是靠socket進行連接的。服務端在建立連接之前需要做的有:
創建socket(偽代碼中簡稱為socket())
將socket與指定的IP和端口(以下簡稱為port)綁定(偽代碼中簡稱為bind())
讓socket在綁定的端口處監聽請求(等待客戶端連接到服務端綁定的端口)(偽代碼中簡稱為listen())
而客戶端發送連接請求并成功連接之后(這個步驟在偽代碼中簡稱為accept()),服務端便會得到客戶端的套接字,于是所有的收發數據便可以在這個客戶端的套接字上進行了。
而收發數據其實就是:
接收數據:使用客戶端套接字拿到客戶端發來的數據,并將其存于buff中。(偽代碼中簡稱為recv())
發送數據:使用客戶端套接字,將buff中的數據發回去。(偽代碼中簡稱為send())
在收發數據之后,就需要斷開與客戶端之間的連接。在socket編程中,只需要關閉客戶端的套接字即可斷開連接。(偽代碼中簡稱為close())
將其補充進去得到:
sockfd?=?socket();????//?創建一個socket,賦給sockfd bind(sockfd,?ip::port和一些配置);????//?讓socket綁定端口,同時配置連接類型之類的 listen(sockfd);????????//?讓socket監聽之前綁定的端口 while(true) {connfd?=?accept(sockfd);????//?等待客戶端連接,直到連接成功,之后將客戶端的套接字返回出來recv(connfd,?buff);?//?接收到從客戶端發來的數據,并放入buff中send(connfd,?buff);?//?將buff的數據發回客戶端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?2048int?sockfd,?connfd;????//?定義服務端套接字和客戶端套接字void?stopServerRunning(int?p) {close(sockfd);printf("Close?Server\n");exit(0); }int?main() {struct?sockaddr_in?servaddr;????//?用于存放ip和端口的結構char?buff[BUFFSIZE];????//?用于收發數據//?對應偽代碼中的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;}//?ENDprintf("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;}//?ENDbzero(buff,?BUFFSIZE);//?對應偽代碼中的recv(connfd,?buff);recv(connfd,?buff,?BUFFSIZE?-?1,?0);//?ENDprintf("Recv:?%s\n",?buff);//?對應偽代碼中的send(connfd,?buff);send(connfd,?buff,?strlen(buff),?0);//?END//?對應偽代碼中的close(connfd);close(connfd);//?END}return?0; }客戶端搭建
客戶端相對于服務端來說會簡單一些。它需要做的事情有:
創建socket
使用socket和已知的服務端的ip和port連接服務端
收發數據
關閉連接
其收發數據也是借助自身的套接字來完成的。
轉換為偽代碼如下:
sockfd?=?socket();????//?創建一個socket,賦給sockfd connect(sockfd,?ip::port和一些配置);????//?使用socket向指定的ip和port發起連接 scanf("%s",?buff);????//?讀取用戶輸入 send(sockfd,?buff);????//?發送數據到服務端 recv(sockfd,?buff);????//?從服務端接收數據 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????????????//?指定服務端的portint?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此時服務端也收到了數據并顯示出來:
$?./TrainServer.o? Listening... Recv:?Re0_CppNetworkProgramming你可以在服務端啟動的時候多次打開客戶端并向服務端發送數據,服務端每當收到請求都會處理并返回數據。
當且僅當服務端下按ctrl+c的時候會關閉服務端。
2. socket庫函數淺析
本節會先從TCP連接入手,簡單回顧一下TCP連接的過程。然后再根據上一節的代碼對這個簡單客戶端/服務器的socket通信涉及到的庫函數進行介紹。
注意:本篇中所有函數都按工作在TCP連接的情況下,并且socket默認為阻塞的情況下講解。
TCP連接簡介
什么是TCP協議
在此之前,需要了解網絡的協議層模型。這里不使用OSI七層模型,而是直接通過網際網協議族進行講解。
在網際網協議族中,協議層從上往下如下圖所示:
這個協議層所表示的意義為:如果A機和B機的網絡都是使用(或可以看作是)網際網協議族的話,那么從機子A上發送數據到機子B所經過的路線大致為:
A的應用層→A的傳輸層(TCP/UDP)→A的網絡層(IPv4,IPv6)→A的底層硬件(此時已經轉化為物理信號了)→B的底層硬件→B的網絡層→B的傳輸層→B的應用層
而我們在使用socket(也就是套接字)編程的時候,其實際上便是工作于應用層和傳輸層之間,此時我們可以屏蔽掉底層細節,將網絡傳輸簡化為:
A的應用層→A的傳輸層→B的傳輸層→B的應用層
而如果使用的是TCP連接的socket連接的話,每個數據包的發送的過程大致為:
數據通過socket套接字構造符合TCP協議的數據包
在屏蔽底層協議的情況下,可以理解為TCP層直接將該數據包發往目標機器的TCP層
目標機器解包得到數據
其實不單是TCP,其他協議的單個數據發送過程大致也是如此。
TCP協議和與其處在同一層的UDP協議的區別主要在于其對于連接和應用層數據的處理和發送方式。
如上一節所述,要使用TCP連接收發數據需要做三件事:
建立連接
收發數據
斷開連接
下面將對這三點展開說明:
建立連接:TCP三次握手
在沒進行連接的情況下,客戶端的TCP狀態處于CLOSED狀態,服務端的TCP處于CLOSED(未開啟監聽)或者LISTEN(開啟監聽)狀態。
TCP中,服務端與客戶端建立連接的過程如下:
客戶端主動發起連接(在socket編程中則為調用connect函數),此時客戶端向服務端發送一個SYN包
這個SYN包可以看作是一個小數據包,不過其中沒有任何實際數據,僅有諸如TCP首部和TCP選項等協議包必須數據。可以看作是客戶端給服務端發送的一個信號
此時客戶端狀態從CLOSED切換為SYN_SENT
服務端收到SYN包,并返回一個針對該SYN包的響應包(ACK包)和一個新的SYN包。
在socket編程中,服務端能收到SYN包的前提是,服務端已經調用過listen函數使其處于監聽狀態(也就是說,其必須處于LISTEN狀態),并且處于accept函數等待連接的阻塞狀態。
此時服務端狀態從LISTEN切換為SYN_RCVD
客戶端收到服務端發來的兩個包,并返回針對新的SYN包的ACK包。
此時客戶端狀態從SYN_SENT切換至ESTABLISHED,該狀態表示可以傳輸數據了。
服務端收到ACK包,成功建立連接,accept函數返回出客戶端套接字。
此時服務端狀態從SYN_RCVD切換至ESTABLISHED
收發數據
當連接建立之后,就可以通過客戶端套接字進行收發數據了。
斷開連接:TCP四次揮手
在收發數據之后,如果需要斷開連接,則斷開連接的過程如下:
雙方中有一方(假設為A,另一方為B)主動關閉連接(調用close,或者其進程本身被終止等情況),則其向B發送FIN包
此時A從ESTABLISHED狀態切換為FIN_WAIT_1狀態
B接收到FIN包,并發送ACK包
此時B從ESTABLISHED狀態切換為CLOSE_WAIT狀態
A接收到ACK包
此時A從FIN_WAIT_1狀態切換為FIN_WAIT_2狀態
一段時間后,B調用自身的close函數,發送FIN包
此時B從CLOSE_WAIT狀態切換為LAST_ACK狀態
A接收到FIN包,并發送ACK包
此時A從FIN_WAIT_2狀態切換為TIME_WAIT狀態
B接收到ACK包,關閉連接
此時B從LAST_ACK狀態切換為CLOSED狀態
A等待一段時間(兩倍的最長生命周期)后,關閉連接
此時A從TIME_WAIT狀態切換為CLOSED狀態
socket函數
根據上節可以知道,socket函數用于創建套接字。其實更嚴謹的講是創建一個套接字描述符(以下簡稱sockfd)。
套接字描述符本質上類似于文件描述符,文件通過文件描述符供程序進行讀寫,而套接字描述符本質上也是提供給程序可以對其緩存區進行讀寫,程序在其寫緩存區寫入數據,寫緩存區的數據通過網絡通信發送至另一端的相同套接字的讀緩存區,另一端的程序使用相同的套接字在其讀緩存區上讀取數據,這樣便完成了一次網絡數據傳輸。
而socket函數的參數便是用于設置這個套接字描述符的屬性。
該函數的原型如下:
#include?<sys/socket.h>int?socket(int?family,?int?type,?int?protocol);family參數
該參數指明要創建的sockfd的協議族,一般比較常用的有兩個:
AF_INET:IPv4協議族
AF_INET6:IPv6協議族
type參數
該參數用于指明套接字類型,具體有:
SOCK_STREAM:字節流套接字,適用于TCP或SCTP協議
SOCK_DGRAM:數據報套接字,適用于UDP協議
SOCK_SEQPACKET:有序分組套接字,適用于SCTP協議
SOCK_RAW:原始套接字,適用于繞過傳輸層直接與網絡層協議(IPv4/IPv6)通信
protocol參數
該參數用于指定協議類型。
如果是TCP協議的話就填寫IPPROTO_TCP,UDP和SCTP協議類似。
也可以直接填寫0,這樣的話則會默認使用family參數和type參數組合制定的默認協議
(參照上面type參數的適用協議)
返回值
socket函數在成功時會返回套接字描述符,失敗則返回-1。
失敗的時候可以通過輸出errno來詳細查看具體錯誤類型。
關于errno
通常一個內核函數運行出錯的時候,它會定義全局變量errno并賦值。
當我們引入errno.h頭文件時便可以使用這個變量。并利用這個變量查看具體出錯原因。
一共有兩種查看的方法:
直接輸出errno,根據輸出的錯誤碼進行Google搜索解決方案
當然也可以直接翻man手冊
借助strerror()函數,使用strerror(errno)得到一個具體描述其錯誤的字符串。一般可以通過其描述定位問題所在,實在不行也可以拿這個輸出去Google搜索解決方案
bind函數
根據上節可以知道,bind函數用于將套接字與一個ip::port綁定。或者更應該說是把一個本地協議地址賦予一個套接字。
該函數的原型如下:
#include?<sys/socket.h>int?bind(int?sockfd,?const?struct?sockaddr?*myaddr,?socklen_t?addrlen);這個函數的參數表比較簡單:第一個是套接字描述符,第二個是套接字地址結構體,第三個是套接字地址結構體的長度。其含義就是將第二個的套接字地址結構體賦給第一個的套接字描述符所指的套接字。
接下來著重講一下套接字地址結構體
套接字地址結構體
在bind函數的參數表中出現了一個名為sockaddr的結構體,這個便是用于存儲將要賦給套接字的地址結構的通用套接字地址結構。其定義如下:
#include?<sys/socket.h>struct?sockaddr {uint8_t?????sa_len;sa_family_t?sa_family;??????//?地址協議族char????????sa_data[14];????//?地址數據 };當然,我們一般不會直接使用這個結構來定義套接字地址結構體,而是使用更加特定化的IPv4套接字地址結構體或IPv6套接字地址結構體。這里只講前者。
IPv4套接字地址結構體的定義如下:
#include?<netinet/in.h>struct?in_addr {in_addr_t???????s_addr;?????????//?32位IPv4地址 }; struct?sockaddr_in {uint8_t?????????sin_len;????????//?結構長度,非必需sa_family_t?????sin_family;?????//?地址族,一般為AF_****格式,常用的是AF_INETin_port_t???????sin_port;???????//?16位TCP或UDP端口號struct?in_addr??sin_addr;???????//?32位IPv4地址char????????????sin_zero[8];????//?保留數據段,一般置零 };值得注意的是,一般而言一個sockaddr_in結構對我們來說有用的字段就三個:
sin_family
sin_addr
sin_port
可以看到在第一節的代碼中也是只賦值了這三個成員:
#define?DEFAULT_PORT?16555//?...struct?sockaddr_in?servaddr;????//?定義一個IPv4套接字地址結構體//?...bzero(&servaddr,?sizeof(servaddr));????//?將該結構體的所有數據置零 servaddr.sin_family?=?AF_INET;????//?指定其協議族為IPv4協議族 servaddr.sin_addr.s_addr?=?htonl(INADDR_ANY);????//?指定IP地址為通配地址 servaddr.sin_port?=?htons(DEFAULT_PORT);????//?指定端口號為16555//?調用bind,注意第二個參數使用了類型轉換,第三個參數直接取其sizeof即可 if?(-1?==?bind(sockfd,?(struct?sockaddr*)&servaddr,?sizeof(servaddr))) {printf("Bind?error(%d):?%s\n",?errno,?strerror(errno));return?-1; }其中有三個細節需要注意:
在指定IP地址的時候,一般就是使用像上面那樣的方法指定為通配地址,此時就交由內核選擇IP地址綁定。指定特定IP的操作在講connect函數的時候會提到。
在指定端口的時候,可以直接指定端口號為0,此時表示端口號交由內核選擇(也就是進程不指定端口號)。但一般而言對于服務器來說,不指定端口號的情況是很罕見的,因為服務器一般都需要暴露一個端口用于讓客戶端知道并作為連接的參數。
注意到不管是賦值IP還是端口,都不是直接賦值,而是使用了類似htons()或htonl()的函數,這便是字節排序函數。
字節排序函數
首先,不同的機子上對于多字節變量的字節存儲順序是不同的,有大端字節序和小端字節序兩種。
那這就意味著,將機子A的變量原封不動傳到機子B上,其值可能會發生變化(本質上數據沒有變化,但如果兩個機子的字節序不一樣的話,解析出來的值便是不一樣的)。這顯然是不好的。
故我們需要引入一個通用的規范,稱為網絡字節序。引入網絡字節序之后的傳遞規則就變為:
機子A先將變量由自身的字節序轉換為網絡字節序
發送轉換后的數據
機子B接到轉換后的數據之后,再將其由網絡字節序轉換為自己的字節序
其實就是很常規的統一標準中間件的做法。
在Linux中,位于<netinet/in.h>中有四個用于主機字節序和網絡字節序之間相互轉換的函數:
#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函數
listen函數的作用就是開啟套接字的監聽狀態,也就是將套接字從CLOSE狀態轉換為LISTEN狀態。
該函數的原型如下:
#include?<sys/socket.h>int?listen(int?sockfd,?int?backlog);其中,sockfd為要設置的套接字,backlog為服務器處于LISTEN狀態下維護的隊列長度和的最大值。
關于backlog
這是一個可調參數。
其意義為,服務器套接字處于LISTEN狀態下所維護的未完成連接隊列(SYN隊列)和已完成連接隊列(Accept隊列)的長度和的最大值。
↑ 這個是原本的意義,現在的backlog僅指Accept隊列的最大長度,SYN隊列的最大長度由系統的另一個變量決定。
這兩個隊列用于維護與客戶端的連接,其中:
客戶端發送的SYN到達服務器之后,服務端返回SYN/ACK,并將該客戶端放置SYN隊列中(第一次+第二次握手)
當服務端接收到客戶端的ACK之后,完成握手,服務端將對應的連接從SYN隊列中取出,放入Accept隊列,等待服務器中的accept接收并處理其請求(第三次握手)
backlog調參
backlog是由程序員決定的,不過最后的隊列長度其實是min(backlog, /proc/sys/net/core/somaxconn , net.ipv4.tcp_max_syn_backlog ),后者直接讀取對應位置文件就有了。
不過由于后者是可以修改的,故這里討論的backlog實際上是這兩個值的最小值。
至于如何調參,可以參考這篇博客:
https://ylgrgyq.github.io/2017/05/18/tcp-backlog/
事實上backlog僅僅是與Accept隊列的最大長度相關的參數,實際的隊列最大長度視不同的操作系統而定。例如說MacOS上使用傳統的Berkeley算法基于backlog參數進行計算,而Linux2.4.7上則是直接等于backlog+3。
返回值
若成功則返回0,否則返回-1并置相應的errno。
connect函數
該函數用于客戶端跟綁定了指定的ip和port并且處于LISTEN狀態的服務端進行連接。
在調用connect函數的時候,調用方(也就是客戶端)便會主動發起TCP三次握手。
該函數的原型如下:
#include?<sys/socket.h>int?connect(int?sockfd,?const?struct?sockaddr?*myaddr,?socklen_t?addrlen);其中第一個參數為客戶端套接字,第二個參數為用于指定服務端的ip和port的套接字地址結構體,第三個參數為該結構體的長度。
操作上比較類似于服務端使用bind函數(雖然做的事情完全不一樣),唯一的區別在于指定ip這塊。服務端調用bind函數的時候無需指定ip,但客戶端調用connect函數的時候則需要指定服務端的ip。
在客戶端的代碼中,令套接字地址結構體指定ip的代碼如下:
inet_pton(AF_INET,?SERVER_IP,?&servaddr.sin_addr);這個就涉及到ip地址的表達格式與數值格式相互轉換的函數。
IP地址格式轉換函數
IP地址一共有兩種格式:
表達格式:也就是我們能看得懂的格式,例如"192.168.19.12"這樣的字符串
數值格式:可以存入套接字地址結構體的格式,數據類型為整型
顯然,當我們需要將一個IP賦進套接字地址結構體中,就需要將其轉換為數值格式。
在<arpa/inet.h>中提供了兩個函數用于IP地址格式的相互轉換:
#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()函數用于將IP地址從表達格式轉換為數值格式
第一個參數指定協議族(AF_INET或AF_INET6)
第二個參數指定要轉換的表達格式的IP地址
第三個參數指定用于存儲轉換結果的指針
對于返回結果而言:
若轉換成功則返回1
若表達格式的IP地址格式有誤則返回0
若出錯則返回-1
inet_ntop()函數用于將IP地址從數值格式轉換為表達格式
第一個參數指定協議族
第二個參數指定要轉換的數值格式的IP地址
第三個參數指定用于存儲轉換結果的指針
第四個參數指定第三個參數指向的空間的大小,用于防止緩存區溢出
第四個參數可以使用預設的變量:
- #include?<netinet/in.h>#define?INET_ADDRSTRLEN????16??//?IPv4地址的表達格式的長度 #define?INET6_ADDRSTRLEN?46????//?IPv6地址的表達格式的長度
對于返回結果而言
若轉換成功則返回指向返回結果的指針
若出錯則返回NULL
返回值
若成功則返回0,否則返回-1并置相應的errno。
其中connect函數會出錯的幾種情況:
若客戶端在發送SYN包之后長時間沒有收到響應,則返回ETIMEOUT錯誤
一般而言,如果長時間沒有收到響應,客戶端會重發SYN包,若超過一定次數重發仍沒響應的話則會返回該錯誤
可能的原因是目標服務端的IP地址不存在
若客戶端在發送SYN包之后收到的是RST包的話,則會立刻返回ECONNREFUSED錯誤
當客戶端的SYN包到達目標機之后,但目標機的對應端口并沒有正在LISTEN的套接字,那么目標機會發一個RST包給客戶端
可能的原因是目標服務端沒有運行,或者沒運行在客戶端知道的端口上
若客戶端在發送SYN包的時候在中間的某一臺路由器上發生ICMP錯誤,則會發生EHOSTUNREACH或ENETUNREACH錯誤
事實上跟處理未響應一樣,為了排除偶然因素,客戶端遇到這個問題的時候會保存內核信息,隔一段時間之后再重發SYN包,在多次發送失敗之后才會報錯
路由器發生ICMP錯誤的原因是,路由器上根據目標IP查找轉發表但查不到針對目標IP應該如何轉發,則會發生ICMP錯誤
可能的原因是目標服務端的IP地址不可達,或者路由器配置錯誤,也有可能是因為電波干擾等隨機因素導致數據包錯誤,進而導致路由無法轉發
由于connect函數在發送SYN包之后就會將自身的套接字從CLOSED狀態置為SYN_SENT狀態,故當connect報錯之后需要主動將套接字狀態置回CLOSED。此時需要通過調用close函數主動關閉套接字實現。
故原版的客戶端代碼需要做一個修改:
if?(-1?==?connect(sockfd,?(struct?sockaddr*)&servaddr,?sizeof(servaddr))) {printf("Connect?error(%d):?%s\n",?errno,?strerror(errno));close(sockfd);????????//?新增代碼,當connect出錯時需要關閉套接字return?-1; }accept函數
根據上一節所述,該函數用于跟客戶端建立連接,并返回客戶端套接字。
更準確的說,accept函數由TCP服務器調用,用于從Accept隊列中pop出一個已完成的連接。若Accept隊列為空,則accept函數所在的進程阻塞。
該函數的原型如下:
#include?<sys/socket.h>int?accept(int?sockfd,?struct?sockaddr?*cliaddr,?socklen_t?*addrlen);其中第一個參數為服務端自身的套接字,第二個參數用于接收客戶端的套接字地址結構體,第三個參數用于接收第二個參數的結構體的長度。
返回值
當accept函數成功拿到一個已完成連接時,其會返回該連接對應的客戶端套接字描述符,用于后續的數據傳輸。
若發生錯誤則返回-1并置相應的errno。
recv函數&send函數
recv函數用于通過套接字接收數據,send函數用于通過套接字發送數據
這兩個函數的原型如下:
#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);其中:
第一個參數為要讀寫的套接字
第二個參數指定要接收數據的空間的指針(recv)或要發送的數據(send)
第三個參數指定最大讀取的字節數(recv)或發送的數據的大小(send)
第四個參數用于設置一些參數,默認為0
目前用不到第四個參數,故暫時不做展開
事實上,去掉第四個參數的情況下,recv跟read函數類似,send跟write函數類似。這兩個函數的本質也是一種通過描述符進行的IO,只是在這里的描述符為套接字描述符。
返回值
在recv函數中:
若成功,則返回所讀取到的字節數
否則返回-1,置errno
在send函數中:
若成功,則返回成功寫入的字節數
事實上,當返回值與nbytes不等時,也可以認為其出錯。
否則返回-1,置errno
close函數
根據第一節所述,該函數用于斷開連接。或者更具體的講,該函數用于關閉套接字,并終止TCP連接。
該函數的原型如下:
#include?<unistd.h>int?close(int?sockfd);返回值
同樣的,若close成功則返回0,否則返回-1并置errno。
常見的錯誤為關閉一個無效的套接字。
3. 搭建HTTP服務器
本節將會將最開始的簡單服務器改為可以接收并處理HTTP請求的HTTP服務器。
在改裝之前,首先需要明白HTTP服務器能做什么。
所謂HTTP服務器,通俗點說就是可以使用像http://192.168.19.12:16555/這樣的URL進行服務器請求,并且能得到一個合法的返回。
其實之前搭的服務器已經可以處理這種HTTP請求了,只是請求的返回不合法罷了(畢竟只是把發送的數據再回傳一遍)。在這里可以做個試驗,看看現階段的服務器是如何處理HTTP請求的:
首先,開啟服務器:
$?./TrainServer.o? Listening...之后,另開一個命令行,使用curl指令發送一個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:?*/*便是接收到的返回數據,我們可以通過服務器自己輸出的日志確認這一點:
$?./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/訪問服務器的時候,其實就相當于發這一長串東西給服務器。
事實上這一串東西就是HTTP請求串,其格式如下:
方法名?URL?協議版本??//請求行 字段名:字段值???????//消息報頭 字段名:字段值???????//消息報頭 ... 字段名:字段值???????//消息報頭請求正文???????????//可選每一行都以\r\n結尾,表示一個換行。
于是對應的就有一個叫做HTTP返回串的東西,這個也是有格式規定的:
協議版本?狀態碼?狀態描述?//狀態行 字段名:字段值???????//消息報頭 字段名:字段值???????//消息報頭 ... 字段名:字段值???????//消息報頭響應正文???????????//可選其中,狀態碼有如下的幾種:
1xx:指示信息,表示請求已接收,繼續處理
2xx:成功,表示請求已被成功接收、理解、接受
3xx:重定向,要完成請求必須進行更進一步的操作
4xx:客戶端錯誤,請求有語法錯誤或請求無法實現
5xx:服務器端錯誤,服務器未能實現合法的請求
比較常見的就有200(OK),404(Not Found),502(Bad Gateway)。
顯然我們需要返回一個成功的HTTP返回串,故這里就需要使用200,于是第一行就可以是:
HTTP/1.1?200?OK至于字段名及其對應的字段值則按需加就行了,具體的可以上網查有哪些選項。
這里為了簡潔就只加一個就行了:
Connection:?close這個表示該連接為短連接,換句話說就是傳輸一個來回之后就關閉連接。
最后,正文可以隨便寫點上面,例如Hello什么的。于是完成的合法返回串就搞定了:
HTTP/1.1?200?OK Connection:?closeHello在代碼中,我們可以寫一個函數用于在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()之前調用該函數就可以了。
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. 壓力測試入門
由于在不同機器上進行壓力測試的結果不同,故將本次及之后的實驗機器的配置貼出來,以供比對:
CPU:4核64位 Intel(R) Xeon(R) CPU E5-2630 v2 @ 2.60GHz
內存:10GB
操作系統:Tencent tlinux release 1.2 (Final)
介紹了這么多,我們一直都只關注服務器能不能跑,卻沒有關注過服務器能力強不強。
怎樣才算強呢?一般而言搭一個能正確響應請求的服務器是不難的,但搭建一個可以在大量請求下仍能正確響應請求的服務器就很難了,這里的大量請求一般指的有:
總的請求數多
請求并發量大
于是要怎么進行壓力測試呢?由于我們的服務器是HTTP服務器,故這個時候就可以直接使用Apache Bench壓力測試工具了。
由于這個工具的測試方式是模擬大量的HTTP請求,故無法適用于之前的裸socket服務器,所以只能測試現在的HTTP服務器。
使用方法很簡答,直接運行以下指令即可:
ab?-c?1?-n?10000?"http://192.168.19.12:16555/"這個指令中,-c后面跟著的數字表示請求并發數,-n后面跟著的數字表示總請求數。于是上面的指令表示的就是【并發數為1,一共10000條請求】,其實就是相當于我們直接curl10000次。
執行之后的效果如下:
$?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.zeustech.net/ Licensed?to?The?Apache?Software?Foundation,?http://www.apache.org/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?requestsServer?Software:???????? Server?Hostname:?????? ?192.168.19.12 Server?Port:????????????16555Document?Path:??????????/ Document?Length:????????6?bytesConcurrency?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]?receivedConnection?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?????670Percentage?of?the?requests?served?within?a?certain?time?(ms)50%??????066%??????075%??????080%??????090%??????095%??????098%??????099%??????0100%????670?(longest?request)其中比較重要的有:
Failed requests:失敗請求數。
Requests per second:每秒處理的請求數,也就是吞吐率。
Transfer rate:傳輸速率,表示每秒收到多少的數據量。
最下面的表:表示百分之xx的請求數的響應時間的分布,可以比較直觀的看出請求響應時間分布。
在這次壓力測試中,撇開其他數據不管,至少失敗請求數是0,已經算是能夠用的了(在并發數為1的情況下)。
那么,更高的請求量呢?例如10000并發,100000請求數呢:
ab?-c?10000?-n?100000?-r?"http://192.168.19.12:16555/"這里加上-r是為了讓其在出錯的時候也繼續壓測(這么大數據量肯定會有請求錯誤的)
結果如下(省略部分輸出,用...表示省略的輸出):
$?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]?receivedConnection?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???15361Percentage?of?the?requests?served?within?a?certain?time?(ms)50%????78166%????87375%???116680%???178390%???474795%??1503898%??1507699%??15087100%??15361?(longest?request)可以看出,這個時候的失敗請求數已經飆到一個難以忍受的地步了(34%的失敗率啊。。),而且請求響應時長也十分的長(甚至有到15秒的),這顯然已經足夠證明在這種并發量和請求數的情況下,我們的服務器宛如一個土豆。
一個優化的Tip
那么在當前階段下要怎么優化這個服務器呢?注意到服務器端在每接收到一個請求的時候都會將收到的內容在屏幕上打印出來。要知道這種與輸出設備交互的IO是很慢的,于是這便是一個要優化掉的點。
考慮到日志是必須的(雖然這僅僅是將收到的內容打印出來,不算嚴格意義上的日志),我們不能直接去掉日志打印,故我們可以嘗試將日志打印轉為文件輸出。
首先,先寫一個用于在文件中打日志的類:
#define?LOG_BUFFSIZE?65536class?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;這里使用了一個長的字符串作為日志緩沖區,每次寫日志的時候往日志緩沖區中寫,直到緩沖區快滿了或者進程終止的時候才把緩沖區的內容一次性寫入文件中。這樣便能減少文件讀寫次數。
那么在打日志的位置便可以直接調用Log()方法:
//?替換掉printf("Recv:?%s\n",?buff); logger.Log("Recv:?",?6); logger.Log(buff,?strlen(buff));接著我們將服務器部署上去,然后用ab指令發送一個請求(并發數1,請求總數1),可以看到目錄下就生成了日志文件:
$?ls TrainClient.cpp??TrainClient.o??TrainServer.cpp??TrainServer.log??TrainServer.o打開日志可以看到這個內容跟之前的屏幕輸出一致。統計行數可以得到單次成功的請求所記錄的日志一共有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接著我們測試一下在一定規模的數據下日志是否能正常工作。這個時候將請求量加大:
ab?-c?100?-n?1000?"http://192.168.19.12:16555/"結果如下(省略部分輸出,用...表示省略的輸出):
$?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]?receivedConnection?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???????0Percentage?of?the?requests?served?within?a?certain?time?(ms)50%??????066%??????075%??????080%??????090%??????095%??????098%??????099%??????0100%??????0?(longest?request)可以看到這10000次請求沒有失敗請求,故如果日志正確記錄的話應該會有50000行。
于是我們查看一下日志行數:
$?cat?TrainServer.log?|?wc?-l 50000一切正常。必要的話還可以用cat或者head隨機檢查日志內容。
接著就可以試一下改良后的服務器的性能了,還是一萬并發十萬請求:
$?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]?receivedConnection?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???16740Percentage?of?the?requests?served?within?a?certain?time?(ms)50%????78066%???147675%???171080%???179790%???369595%???382598%???766099%???7817100%??16740?(longest?request) 與優化前的服務器性能對比如下:可以看到,相比起來整體還是優化了不少了,尤其是失敗率,從34%下降到不到2%。總結
本文通過一個簡單的C++客戶端/服務器例子講述了C++網絡編程的基礎以及一些關于壓力測試的入門知識。讀者可以借此對C++網絡編程有一個大體的認識,也算是從零開始的C++網絡編程的一個入門吧。
你可能還喜歡
總結
以上是生活随笔為你收集整理的从零开始的C++网络编程的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 关于小程序·云开发峰会,你想get的干货
- 下一篇: 还能这样?把 Python 自动翻译成