从零开始的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 2048

int 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;
    }
    // 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;
}

客户端搭建

客户端相对于服务端来说会简单一些。它需要做的事情有:

  • 创建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            // 指定服务端的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

此时服务端也收到了数据并显示出来:

$ ./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_INET
    in_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_INETAF_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错误,则会发生EHOSTUNREACHENETUNREACH错误
    • 事实上跟处理未响应一样,为了排除偶然因素,客户端遇到这个问题的时候会保存内核信息,隔一段时间之后再重发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: close

Hello

在代码中,我们可以写一个函数用于在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 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:失败请求数。
  • 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] 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)

可以看出,这个时候的失败请求数已经飙到一个难以忍受的地步了(34%的失败率啊。。),而且请求响应时长也十分的长(甚至有到15秒的),这显然已经足够证明在这种并发量和请求数的情况下,我们的服务器宛如一个土豆。

一个优化的Tip

那么在当前阶段下要怎么优化这个服务器呢?注意到服务器端在每接收到一个请求的时候都会将收到的内容在屏幕上打印出来。要知道这种与输出设备交互的IO是很慢的,于是这便是一个要优化掉的点。

考虑到日志是必须的(虽然这仅仅是将收到的内容打印出来,不算严格意义上的日志),我们不能直接去掉日志打印,故我们可以尝试将日志打印转为文件输出

首先,先写一个用于在文件中打日志的类:

#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;

这里使用了一个长的字符串作为日志缓冲区,每次写日志的时候往日志缓冲区中写,直到缓冲区快满了或者进程终止的时候才把缓冲区的内容一次性写入文件中。这样便能减少文件读写次数。

那么在打日志的位置便可以直接调用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] 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行。

于是我们查看一下日志行数:

$ 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] 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)

与优化前的服务器性能对比如下:

可以看到,相比起来整体还是优化了不少了,尤其是失败率,从34%下降到不到2%

总结

本文通过一个简单的C++客户端/服务器例子讲述了C++网络编程的基础以及一些关于压力测试的入门知识。读者可以借此对C++网络编程有一个大体的认识,也算是从零开始的C++网络编程的一个入门吧。

本文分享自微信公众号 - 腾讯技术工程(Tencent_TEG)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-10-22

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券