基于TCP协议的网络服务器的模拟实现

目录

前言(必看)

单进程版本的服务端类TcpServer的模拟实现

服务端类TcpServer的成员变量

服务端类TcpServer的构造函数、初始化函数initServer、析构函数

服务端类TcpServer的start函数(单进程版本)

单进程版本的服务端类TcpServer的整体代码(因为start成员函数是单进程版本,所以TcpServer类就是单进程版本)

基于单进程版本的服务端类TcpServer模拟实现的服务端

tcp_server.cc文件的整体代码

对通过【单进程版本的服务端类TcpServer】实现的网络服务器的测试

单进程版本的服务端类TcpServer的弊端

奇怪的现象

由奇怪的现象得出的结论

多进程版本的服务端类TcpServer的模拟实现

(写法1)服务端类TcpServer的start函数(多进程版本)

(写法2)服务端类TcpServer的start函数(多进程版本)

多进程版本的服务端类TcpServer的整体代码(因为start成员函数是多进程版本,所以TcpServer类就是多进程版本)

对通过【多进程版本的服务端类TcpServer】实现的网络服务器的测试(包括如何实现群聊功能的思路)

客户端的模拟实现

tcp_client.cc的整体代码

加上客户端的代码后再对【通过多进程版本的服务端类TcpServer实现的网络服务器】进行测试

多线程版本的服务端类TcpServer的模拟实现

服务端类TcpServer的start函数(多线程版本)

多线程版本的服务端类TcpServer的整体代码(因为start成员函数是多线程版本,所以TcpServer类就是多线程版本)

对通过【多线程版本的服务端类TcpServer】实现的网络服务器的测试

线程池版本的服务端类TcpServer的模拟实现

服务端类TcpServer的start函数(线程池版本)

线程池版本的服务端类TcpServer的整体代码(因为start成员函数是线程池版本,所以TcpServer类就是线程池版本)

如何更换线程池中新线程的服务呢?

对通过【线程池版本的服务端类TcpServer】实现的网络服务器的测试

通过【线程池版本的服务端类TcpServer】实现的网络服务器的优缺点


前言(必看)

说一下,本文中分类分成单进程版本的服务端类TcpServer、多进程版本的服务端类TcpServer、多线程版本的服务端类TcpServer、线程池版本的服务端类TcpServer只是因为这四个版本的TcpServer类中有一个成员函数start的实现不太一样,至于其他的成员变量或者成员函数在四个版本中都是完全一致的。

然后说一下,本章中所有的代码都是不完善的,读过<<套接字socket编程的基础知识点>>一文中标题为面向字节流的部分就能知道这是因为本章使用【read或者recv】和【write或者send】这些函数时太过于粗糙:

  • 比如没有通过定制协议保证调用【read或者recv】函数时接收到的信息是一个完整的信息(比如接收缓冲区中只有信息的一部分,另一部分还在网络中传输,此时我调用【read或者recv】函数从接收缓冲区中读取到的信息就是一个不完整的信息),拿一个不完整的信息去处理业务肯定是会出问题的;
  • 再比如没有通过定制协给需要调用【write或者send】函数发送出去的数据添加一些用于分隔数据的信息,为什么需要添加这些信息呢?举个例子,对端在调用【read或者recv】函数接收我发过去的数据时,它怎么知道本次读取的信息是完整的还是不完整的呢?如果我不添加一些用于分隔数据的信息,那么对端就绝对无法知道;只有我定制协议比如约定一个完整数据的末尾是@,那么对端读取数据时,发现读取到的信息中有一个@,他就知道读取到了一个完整信息。

除此之外,还因为没有对一些信号进行处理,读过<<套接字socket编程的基础知识点>>一文中标题为write函数的部分就能知道,如果客户端的套接字被关闭,服务端第二次调用write函数向客户端写信息导致服务端收到SIGPIPE信号进而服务端被OS杀死(因为服务端的服务是个循环,所以是一定会调用多次write向客户端发送信息的),这就会出现一种奇葩现象:因为客户端关闭,导致了服务端也跟着被关闭了。这肯定是不合理的,所以需要调用signal函数主动将SIGPIPE信号忽略。

除此之外,在<<传输层协议——TCP协议>>一文中学过TIME_WAIT状态后,我们就知道了本文中还没有调用setsockopt函数把通过socket函数创建出来的套接字的地址复用功能打开,这也是不对的。

单进程版本的服务端类TcpServer的模拟实现

服务端类TcpServer的成员变量

(tips:如果对以下内容感到疑惑,建议结合<<套接字socket编程的基础知识点>>一文阅读)

  • 一个服务器是需要绑定ip和端口号的,不然其他机器找不到该服务器,所以成员中肯定是有_ip和_port的。
  • 在网络中,发信息需要一个通信通道,这个通道为【当前进程--->sockfd指向的文件的文件缓冲区(即分配给该文件的某块内存)--->内核缓冲区--->网卡--->网络--->对方的网卡--->对方的内核缓冲区--->对方的sockfd指向的文件的文件缓冲区--->对方的进程】;接收信息需要一个通信通道,这个通道为【对方进程--->对方进程的sockfd指向的文件的文件缓冲区(即分配给该文件的某块内存)--->对方的内核缓冲区--->对方的网卡--->网络--->当前主机的网卡--->当前主机的内核缓冲区--->当前进程的sockfd指向的文件的文件缓冲区--->当前的进程】。根据前面的理论,可以看出这里在网络中,服务端进程和客户端进程通信肯定是需要通过socket文件的,需要该文件作为通信通道中的一环,所以服务端中肯定是需要一个指向该socket文件的文件描述符_sock的。同时根据<<套接字socket编程的基础知识点>>一文中讲解listen函数时的理论,可知该_sock只做了监听的工作,并不用于直接传输双方进程收发的信息,所以我们把该_sock命名为_listen_sock。

根据上面的理论,服务端类TcpServer的成员变量如下。

#include<iostream>
using namespace std;
#include<string.h>//提供bzero函数
#include<memory>//提供智能指针的库
#include<cstdlib>//提供atoi函数、exit函数
#include <unistd.h>//提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体
 
 
class UdpServer
{
public:
 
private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
};

服务端类TcpServer的构造函数、初始化函数initServer、析构函数

(tips:如果对以下内容感到疑惑,建议结合<<套接字socket编程的基础知识点>>一文阅读)

思路如下:

  • 构造函数的思路:就是把传给构造函数的参数赋值给TcpServer类的成员变量,没啥可说的。
  • 初始化函数的思路:就是先调用socket函数创建套接字socket文件(原因是需要socket文件作为通信通道的一环),然后调用bind函数将【当前进程】和【某个ip地址和某个端口port和刚创建的socket套接字文件】进行绑定(至于其原因,在<<套接字socket编程的基础知识点>>一文中已经说的很明白了)。说一下,下面代码的注释中说明了为什么要先将【ip和port】从主机字节序转化成网络字节序,再将【当前进程】和【这些转化成了网络字节序的ip和port】进行bind绑定。
  • 析构函数的思路: 在<<套接字socket编程的基础知识点>>一文中讲解socket函数的部分说过,socket函数会在内核上创建一个struct file文件,并把该文件的文件描述符返回,所以析构函数需要把【在初始化函数中因为调用了socket函数而“打开”的文件描述符】给close了。

剩余的说明都在下面代码的注释中了,请结合代码思考。说一下,关于注释中connect和accept函数的知识点,这里再加入一点补充内容:

  • 1、在TCP通信中,通信双方会在建立连接时(即3次握手时)交换各自的ip和port信息,比如客户端在调用connect函数发起连接请求的时候(本质是在发送SYN标志位为1的TCP报文),OS会自动把客户端进程绑定的ip和port信息包含进连接请求中发给服务端进程,服务端进程调用accept函数从连接队列里获取连接对象时如果成功获取,则通过传给accept函数的输出型参数就能知道客户端的ip和port了。
  • 2、accept函数是完全不参与3次握手的,早在服务端调用accept函数前,服务端和客户端双方就已经3次握手完毕了(即双方的连接就已经建立成功了,这是由OS控制完成的,用户无法干涉),换句话说就是即使服务端不调用accept函数,服务端和客户端也是能建立连接成功的(从这里可以看出,服务端的3次握手完成的时间点在listen函数后,accept函数前)。accept函数本质是用于获取已经建立好的连接,或者说本质是用于获取连接队列中的连接对象的(从这里可以发现,如果服务端和任意一个客户端3次握手都没有成功,没有一个连接建立完成,即没有一个连接对象被创建并放入连接队列中,那么此时服务端调用accept就会陷入阻塞)

结合上面的理论,服务端类TcpServer的构造函数、初始化函数initServer、析构函数的代码如下。

class TcpServer
{
public:
    TcpServer(uint16_t port, string ip = "")
        :_port(port)
        ,_ip(ip)
    {}

    void InitServer()
    {
        _listen_sock = socket(AF_INET, SOCK_STREAM, 0);

        //bind,作用为将【ip和port和socket文件】和当前进程绑定,为什么要bind绑定呢?套接字sock文件用于通信,首先,如果想要网络通信,则必须通过网卡,所
        //以你必须得指定从哪个网卡(ip)读取数据送到socket文件,这就是bind ip的原因,送到哪个socket文件呢?这是bind sockfd的原因,数据读取完毕后,送到
        //哪个端口(进程)呢?所以你必须指定一个端口号。
       
        //在TCP通信中,通信双方会在3次握手建立连接时交换各自的ip和port信息,比如客户端在调用connect函数发起SYN连接请求的时候,OS会自动把客户端进程绑定的
        //ip和port信息包含进连接请求中发给服务端进程,服务端进程调用accept函数接收连接队列中的连接对象时如果成功,则通过传给accept函数的输出型参数就能知
        //道客户端的ip和port了。
 
        //根据上一段的理论,因为通信双方会在3次握手建立连接时交换各自的ip和port信息,即服务端的OS会在3次握手时自动把服务端进程bind绑定的ip和port信息发送
        //给客户端进程,注意这个包含ip和port的信息是先经过网络,再被客户端进程接收的,而网络资源又是寸土寸金的,发送的数据越小越好,所以在双方进行3次握手
        //建立连接前(3次握手发生在调用listen函数后、调用accept函数前这个时间段内),是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机采用的字
        //节序列不一致,所以在双方进行3次握手建立连接前(3次握手发生在调用listen函数后、调用accept函数前这个时间段内),还需要将转化成整形的ip从主机序列转
        //化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)。注意因为服务端和客户端3次握手交换ip和port信息时、服务端把自己的
        //ip和port的信息发送给客户端时,是OS自动把服务端进程bind绑定的ip和port包含进发送的交换信息中,所以如果想要服务端在进行3次握手时发送出去的ip和port
        //是网络序列,那在服务端进程bind绑定ip和port时,ip和port就应该是网络序列,所以在服务端bind绑定ip和port前,需要将这些信息转化成网络序列。
 
        //根据上上段的理论,客户端调用connect函数发起连接请求时会把请求信息先发送到网络中,然后另一端的服务端进程会从网络中获取到这个请求,注意因为客户端调
        //用connect发起连接请求时,OS会自动把客户端进程绑定的ip和port信息包含进发起的请求中,而网络资源又是寸土寸金的,发送的数据越小越好,所以在客户端调用
        //connect发送连接请求前,是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机采用的字节序列不一致,所以在客户端调用connect前还需要将转化成
        //整形的ip从主机序列转化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)的。注意因为客户端调用connect函数发送连接请求信
        //息到网络时,是OS自动把客户端进程bind绑定的ip和port包含进发送的连接请求中,所以如果想要客户端调用connect发送连接请求时ip和port是网络序列,那在客户
        //端进程bind绑定ip和port时,ip和port就应该是网络序列,这个对于客户端进程来说不必担心,对于客户端进程来说一般是不必显示调用bind函数绑定ip和port的,
        //如果不显示调用bind函数,则客户端调用connect函数向服务端发起连接请求时,OS会自动把当前机器的ip和随机分配的一个port给客户端进程进行bind,并且ip和
        //port在绑定前也由OS自动转化成网络序列了。(关于为什么不必显示调用bind的原因在和UDP网络服务器的模拟实现相关的文章中说明过了)
       
        //说一下,bind绑定这些信息(即ip和port)时,是先把需要绑定的信息全填充到一个sockaddr类的对象中,之后bind只需要绑定这个sockaddr类的对象即可,由于该
        //结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
        sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = (_ip.empty()==true?INADDR_ANY:inet_addr(_ip.c_str()));
        local.sin_port = htons(_port);
        if (bind(_listen_sock, (sockaddr*)&local, sizeof local) < 0)
        {
            exit(1);
        }
        //TCP是面向连接的,正式通信前需要先建立连接
        if(listen(_listen_sock, 20) < 0)
        { 
            exit(1);
        }
        cout<<"服务端初始化成功"<<endl;
    }

    ~TcpServer()
    {
        if (_listen_sock > 0)
            close(_listen_sock);
    }

private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
};

服务端类TcpServer的start函数(单进程版本)

(tips:如果对以下内容感到疑惑,建议结合<<套接字socket编程的基础知识点>>一文阅读)

对于我们当前模拟实现的服务器类TcpServer,如果想让服务器跑起来,则调用其成员函数的顺序是【TcpServer类的构造函数--->TcpServer类的初始化函数initServer--->TcpServer类的start函数--->TcpServer类的析构函数】,可以看到start函数是在初始化函数之后的,走完初始化函数后,已经完成了socket文件的创建、将【当前进程】和【转化成网络字节序后的某个ip地址和某个端口port外加刚创建好的socket文件】进行绑定,剩下的工作就是start函数需要完成的了,需要做的事情为:

  • (结合下图思考)设置一个死循环,每次循环都要做的事情是【调用accept函数从一直处于监听状态的listen_sock中尝试获取其他客户端进程通过connect函数发过来的连接请求,如果accept成功,则accpet函数会创建一个服务套接字文件并返回指向该服务套接字文件的文件描述符service_sock,期望让这个服务套接字文件替监听套接字文件打工,即期望让服务套接字文件去和客户端通信,所以接下来就需要程序员编码控制来实现这个期望,先在TcpServer类外创建一个service函数,然后调用该service函数并将该service_sock传进该函数中,让service_sock对应的socket文件在service函数中与客户端通信,service函数结束后再close关闭文件描述符service_sock,注意是不能关闭监听套接字的,服务器不倒,监听套接字就不能被关闭】。注意在调用accept函数接收客户端发来的连接请求时,需要创建一个sockaddr类的对象作为输出型参数,以拿到客户端的ip和port信息,这样当前进程(即服务端)才能在需要向客户端发送信息时知道目的地(即客户端)在哪。

那service函数如何实现呢?思路如下:

  • (结合下图思考)在start函数中获取到了包含客户端的ip和port信息的sockaddr_in结构体对象,但因为这些信息是从网络中来的,所以此时就需要先将这些信息从网络字节序转化成主机字节序,这样做的原因是为了在service中打印客户端的ip和port信息。然后设置一个死循环,每次循环都要做的事情是【读取客户端发过来的信息,读取完毕后再原模原样的返回给客户端】。

剩余的说明都在下面代码的注释中了,请结合代码思考。

结合上面的理论,服务端类TcpServer的start函数(单进程版本)的代码如下。

#include<iostream>
using namespace std;
#include<string.h>//提供bzero函数
#include<memory>//提供智能指针的库
#include<cstdlib>//提供atoi函数、exit函数
#include <unistd.h>//提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体

//当前service的逻辑就是接收客户端发来的信息并原模原样返回给客户端
void service(int service_sock, sockaddr_in other_side)
{
    char* ip = inet_ntoa(other_side.sin_addr);
    uint16_t port = ntohs(other_side.sin_port);
    char buffer[64];
    while(1)
    {
        ssize_t size = read(service_sock, buffer, sizeof(buffer)-1);
        if(size > 0)
        {
            buffer[size] = '\0';//因为在设计客户端与服务端通信时,设计的逻辑是双方都只发送C语言字符串,所以通信通道中的信息就全是C语言字符串,所以这里需要在读到的数据的末尾设置0,防止打印时出现乱码
            printf("ip为%s、port为%d的客户端发给我(即服务端进程)的信息为:%s\n", ip, port, buffer);
        }
        else if(size == 0)//如果返回值为0,则表示给我write发送信息的对端进程中的套接字文件对应的文件描述符被close关闭了,既然没人给我发数据了,那这边我们也就不必再读了,直接break
        {
            cout<<"客户端的套接字被关闭,客户端不会再给我(服务端)发送信息了,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        else
        {
            cout<<"服务端读取数据异常,发生了未知错误,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        //走到这里一定是read接客户端发来的信息成功了,将数据原模原样返回给客户端即可
        write(service_sock, buffer, sizeof(buffer)-1);

    }
}

class TcpServer
{
public:
    void start()
    {
        while(1)
        {
            //监听成功后,这里需要从监听套接字中获取连接
            sockaddr_in other_side;
            socklen_t len = sizeof other_side;
            int service_sock = accept(_listen_sock, (sockaddr*)&other_side, &len);
            if (service_sock < 0)
            {
                cout<<"获取监听套接字中的连接失败,即将重新获取"<<endl;
                continue;
            }
            //获取连接成功,开始通信
            //version 1 -- 单进程循环版本
            cout<<"获取连接成功"<<endl;
            service(service_sock, other_side);
            close(service_sock);
        }
    }

private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
};


单进程版本的服务端类TcpServer的整体代码(因为start成员函数是单进程版本,所以TcpServer类就是单进程版本)

以下是Tcp_server.h文件的整体代码。

#include<iostream>
using namespace std;
#include<string.h>//提供bzero函数
#include<memory>//提供智能指针的库
#include<cstdlib>//提供atoi函数、exit函数
#include <unistd.h>//提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体

//当前service的逻辑就是接收客户端发来的信息并原模原样返回给客户端
void service(int service_sock, sockaddr_in other_side)
{
    char* ip = inet_ntoa(other_side.sin_addr);
    uint16_t port = ntohs(other_side.sin_port);
    char buffer[64];
    while(1)
    {
        ssize_t size = read(service_sock, buffer, sizeof(buffer)-1);
        if(size > 0)
        {
            buffer[size] = '\0';//因为在设计客户端与服务端通信时,设计的逻辑是双方都只发送C语言字符串,所以通信通道中的信息就全是C语言字符串,所以这里需要在读到的数据的末尾设置0,防止打印时出现乱码
            printf("ip为%s、port为%d的客户端发给我(即服务端进程)的信息为:%s\n", ip, port, buffer);
        }
        else if(size == 0)//如果返回值为0,则表示给我write发送信息的对端进程中的套接字文件对应的文件描述符被close关闭了,既然没人给我发数据了,那这边我们也就不必再读了,直接break
        {
            cout<<"客户端的套接字被关闭,客户端不会再给我(服务端)发送信息了,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        else
        {
            cout<<"服务端读取数据异常,发生了未知错误,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        //走到这里一定是read接客户端发来的信息成功了,将数据原模原样返回给客户端即可
        write(service_sock, buffer, sizeof(buffer)-1);

    }
}

class TcpServer
{
public:
    TcpServer(uint16_t port, string ip = "")
        :_port(port)
        ,_ip(ip)
    {}

    void InitServer()
    {
        _listen_sock = socket(AF_INET, SOCK_STREAM, 0);

        //bind,作用为将【ip和port和socket文件】和当前进程绑定,为什么要bind绑定呢?套接字sock文件用于通信,首先,如果想要网络通信,则必须通过网卡,所
        //以你必须得指定从哪个网卡(ip)读取数据送到socket文件,这就是bind ip的原因,送到哪个socket文件呢?这是bind sockfd的原因,数据读取完毕后,送到
        //哪个端口(进程)呢?所以你必须指定一个端口号。
       
        //在TCP通信中,通信双方会在3次握手建立连接时交换各自的ip和port信息,比如客户端在调用connect函数发起SYN连接请求的时候,OS会自动把客户端进程绑定的
        //ip和port信息包含进连接请求中发给服务端进程,服务端进程调用accept函数接收连接队列中的连接对象时如果成功,则通过传给accept函数的输出型参数就能知
        //道客户端的ip和port了。
 
        //根据上一段的理论,因为通信双方会在3次握手建立连接时交换各自的ip和port信息,即服务端的OS会在3次握手时自动把服务端进程bind绑定的ip和port信息发送
        //给客户端进程,注意这个包含ip和port的信息是先经过网络,再被客户端进程接收的,而网络资源又是寸土寸金的,发送的数据越小越好,所以在双方进行3次握手
        //建立连接前(3次握手发生在调用listen函数后、调用accept函数前这个时间段内),是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机采用的字
        //节序列不一致,所以在双方进行3次握手建立连接前(3次握手发生在调用listen函数后、调用accept函数前这个时间段内),还需要将转化成整形的ip从主机序列转
        //化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)。注意因为服务端和客户端3次握手交换ip和port信息时、服务端把自己的
        //ip和port的信息发送给客户端时,是OS自动把服务端进程bind绑定的ip和port包含进发送的交换信息中,所以如果想要服务端在进行3次握手时发送出去的ip和port
        //是网络序列,那在服务端进程bind绑定ip和port时,ip和port就应该是网络序列,所以在服务端bind绑定ip和port前,需要将这些信息转化成网络序列。
 
        //根据上上段的理论,客户端调用connect函数发起连接请求时会把请求信息先发送到网络中,然后另一端的服务端进程会从网络中获取到这个请求,注意因为客户端调
        //用connect发起连接请求时,OS会自动把客户端进程绑定的ip和port信息包含进发起的请求中,而网络资源又是寸土寸金的,发送的数据越小越好,所以在客户端调用
        //connect发送连接请求前,是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机采用的字节序列不一致,所以在客户端调用connect前还需要将转化成
        //整形的ip从主机序列转化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)的。注意因为客户端调用connect函数发送连接请求信
        //息到网络时,是OS自动把客户端进程bind绑定的ip和port包含进发送的连接请求中,所以如果想要客户端调用connect发送连接请求时ip和port是网络序列,那在客户
        //端进程bind绑定ip和port时,ip和port就应该是网络序列,这个对于客户端进程来说不必担心,对于客户端进程来说一般是不必显示调用bind函数绑定ip和port的,
        //如果不显示调用bind函数,则客户端调用connect函数向服务端发起连接请求时,OS会自动把当前机器的ip和随机分配的一个port给客户端进程进行bind,并且ip和
        //port在绑定前也由OS自动转化成网络序列了。(关于为什么不必显示调用bind的原因在和UDP网络服务器的模拟实现相关的文章中说明过了)
       
        //说一下,bind绑定这些信息(即ip和port)时,是先把需要绑定的信息全填充到一个sockaddr类的对象中,之后bind只需要绑定这个sockaddr类的对象即可,由于该
        //结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
        sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = (_ip.empty()==true?INADDR_ANY:inet_addr(_ip.c_str()));
        local.sin_port = htons(_port);
        if (bind(_listen_sock, (sockaddr*)&local, sizeof local) < 0)
        {
            exit(1);
        }
        //TCP是面向连接的,正式通信前需要先建立连接
        if(listen(_listen_sock, 20) < 0)
        { 
            exit(1);
        }
        cout<<"服务端初始化成功"<<endl;
    }

    void start()
    {
        while(1)
        {
            //监听成功后,这里需要从监听套接字中获取连接
            sockaddr_in other_side;
            socklen_t len = sizeof other_side;
            int service_sock = accept(_listen_sock, (sockaddr*)&other_side, &len);
            if (service_sock < 0)
            {
                cout<<"获取监听套接字中的连接失败,即将重新获取"<<endl;
                continue;
            }
            //获取连接成功,开始通信
            //version 1 -- 单进程循环版本
            cout<<"获取连接成功"<<endl;
            service(service_sock, other_side);
            close(service_sock);
        }
    }

    ~TcpServer()
    {
        if (_listen_sock > 0)
            close(_listen_sock);
    }

private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
};


基于单进程版本的服务端类TcpServer模拟实现的服务端

tcp_server.cc文件的整体代码

tcp_server.cc文件的整体代码如下。逻辑非常简单,从命令行中获取到传给服务端进程的ip和port后,通过它们构造出在上文中模拟实现出的单进程版本的服务端类TcpServer的对象,然后通过该对象调用TcpServer类的成员函数initServer和Start,这样服务端进程就跑起来了,即服务器就跑起来了。(注意tcp_server.h的代码就是在上文中模拟实现出来的单进程版本的服务端类TcpServer的整体代码)

#include"tcp_server.h"

void usage(char*c)
{
    printf("Usage:%s port\n", c);
}
//将来服务端进程被调用时的格式为:“./tcp_server port”,如果默认不传ip,
//则bind绑定的ip是INADDR_ANY地址;如果有特殊情况,就是要显示传ip,则bind
//绑定的ip就是指定ip。
int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        usage(argv[0]);
        exit(1);
    }
    uint16_t port = atoi(argv[1]);
    unique_ptr<TcpServer> p(new TcpServer(port));
    p->InitServer();
    p->start();
    return 0;
}


对通过【单进程版本的服务端类TcpServer】实现的网络服务器的测试

(tips:如果对以下内容感到疑惑,建议结合<<套接字socket编程的基础知识点>>一文阅读)

将上文中编写好的tcp_server.cc文件编译好后,直接在一个ssh渠道中运行它,在运行时要传入命令行参数:

  • 如果没有特殊需求,不必传ip地址,把端口号8080设置成命令行参数传给服务端进程即可,因为我们的编码逻辑是在不显示传ip时,ip会被设置成缺省参数的值(即INADDR_ANY),之后服务端进程会bind绑定INADDR_ANY地址,这样一来服务端进程就能从任意一个网卡中读取数据、就能向任意一个网卡中发送数据,从而提高IO效率。

虽然现在还没有编写客户端的代码,但我们可以通过复制一个ssh渠道,在另一个shell界面中使用使用telnet命令连接咱们编写的正在运行中的服务端进程。能连接成功的原因是:因为咱们服务端是基于TCP协议进行编写的(即使用的都是基于TCP协议的系统接口),而telnet底层实际采用的也是TCP协议,所以双方才能连接成功并通信。

我们先测试本地通信,在<<套接字socket编程的基础知识点>>一文中说过,某个进程bind绑定INADDR_ANY地址后,不光可以让其他主机上的进程访问该进程,也是可以让本地(即本机)上的其他进程访问该进程的,当前的服务端进程bind绑定的ip就是INADDR_ANY地址,所以本地上的telnet进程是可以访问同属于本地的服务端进程的。在运行telnet命令时要传入命令行参数:

  • 把本地环回地址(即ip地址127.0.0.1)和服务端进程的端口号8080设置成命令行参数传给telnet指令,让充当客户端的telnet进程知道服务端进程在哪。
  • 上一步完毕后,还需要按CTRL键加上右方括号(即 “]” ),然后按下enter键,然后充当客户端进程的telnet就可以给服务端进程发信息了。
  • 说一下,如果想要退出telnet进程,首先需要按CTRL键加上右方括号(即 “]” ),然后输入quit即可。

这样一来,客户端进程telnet和服务端进程tcp_server收发数据时就只在本地协议栈中进行数据流动,不会把我们的数据发送到网络中。

测试结果如下(左半部分是服务端、右半部分的上面是客户端,右半部分的下面是一个用于检验本机各端口的网络连接情况的指令)。可以看到结果是符合我们的预期的,客户端向服务端发送信息后,服务端能收到该信息,并能将信息原模原样地返回给客户端。

单进程版本的服务端类TcpServer的弊端

奇怪的现象

先观察几个现象:

现象1:如下图1和2中都启动了3个ssh渠道,在图1中,首先在渠道1中让telnet进程连接服务端进程,然后在运行服务端进程的渠道3中就打印出了服务端初始化并连接成功的语句,这是符合我们预期的;但紧接着奇怪的现象就出现了,在下图2中,我们在渠道2中也让telnet进程连接服务端进程,可奇怪的是在运行服务端进程的渠道3中的红框处没有打印出服务端初始化并连接成功的语句,这是为什么呢?

现象2:更奇怪的是,如下图1所示,我渠道2的telnet进程向在渠道3中运行的服务端进程发送信息时,服务端根本就完全不理会;但在下图2中,服务端进程却接收了渠道1中的telnet进程发来的信息,并将信息原模原样的返回给渠道1中的telnet进程;如下图3所示,当输入quit关闭渠道1中的telnet进程后,虽然我们只关闭了渠道1中的telnet进程,其他什么操作都没干,但渠道2中的telnet进程却和渠道3中的服务端进程建立好了连接,并且渠道2中的之前未被发出去的信息还被服务端进程接收到并且原模原样返回给了渠道2的telnet进程。综上所述可以看出在渠道1中的telnet进程被quit关闭前,服务端进程只会接收渠道1中的telnet进程发过来的信息、不会接收其他渠道中的telnet进程发过来的信息,只有渠道1中的telnet进程被quit关闭后,服务端进程才能接收另一个(注意数量是一个)渠道中的telnet进程发过来的若干信息,换句话说也就是服务端同一时间只能给一个客户端提供服务,只有该客户端不再访问服务端,下一个客户端才能访问服务端。这又是为什么呢?

说一下,上面两个奇怪现象发生的原因就是在当前服务端进程中所使用的服务端类TcpServer在编写时是单进程版本(因为start成员函数是单进程版本,所以TcpServer类就是单进程版本。上文中也说过本文中分单进程版本的服务端类TcpServer和多进程版本的服务端类TcpServer只是因为这两个版本的TcpServer类中有一个成员函数start的实现不太一样,至于其他的成员变量或者成员函数在两个版本中都是完全一致的。),什么意思呢?

(结合下图思考)如果有某个客户端通过connect函数向服务端发起连接请求,并且服务端在start函数中调用accept函数成功,那么双方进程就建立连接成功,此时服务端进程就会调用service函数为该客户端进程服务(即调用read函数接收客户端的信息并调用write函数将信息原模原样的发回给客户端)。但注意service函数中的逻辑是一个死循环,如果双方进程都不关闭自己的套接字文件(即双方没有一个close掉自己的套接字文件对应的文件描述符),那么service函数就无法结束循环(因为此时read函数的返回值不会<=0,也就无法break),那么就会一直在service函数中执行代码,连service函数都无法退出,那么start函数中的循环也就无法继续,所以这时即使又有其他客户端通过connect函数向服务端发起连接请求,但因为服务端没法在start函数中走到accept函数处去调用accept函数,所以也无法让双方进程成功地建立起连接。

所以根据上一段的理论,在上文的两个奇怪现象中,因为渠道1中的telnet进程(客户端)是先和服务端建立连接成功,所以渠道2中的telnet进程就没法和服务端建立连接成功了,那么当然服务端只能接收渠道1的telnet进程发过来的信息;等到渠道1的telnet进程退出后,服务端进程在service函数中调用read函数就会返回0值,然后执行break语句结束service函数内的循环,然后重新回到start函数内继续执行代码,重新accept接受渠道2的telnet进程发过来的连接请求,然后建立连接成功,服务端才可以正常接收渠道2的telnet进程发过来的信息。这就是上文中两个奇怪现象发生的原因了。

由奇怪的现象得出的结论

所以阅读完上文后,我们就能清晰的明白单进程版本的服务端类TcpServer的弊端:那就是服务端在同一时间内只能服务一个客户端,只有这个客户端退出了,才能继续服务下一个服务端,没法并发地运作,这显然是不合理的,所以就衍生出了咱们接下来要模拟实现的多进程版本的服务端类TcpServer,请继续往下看。

多进程版本的服务端类TcpServer的模拟实现

在标题为前言的部分中就说过,本文中分类分成单进程版本的服务端类TcpServer、多进程版本的服务端类TcpServer、多线程版本的服务端类TcpServer、线程池版本的服务端类TcpServer只是因为这四个版本的TcpServer类中有一个成员函数start的实现不太一样,至于其他的成员变量或者成员函数在四个版本中都是完全一致的,所以只要将start函数修改成多进程版本,那多进程版本的服务端类TcpServer的模拟实现也就完毕了,整体代码也就出来了,所以首先说一说如何实现start成员函数,请往下看。

(写法1)服务端类TcpServer的start函数(多进程版本)

(tips:如果对以下内容感到疑惑,建议结合<<套接字socket编程的基础知识点>>一文阅读)

对于我们当前模拟实现的服务器类TcpServer,如果想让服务器跑起来,则调用其成员函数的顺序是【TcpServer类的构造函数--->TcpServer类的初始化函数initServer--->TcpServer类的start函数--->TcpServer类的析构函数】,可以看到start函数是在初始化函数之后的,走完初始化函数后,已经完成了socket文件的创建、将【当前进程】和【转化成网络字节序后的某个ip地址和某个端口port外加刚创建好的socket文件】进行绑定,剩下的工作就是start函数需要完成的了,需要做的事情为:

  • (结合下图思考)设置一个死循环,每次循环都要做的事情是【调用accept函数从一直处于监听状态的listen_sock中尝试获取其他客户端进程通过connect函数发过来的连接请求,如果accept成功,则accpet函数会创建一个服务套接字文件并返回指向该服务套接字文件的文件描述符service_sock,期望让这个服务套接字文件替监听套接字文件打工,即期望让服务套接字文件去和客户端通信,所以接下来就需要程序员编码控制来实现这个期望。既然现在实现的是多进程版本的start成员函数,所以首先需要调用fork函数创建子进程,然后让service_sock对应的套接字文件在子进程中和其他主机上的客户端进程进行数据的传输;父进程就只专心完成接收其他客户端的连接请求并创建服务套接字文件,然后再创建一个去和客户端通信的子进程即可。那如何让service_sock对应的套接字文件在子进程中和其他主机上的客户端进程进行数据的传输呢?先在TcpServer类外创建一个service函数,然后在子进程中调用该service函数并将该service_sock传进该函数中,就可以让service_sock对应的socket文件在service函数中与客户端通信了,即就可以让service_sock对应的套接字文件在子进程中和其他主机上的客户端进程进行数据的传输了。注意父进程在调用accept函数接收客户端发来的连接请求时,需要创建一个sockaddr类的对象作为输出型参数,以拿到客户端的ip和port信息,这样当前进程(即服务端)才能在需要向客户端发送信息时知道目的地(即客户端)在哪。

那这里的service函数如何实现呢?和在上文中标题为服务端类TcpServer的start函数(单进程版本)的部分中实现的service函数相比没有任何变化,直接把上文中的拿来用就行。

关于【服务端类TcpServer的start函数(多进程版本)】的剩余说明,都在下面代码的注释中了,请结合代码思考。

结合上面的理论,(写法1)服务端类TcpServer的start函数(多进程版本)的代码如下。

#include<iostream>
using namespace std;
#include<string.h>//提供bzero函数
#include<memory>//提供智能指针的库
#include<cstdlib>//提供atoi函数、exit函数
#include <unistd.h>//提供close
#include<signal.h>//提高signal函数
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体

//当前service的逻辑就是接收客户端发来的信息并原模原样返回给客户端
void service(int service_sock, sockaddr_in other_side)
{
    char* ip = inet_ntoa(other_side.sin_addr);
    uint16_t port = ntohs(other_side.sin_port);
    char buffer[64];
    while(1)
    {
        ssize_t size = read(service_sock, buffer, sizeof(buffer)-1);
        if(size > 0)
        {
            buffer[size] = '\0';//因为在设计客户端与服务端通信时,设计的逻辑是双方都只发送C语言字符串,所以通信通道中的信息就全是C语言字符串,所以这里需要在读到的数据的末尾设置0,防止打印时出现乱码
            printf("ip为%s、port为%d的客户端发给我(即服务端进程)的信息为:%s\n", ip, port, buffer);
        }
        else if(size == 0)//如果返回值为0,则表示给我write发送信息的对端进程中的套接字文件对应的文件描述符被close关闭了,既然没人给我发数据了,那这边我们也就不必再读了,直接break
        {
            cout<<"客户端的套接字被关闭,客户端不会再给我(服务端)发送信息了,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        else
        {
            cout<<"服务端读取数据异常,发生了未知错误,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        //走到这里一定是read接客户端发来的信息成功了,将数据原模原样返回给客户端即可
        write(service_sock, buffer, sizeof(buffer)-1);

    }
}

class TcpServer
{
public:
    void start()
    {
        signal(SIGCHLD,SIG_IGN);//通过该函数能让僵尸的子进程不需要父进程回收,而是自动释放自己的PCB以及相关内核数据结构以避免内存泄漏
        while(1)
        {
            //监听成功后,这里需要从监听套接字中获取连接
            sockaddr_in other_side;
            socklen_t len = sizeof other_side;
            int service_sock = accept(_listen_sock, (sockaddr*)&other_side, &len);
            if (service_sock < 0)
            {
                cout<<"获取监听套接字中的连接失败,即将重新获取"<<endl;
                continue;
            }
            //获取连接成功,开始通信
            //version 2 -- 多进程版本
            cout<<"获取连接成功"<<endl;

            //创建子进程,让service_sock对应的套接字文件在子进程中和其他主机上的客户端进程进行数据的传输。
            pid_t id = fork();
            if (id == 0)//子进程走这里
            {
                //子进程被创建出来后,自己的内核数据结构中的信息会完全拷贝父进程,所以子进程能和父进程看到的文件描述符表中的内容是完全一致的,一定不要忘了父子进程中各
                //自有一份文件描述符表,相互是独立的,父子进程各自管理属于自己的表,修改自己的表不会影响到对方的表。此时因为子进程不需要访问文件描述符listen_sock对应
                //的文件,所以子进程将它close关闭,避免无效使用内存。需要注意的是不要认为子进程关闭了listen_sock文件描述符,OS就会将其对应的监听套接字文件从内存中删
                //除(即把该文件对应的struct file清除)导致父进程无法通过该监听套接字文件继续accept获取其他客户端的连接请求,这是错误的认识。正确的认识为:OS内部会有
                //一个表示正在使用该文件的进程的总数的引用计数,只有当所有打开过该文件的进程都close掉该文件对应的文件描述符,即引用计数为0的时候,该文件才会真正被OS删
                //除,其他情况的时候只不过是把计数减一了而已。然后需要注意的是这里不要和多线程中的知识点混淆了,在多线程中新线程是看不见主线程中的局部变量的;但这是多进程,
                //在子进程没有修改数据发生写时拷贝前,父子进程中数据的虚拟地址以及虚拟地址映射的物理地址都完全相同,换言之父子进程的所有数据都是共享的,所以子进程能看到
                //在父进程中创建的变量listen_sock。
                close(_listen_sock);
                //再让子进程去执行service函数
                service(service_sock, other_side);

                //对多进程编程不太熟悉的小伙伴们不要认为这里子进程走完当前的if分支后就自动进程退出了,是不会退出的,所以如果想要子进程退出(即结束子进程),则一定要在子进
                //程执行完service函数后显示调用exit函数,否则子进程会继续循环,跑到上面去调用accept,因为在子进程中,监听套接字已经被close关闭了,那么accept一定会失败,
                //返回值就<0,然后子进程就陷入了不断continue的过程,子进程就永远无法自动进程退出。
                //进程马上要被exit(0)销毁了,在销毁时OS会自动帮子进程把service_sock给close的,所以子进程这里就不必显示调用close(service_sock)了。
                exit(0);
            }
            else//父进程走这里
            {
                //在多进程版本下,父进程需要频繁的调用accept函数接收若干客户端的连接请求,也就要频繁创建服务套接字文件(文件对应的描述符就是service_sock),让这些服务
                //套接字文件和其他主机的客户端进程中的套接字文件进行数据传输。但注意数据传输的工作是在子进程中完成的,父进程只需要完成服务套接字文件和子进程的创建工作即
                //可,所以父进程是不需要访问这些服务套接字文件的,所以在父进程中需要统统将他们close关闭掉,否则当有过多的客户端通过connect函数向服务端发起连接请求时就会
                //导致父进程的文件描述符表被占满从而导致调用accept创建不出来服务套接字文件从而导致调用accept函数失败。
                close(service_sock);

                //在父进程中还需要解决的一个问题是僵尸子进程的回收问题,父进程是服务器,肯定是不会退出的,所以子进程退出时父进程一定是没有退出的,所以子进程就变成了僵尸进程,
                //如果不回收子进程,子进程的PCB以及相关内核数据结构就不会被释放从而内存泄漏。如果父进程以阻塞的模式调用waitpid函数去回收子进程,则父进程在子进程完成与其他主
                //机上的客户端的通信工作前,是没法再次创建子进程让该子进程和另一台主机上的客户端去通信的,这样一来多进程版本的服务端类TcpServer和之前模拟实现的单进程版本的服
                //务端类TcpServer就没有了区别,服务端进程都是在同一时间处理一个客户端,处理完毕后才能处理下一个;可如果父进程以非阻塞的模式调用waitpid函数,此时的确能正常的创
                //建多个子进程并让它们去和多个客户端进程通信,但因为其他若干主机上的客户端与本机的服务端通信的时长是不同的,不知道哪些进程已经通信结束,哪些正在通信,所以要想
                //回收本机上服务端进程为了和若干其他主机上的客户端进程通信而创建出若干子进程,则需要将父进程创建出的所有子进程对应的进程ID(即pid_t类型的对象)都通过某种容器存
                //储起来,每次父进程在回收子进程的时候,都需要遍历该容器,看看哪些子进程可以被父进程通过waitpid函数回收,这样做的确是可行的,但因为父进程在start的循环中每次循
                //环都要尝试回收若干子进程,检查是否有子进程可以被回收,而检查本身又是一个通过循环遍历容器并调用waitpid的过程,可能此时没有任何子进程可以被回收,但依然需要傻傻
                //的进行检测,所以这样效率就太差了,此时有人可能会想到信号,在讲解信号的章节中我们说过子进程退出是会给父进程发SIGCHLD信号的,我们可以将回收子进程的逻辑放进一个
                //函数中,然后将该函数设置成SIGCHLD信号的信号处理函数,这样就能避免之前的每次循环都无脑检测。这里我想说的是,这样的确算是一种优化,提高了效率,但我们有更好的方
                //式去回收子进程,在讲解信号的章节中,我们提到过一种方案,能够不需要父进程回收,子进程能够自己把成为僵尸进程的自己自动释放掉,在这里我们就使用这样的方法,即直接
                //在当前start函数的第一行写上代码signal(SIGCHLD,SIG_IGN)即可,因为在信号的章节中说过,对于SIGCHLD信号,如果是主动忽略SIGCHLD信号,子进程退出的时候,会自动释
                //放自己的僵厂状态,即释放掉自己的PCB以及相关内核数据结构。
            }
        }
    }

private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
};


(写法2)服务端类TcpServer的start函数(多进程版本)

说一下,这里的写法2对比写法1,除了回收子进程(替只做监听工作的父进程去和客户端进程通信的进程就是子进程)的方式不同,其他所有的内容都没有发生变化,所以写法2的代码只需在写法1的代码上稍作修改,修改的思路为:

  • 既然是要换一种回收子进程的方式,那首先就要把原来回收子进程的方式(即通过主动忽略SIGCHLD信号从而让子进程自动释放PCB)所对应的代码给注释掉,即把start函数中第一行语句signal(SIGCHLD,SIG_IGN);给注释掉。
  • 经过修改后,回收子进程的方式为:让父进程以阻塞模式调用waitpid函数回收(或者说等待)子进程。
  • 有人可能会说:“替只做监听工作的父进程去和客户端进程通信的进程就是子进程,在子进程通信的期间如果父进程阻塞地等子进程,那么父进程不就没法再建立新的连接以及创建新的子进程去和客户端进程通信了,那么使用多进程版本的服务端类不就和使用单进程版本的服务端类一样了吗,服务端都是同一时间只能处理一个客户端,处理完毕才能处理下一个客户端?”
  • 这里我想说的是,的确如此,但我们可以让服务端的工作模式发生一些变化,即让子进程再去创建子进程(这里把前者称为子进程A,把子进程A的子进程称为子进程B),让替只做监听工作的父进程去和客户端进程通信的进程从子进程A变成子进程B,也就是让只做监听工作的父进程的孙子进程替父进程去和客户端进程通信,而子进程A的工作就只是创建子进程B,完毕后子进程A立刻退出,这样一来父进程即使需要wait阻塞等待子进程A,也只需要等待一瞬间就能回收子进程A完毕并恢复运行,父进程也就能继续循环和其他客户端建立连接以及通信了。
  • 注意替父进程和客户端进程通信的孙子进程(即子进程A的子进程B)还正在工作呢!当子进程B工作完毕后进行进程退出时,谁来回收它呢?答案:子进程B的父进程(即子进程A)因为比子进程B早退出,子进程B也就成为了孤儿进程,后序孤儿进程会被PID为1的进程领养,1号进程是init进程,即系统本身,等待孤儿进程需要退出时,系统就会自动回收它。
  • 这样一来,父进程创建出的子进程A、以及子进程A创建出的子进程B也就都有人回收了,不会发生内存泄漏的问题,并且还能让服务端在同一时间内处理多个客户端,也就完美地解决了所有的问题,这就是服务端类TcpServer的start函数的第二种写法全部内容了。

结合上面的思路,(写法2)服务端类TcpServer的start函数(多进程版本)的代码如下。

#include<iostream>
using namespace std;
#include<string.h>//提供bzero函数
#include<memory>//提供智能指针的库
#include<cstdlib>//提供atoi函数、exit函数
#include <unistd.h>//提供close
#include<signal.h>//提高signal函数
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体

//当前service的逻辑就是接收客户端发来的信息并原模原样返回给客户端
void service(int service_sock, sockaddr_in other_side)
{
    char* ip = inet_ntoa(other_side.sin_addr);
    uint16_t port = ntohs(other_side.sin_port);
    char buffer[64];
    while(1)
    {
        ssize_t size = read(service_sock, buffer, sizeof(buffer)-1);
        if(size > 0)
        {
            buffer[size] = '\0';//因为在设计客户端与服务端通信时,设计的逻辑是双方都只发送C语言字符串,所以通信通道中的信息就全是C语言字符串,所以这里需要在读到的数据的末尾设置0,防止打印时出现乱码
            printf("ip为%s、port为%d的客户端发给我(即服务端进程)的信息为:%s\n", ip, port, buffer);
        }
        else if(size == 0)//如果返回值为0,则表示给我write发送信息的对端进程中的套接字文件对应的文件描述符被close关闭了,既然没人给我发数据了,那这边我们也就不必再读了,直接break
        {
            cout<<"客户端的套接字被关闭,客户端不会再给我(服务端)发送信息了,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        else
        {
            cout<<"服务端读取数据异常,发生了未知错误,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        //走到这里一定是read接客户端发来的信息成功了,将数据原模原样返回给客户端即可
        write(service_sock, buffer, sizeof(buffer)-1);

    }
}

class TcpServer
{
public:
    void start()
    {
        while(1)
        {
            //监听成功后,这里需要从监听套接字中获取连接
            sockaddr_in other_side;
            socklen_t len = sizeof other_side;
            int service_sock = accept(_listen_sock, (sockaddr*)&other_side, &len);
            if (service_sock < 0)
            {
                cout<<"获取监听套接字中的连接失败,即将重新获取"<<endl;
                continue;
            }
            //获取连接成功,开始通信
            //version 2 -- 多进程版本
            cout<<"获取连接成功"<<endl;

            //创建子进程,让service_sock对应的套接字文件在子进程中和其他主机上的客户端进程进行数据的传输。
            pid_t id = fork();
            if (id == 0)//子进程A走这里
            {
                //子进程被创建出来后,自己的内核数据结构中的信息会完全拷贝父进程,所以子进程能和父进程看到的文件描述符表中的内容是完全一致的,一定不要忘了父子进程中各
                //自有一份文件描述符表,相互是独立的,父子进程各自管理属于自己的表,修改自己的表不会影响到对方的表。此时因为子进程不需要访问文件描述符listen_sock对应
                //的文件,所以子进程将它close关闭,避免无效使用内存。需要注意的是不要认为子进程关闭了listen_sock文件描述符,OS就会将其对应的监听套接字文件从内存中删
                //除(即把该文件对应的struct file清除)导致父进程无法通过该监听套接字文件继续accept获取其他客户端的连接请求,这是错误的认识。正确的认识为:OS内部会有
                //一个表示正在使用该文件的进程的总数的引用计数,只有当所有打开过该文件的进程都close掉该文件对应的文件描述符,即引用计数为0的时候,该文件才会真正被OS删
                //除,其他情况的时候只不过是把计数减一了而已。然后需要注意的是这里不要和多线程中的知识点混淆了,在多线程中新线程是看不见主线程中的局部变量的;但这是多进程,
                //在子进程没有修改数据发生写时拷贝前,父子进程中数据的虚拟地址以及虚拟地址映射的物理地址都完全相同,换言之父子进程的所有数据都是共享的,所以子进程能看到
                //在父进程中创建的变量listen_sock。
                close(_listen_sock);
                
                if(fork() > 0)//子进程A走这里
                {
                    exit(0);
                }
                else//子进程B走这里
                {
                    service(service_sock, other_side);
                    //对多进程编程不太熟悉的小伙伴们不要认为这里的进程走完当前的if分支后就自动进程退出了,是不会退出的,所以如果想要当前进程退出(即结束进程),则一定要在当前进
                    //程执行完service函数后显示调用exit函数,否则当前进程会继续循环,跑到上面去调用accept,因为在当前进程中,监听套接字已经被close关闭了,那么accept一定会失败,
                    //返回值就<0,然后当前进程就陷入了不断continue的过程,当前进程就永远无法自动进程退出。
                    //进程马上要被exit(0)销毁了,在销毁时OS会自动帮当前进程把service_sock给close的,所以当前进程这里就不必显示调用close(service_sock)了。
                    exit(0);
                }
            }
            else//父进程走这里
            {
                //在多进程版本下,父进程需要频繁的调用accept函数接收若干客户端的连接请求,也就要频繁创建服务套接字文件(文件对应的描述符就是service_sock),让这些服务
                //套接字文件和其他主机的客户端进程中的套接字文件进行数据传输。但注意数据传输的工作是在子进程中完成的,父进程只需要完成服务套接字文件和子进程的创建工作即
                //可,所以父进程是不需要访问这些服务套接字文件的,所以在父进程中需要统统将他们close关闭掉,否则当有过多的客户端通过connect函数向服务端发起连接请求时就会
                //导致父进程的文件描述符表被占满从而导致调用accept创建不出来服务套接字文件从而导致调用accept函数失败。
                close(service_sock);
                waitpid(id, nullptr, 0);//第三个参数为0表示阻塞等待
            }
        }
    }
private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
};


多进程版本的服务端类TcpServer的整体代码(因为start成员函数是多进程版本,所以TcpServer类就是多进程版本)

在标题为前言的部分中就说过,本文中分类分成单进程版本的服务端类TcpServer、多进程版本的服务端类TcpServer、多线程版本的服务端类TcpServer、线程池版本的服务端类TcpServer只是因为这四个版本的TcpServer类中有一个成员函数start的实现不太一样,至于其他的成员变量或者成员函数在四个版本中都是完全一致的,所以现在既然start函数修改成多进程版本的了,多进程版本的服务端类TcpServer的整体代码也就出来了,如下。(以下是Tcp_server.h文件的整体代码)

#include<iostream>
using namespace std;
#include<string.h>//提供bzero函数
#include<memory>//提供智能指针的库
#include<cstdlib>//提供atoi函数、exit函数
#include <unistd.h>//提供close
#include<signal.h>//提高signal函数
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体

//当前service的逻辑就是接收客户端发来的信息并原模原样返回给客户端
void service(int service_sock, sockaddr_in other_side)
{
    char* ip = inet_ntoa(other_side.sin_addr);
    uint16_t port = ntohs(other_side.sin_port);
    char buffer[64];
    while(1)
    {
        ssize_t size = read(service_sock, buffer, sizeof(buffer)-1);
        if(size > 0)
        {
            buffer[size] = '\0';//因为在设计客户端与服务端通信时,设计的逻辑是双方都只发送C语言字符串,所以通信通道中的信息就全是C语言字符串,所以这里需要在读到的数据的末尾设置0,防止打印时出现乱码
            printf("ip为%s、port为%d的客户端发给我(即服务端进程)的信息为:%s\n", ip, port, buffer);
        }
        else if(size == 0)//如果返回值为0,则表示给我write发送信息的对端进程中的套接字文件对应的文件描述符被close关闭了,既然没人给我发数据了,那这边我们也就不必再读了,直接break
        {
            cout<<"客户端的套接字被关闭,客户端不会再给我(服务端)发送信息了,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        else
        {
            cout<<"服务端读取数据异常,发生了未知错误,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        //走到这里一定是read接客户端发来的信息成功了,将数据原模原样返回给客户端即可
        write(service_sock, buffer, sizeof(buffer)-1);

    }
}

class TcpServer
{
public:
    TcpServer(uint16_t port, string ip = "")
        :_port(port)
        ,_ip(ip)
    {}

    void InitServer()
    {
        _listen_sock = socket(AF_INET, SOCK_STREAM, 0);

        //bind,作用为将【ip和port和socket文件】和当前进程绑定,为什么要bind绑定呢?套接字sock文件用于通信,首先,如果想要网络通信,则必须通过网卡,所
        //以你必须得指定从哪个网卡(ip)读取数据送到socket文件,这就是bind ip的原因,送到哪个socket文件呢?这是bind sockfd的原因,数据读取完毕后,送到
        //哪个端口(进程)呢?所以你必须指定一个端口号。
       
        //在TCP通信中,通信双方会在3次握手建立连接时交换各自的ip和port信息,比如客户端在调用connect函数发起SYN连接请求的时候,OS会自动把客户端进程绑定的
        //ip和port信息包含进连接请求中发给服务端进程,服务端进程调用accept函数接收连接队列中的连接对象时如果成功,则通过传给accept函数的输出型参数就能知
        //道客户端的ip和port了。
 
        //根据上一段的理论,因为通信双方会在3次握手建立连接时交换各自的ip和port信息,即服务端的OS会在3次握手时自动把服务端进程bind绑定的ip和port信息发送
        //给客户端进程,注意这个包含ip和port的信息是先经过网络,再被客户端进程接收的,而网络资源又是寸土寸金的,发送的数据越小越好,所以在双方进行3次握手
        //建立连接前(3次握手发生在调用listen函数后、调用accept函数前这个时间段内),是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机采用的字
        //节序列不一致,所以在双方进行3次握手建立连接前(3次握手发生在调用listen函数后、调用accept函数前这个时间段内),还需要将转化成整形的ip从主机序列转
        //化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)。注意因为服务端和客户端3次握手交换ip和port信息时、服务端把自己的
        //ip和port的信息发送给客户端时,是OS自动把服务端进程bind绑定的ip和port包含进发送的交换信息中,所以如果想要服务端在进行3次握手时发送出去的ip和port
        //是网络序列,那在服务端进程bind绑定ip和port时,ip和port就应该是网络序列,所以在服务端bind绑定ip和port前,需要将这些信息转化成网络序列。
 
        //根据上上段的理论,客户端调用connect函数发起连接请求时会把请求信息先发送到网络中,然后另一端的服务端进程会从网络中获取到这个请求,注意因为客户端调
        //用connect发起连接请求时,OS会自动把客户端进程绑定的ip和port信息包含进发起的请求中,而网络资源又是寸土寸金的,发送的数据越小越好,所以在客户端调用
        //connect发送连接请求前,是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机采用的字节序列不一致,所以在客户端调用connect前还需要将转化成
        //整形的ip从主机序列转化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)的。注意因为客户端调用connect函数发送连接请求信
        //息到网络时,是OS自动把客户端进程bind绑定的ip和port包含进发送的连接请求中,所以如果想要客户端调用connect发送连接请求时ip和port是网络序列,那在客户
        //端进程bind绑定ip和port时,ip和port就应该是网络序列,这个对于客户端进程来说不必担心,对于客户端进程来说一般是不必显示调用bind函数绑定ip和port的,
        //如果不显示调用bind函数,则客户端调用connect函数向服务端发起连接请求时,OS会自动把当前机器的ip和随机分配的一个port给客户端进程进行bind,并且ip和
        //port在绑定前也由OS自动转化成网络序列了。(关于为什么不必显示调用bind的原因在和UDP网络服务器的模拟实现相关的文章中说明过了)
       
        //说一下,bind绑定这些信息(即ip和port)时,是先把需要绑定的信息全填充到一个sockaddr类的对象中,之后bind只需要绑定这个sockaddr类的对象即可,由于该
        //结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
        sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = (_ip.empty()==true?INADDR_ANY:inet_addr(_ip.c_str()));
        local.sin_port = htons(_port);
        if (bind(_listen_sock, (sockaddr*)&local, sizeof local) < 0)
        {
            exit(1);
        }
        //TCP是面向连接的,正式通信前需要先建立连接
        if(listen(_listen_sock, 20) < 0)
        { 
            exit(1);
        }
        cout<<"服务端初始化成功"<<endl;
    }

    void start()
    {
        signal(SIGCHLD,SIG_IGN);//通过该函数能让僵尸的子进程不需要父进程回收,而是自动释放自己的PCB以及相关内核数据结构以避免内存泄漏
        while(1)
        {
            //监听成功后,这里需要从监听套接字中获取连接
            sockaddr_in other_side;
            socklen_t len = sizeof other_side;
            int service_sock = accept(_listen_sock, (sockaddr*)&other_side, &len);
            if (service_sock < 0)
            {
                cout<<"获取监听套接字中的连接失败,即将重新获取"<<endl;
                continue;
            }
            //获取连接成功,开始通信
            //version 2 -- 多进程版本
            cout<<"获取连接成功"<<endl;

            //创建子进程,让service_sock对应的套接字文件在子进程中和其他主机上的客户端进程进行数据的传输。
            pid_t id = fork();
            if (id == 0)//子进程走这里
            {
                //子进程被创建出来后,自己的内核数据结构中的信息会完全拷贝父进程,所以子进程能和父进程看到的文件描述符表中的内容是完全一致的,一定不要忘了父子进程中各
                //自有一份文件描述符表,相互是独立的,父子进程各自管理属于自己的表,修改自己的表不会影响到对方的表。此时因为子进程不需要访问文件描述符listen_sock对应
                //的文件,所以子进程将它close关闭,避免无效使用内存。需要注意的是不要认为子进程关闭了listen_sock文件描述符,OS就会将其对应的监听套接字文件从内存中删
                //除(即把该文件对应的struct file清除)导致父进程无法通过该监听套接字文件继续accept获取其他客户端的连接请求,这是错误的认识。正确的认识为:OS内部会有
                //一个表示正在使用该文件的进程的总数的引用计数,只有当所有打开过该文件的进程都close掉该文件对应的文件描述符,即引用计数为0的时候,该文件才会真正被OS删
                //除,其他情况的时候只不过是把计数减一了而已。然后需要注意的是这里不要和多线程中的知识点混淆了,在多线程中新线程是看不见主线程中的局部变量的;但这是多进程,
                //在子进程没有修改数据发生写时拷贝前,父子进程中数据的虚拟地址以及虚拟地址映射的物理地址都完全相同,换言之父子进程的所有数据都是共享的,所以子进程能看到
                //在父进程中创建的变量listen_sock。
                close(_listen_sock);
                //再让子进程去执行service函数
                service(service_sock, other_side);

                //对多进程编程不太熟悉的小伙伴们不要认为这里子进程走完当前的if分支后就自动进程退出了,是不会退出的,所以如果想要子进程退出(即结束子进程),则一定要在子进
                //程执行完service函数后显示调用exit函数,否则子进程会继续循环,跑到上面去调用accept,因为在子进程中,监听套接字已经被close关闭了,那么accept一定会失败,
                //返回值就<0,然后子进程就陷入了不断continue的过程,子进程就永远无法自动进程退出。
                //进程马上要被exit(0)销毁了,在销毁时OS会自动帮子进程把service_sock给close的,所以子进程这里就不必显示调用close(service_sock)了。
                exit(0);
            }
            else//父进程走这里
            {
                //在多进程版本下,父进程需要频繁的调用accept函数接收若干客户端的连接请求,也就要频繁创建服务套接字文件(文件对应的描述符就是service_sock),让这些服务
                //套接字文件和其他主机的客户端进程中的套接字文件进行数据传输。但注意数据传输的工作是在子进程中完成的,父进程只需要完成服务套接字文件和子进程的创建工作即
                //可,所以父进程是不需要访问这些服务套接字文件的,所以在父进程中需要统统将他们close关闭掉,否则当有过多的客户端通过connect函数向服务端发起连接请求时就会
                //导致父进程的文件描述符表被占满从而导致调用accept创建不出来服务套接字文件从而导致调用accept函数失败。
                close(service_sock);

                //在父进程中还需要解决的一个问题是僵尸子进程的回收问题,父进程是服务器,肯定是不会退出的,所以子进程退出时父进程一定是没有退出的,所以子进程就变成了僵尸进程,
                //如果不回收子进程,子进程的PCB以及相关内核数据结构就不会被释放从而内存泄漏。如果父进程以阻塞的模式调用waitpid函数去回收子进程,则父进程在子进程完成与其他主
                //机上的客户端的通信工作前,是没法再次创建子进程让该子进程和另一台主机上的客户端去通信的,这样一来多进程版本的服务端类TcpServer和之前模拟实现的单进程版本的服
                //务端类TcpServer就没有了区别,服务端进程都是在同一时间处理一个客户端,处理完毕后才能处理下一个;可如果父进程以非阻塞的模式调用waitpid函数,此时的确能正常的创
                //建多个子进程并让它们去和多个客户端进程通信,但因为其他若干主机上的客户端与本机的服务端通信的时长是不同的,不知道哪些进程已经通信结束,哪些正在通信,所以要想
                //回收本机上服务端进程为了和若干其他主机上的客户端进程通信而创建出若干子进程,则需要将父进程创建出的所有子进程对应的进程ID(即pid_t类型的对象)都通过某种容器存
                //储起来,每次父进程在回收子进程的时候,都需要遍历该容器,看看哪些子进程可以被父进程通过waitpid函数回收,这样做的确是可行的,但因为父进程在start的循环中每次循
                //环都要尝试回收若干子进程,检查是否有子进程可以被回收,而检查本身又是一个通过循环遍历容器并调用waitpid的过程,可能此时没有任何子进程可以被回收,但依然需要傻傻
                //的进行检测,所以这样效率就太差了,此时有人可能会想到信号,在讲解信号的章节中我们说过子进程退出是会给父进程发SIGCHLD信号的,我们可以将回收子进程的逻辑放进一个
                //函数中,然后将该函数设置成SIGCHLD信号的信号处理函数,这样就能避免之前的每次循环都无脑检测。这里我想说的是,这样的确算是一种优化,提高了效率,但我们有更好的方
                //式去回收子进程,在讲解信号的章节中,我们提到过一种方案,能够不需要父进程回收,子进程能够自己把成为僵尸进程的自己自动释放掉,在这里我们就使用这样的方法,即直接
                //在当前start函数的第一行写上代码signal(SIGCHLD,SIG_IGN)即可,因为在信号的章节中说过,对于SIGCHLD信号,如果是主动忽略SIGCHLD信号,子进程退出的时候,会自动释
                //放自己的僵厂状态,即释放掉自己的PCB以及相关内核数据结构。
            }
        }
    }

    // //start成员函数的第二种写法
    // void start()
    // {
    //     while(1)
    //     {
    //         //监听成功后,这里需要从监听套接字中获取连接
    //         sockaddr_in other_side;
    //         socklen_t len = sizeof other_side;
    //         int service_sock = accept(_listen_sock, (sockaddr*)&other_side, &len);
    //         if (service_sock < 0)
    //         {
    //             cout<<"获取监听套接字中的连接失败,即将重新获取"<<endl;
    //             continue;
    //         }
    //         //获取连接成功,开始通信
    //         //version 2 -- 多进程版本
    //         cout<<"获取连接成功"<<endl;

    //         //创建子进程,让service_sock对应的套接字文件在子进程中和其他主机上的客户端进程进行数据的传输。
    //         pid_t id = fork();
    //         if (id == 0)//子进程A走这里
    //         {
    //             //子进程被创建出来后,自己的内核数据结构中的信息会完全拷贝父进程,所以子进程能和父进程看到的文件描述符表中的内容是完全一致的,一定不要忘了父子进程中各
    //             //自有一份文件描述符表,相互是独立的,父子进程各自管理属于自己的表,修改自己的表不会影响到对方的表。此时因为子进程不需要访问文件描述符listen_sock对应
    //             //的文件,所以子进程将它close关闭,避免无效使用内存。需要注意的是不要认为子进程关闭了listen_sock文件描述符,OS就会将其对应的监听套接字文件从内存中删
    //             //除(即把该文件对应的struct file清除)导致父进程无法通过该监听套接字文件继续accept获取其他客户端的连接请求,这是错误的认识。正确的认识为:OS内部会有
    //             //一个表示正在使用该文件的进程的总数的引用计数,只有当所有打开过该文件的进程都close掉该文件对应的文件描述符,即引用计数为0的时候,该文件才会真正被OS删
    //             //除,其他情况的时候只不过是把计数减一了而已。然后需要注意的是这里不要和多线程中的知识点混淆了,在多线程中新线程是看不见主线程中的局部变量的;但这是多进程,
    //             //在子进程没有修改数据发生写时拷贝前,父子进程中数据的虚拟地址以及虚拟地址映射的物理地址都完全相同,换言之父子进程的所有数据都是共享的,所以子进程能看到
    //             //在父进程中创建的变量listen_sock。
    //             close(_listen_sock);

    //             if(fork() > 0)//子进程A走这里
    //             {
    //                 exit(0);
    //             }
    //             else//子进程B走这里
    //             {
    //                 service(service_sock, other_side);
    //                 //对多进程编程不太熟悉的小伙伴们不要认为这里的进程走完当前的if分支后就自动进程退出了,是不会退出的,所以如果想要当前进程退出(即结束进程),则一定要在当前进
    //                 //程执行完service函数后显示调用exit函数,否则当前进程会继续循环,跑到上面去调用accept,因为在当前进程中,监听套接字已经被close关闭了,那么accept一定会失败,
    //                 //返回值就<0,然后当前进程就陷入了不断continue的过程,当前进程就永远无法自动进程退出。
    //                 //进程马上要被exit(0)销毁了,在销毁时OS会自动帮当前进程把service_sock给close的,所以当前进程这里就不必显示调用close(service_sock)了。
    //                 exit(0);
    //             }
    //         }
    //         else//父进程走这里
    //         {
    //             //在多进程版本下,父进程需要频繁的调用accept函数接收若干客户端的连接请求,也就要频繁创建服务套接字文件(文件对应的描述符就是service_sock),让这些服务
    //             //套接字文件和其他主机的客户端进程中的套接字文件进行数据传输。但注意数据传输的工作是在子进程中完成的,父进程只需要完成服务套接字文件和子进程的创建工作即
    //             //可,所以父进程是不需要访问这些服务套接字文件的,所以在父进程中需要统统将他们close关闭掉,否则当有过多的客户端通过connect函数向服务端发起连接请求时就会
    //             //导致父进程的文件描述符表被占满从而导致调用accept创建不出来服务套接字文件从而导致调用accept函数失败。
    //             close(service_sock);
    //             waitpid(id, nullptr, 0);//第三个参数为0表示阻塞等待
    //         }
    //     }
    // }

    ~TcpServer()
    {
        if (_listen_sock > 0)
            close(_listen_sock);
    }

private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
};


对通过【多进程版本的服务端类TcpServer】实现的网络服务器的测试(包括如何实现群聊功能的思路)

在测试通过【多进程版本的服务端类TcpServer】实现的网络服务器时,tcp_server.cc文件不需要经过任何修改,直接把上文中的拿来用即可,将tcp_server.cc文件编译成可执行文件后,直接在一个ssh渠道中运行它,在运行时要传入命令行参数:

  • 如果没有特殊需求,不必传ip地址,把端口号8080设置成命令行参数传给服务端进程即可,因为我们的编码逻辑是在不显示传ip时,ip会被设置成缺省参数的值(即INADDR_ANY),之后服务端进程会bind绑定INADDR_ANY地址,这样一来服务端进程就能从任意一个网卡中读取数据、就能向任意一个网卡中发送数据,从而提高IO效率。

虽然现在还没有编写客户端的代码,但我们可以通过复制一个ssh渠道,在另一个shell界面中使用使用telnet命令连接咱们编写的正在运行中的服务端进程。能连接成功的原因是:因为咱们服务端是基于TCP协议进行编写的(即使用的都是基于TCP协议的系统接口),而telnet底层实际采用的也是TCP协议,所以双方才能连接成功并通信。

我们先测试本地通信,在<<套接字socket编程的基础知识点>>一文中说过,某个进程bind绑定INADDR_ANY地址后,不光可以让其他主机上的进程访问该进程,也是可以让本地(即本机)上的其他进程访问该进程的,当前的服务端进程bind绑定的ip就是INADDR_ANY地址,所以本地上的telnet进程是可以访问同属于本地的服务端进程的。在运行telnet命令时要传入命令行参数:

  • 把本地环回地址(即ip地址127.0.0.1)和服务端进程的端口号8080设置成命令行参数传给telnet指令,让充当客户端的telnet进程知道服务端进程在哪。
  • 上一步完毕后,还需要按CTRL键加上右方括号(即 “]” ),然后按下enter键,然后充当客户端进程的telnet就可以给服务端进程发信息了。
  • 说一下,如果想要退出telnet进程,首先需要按CTRL键加上右方括号(即 “]” ),然后输入quit即可。

这样一来,客户端进程telnet和服务端进程tcp_server收发数据时就只在本地协议栈中进行数据流动,不会把我们的数据发送到网络中。

测试结果如下,可以发现在下图1中,不再发生和上文中在测试【通过单进程版本的服务端类TcpServer实现的网络服务器】时一样的情况了,而是即使同时有多个客户端进程来连接服务端进程,也可以成功连接,图1中的红框处有两条【获取连接成功】的语句就是证明。

并且在下图2中可以发现,不仅连接建立成功了,多个客户端也是可以同时给服务端发信息的,服务端也收到信息并将信息原模原样地返回给了对应的客户端;图2中的红框处有3个tcp_server进程也能很好的证明了多个客户端与服务端的连接建立成功了,因为3个tcp_server进程中,只有一个是父进程,其他两个都是子进程,这两个子进程是父进程创建出来替父进程和客户端进程通信的,而刚好咱们也只创建了两个ssh渠道,也就是只创建了两个telnet进程去充当客户端,数量上也都对的上。

问题:可以看到下图2中每个客户端只能收到自己发送给服务端的信息,那如果客户端A想要看到其他所有客户端发的消息,即像一个群聊一样,该怎么实现呢?

答案:客户端的代码不用变,只需要简单地修改一下服务端的代码即可,思路如下:

  • 在服务端调用accept接收客户端的连接请求时,会通过给accept函数传入sockaddr_in类型的输出型参数获取客户端的ip和port信息,那么每当服务端接收连接请求成功时,就都把该sockaddr_in类型的对象push放进vector或者其他容器中,之后服务端每收到一个客户端的消息,就不再只把该消息发给这一个客户端,而是遍历vector,把这个消息发给每一个客户端,这样就能完成群发的功能,比如我发的你能看见,而你发的我也能看见,这样就形成了和一个聊天群一样的功能。

客户端的模拟实现

tcp_client.cc的整体代码

在上文所有测试中,我们都是让telnet指令充当客户端,现在咱们自己编写一个客户端,基本思路很简单,如下:

  • 让客户端进程给服务端进程发信息,然后服务端再把收到的信息原模原样的返回给客户端,然后客户端将这个信息cout打印出来。
  • 剩余的说明都在下面代码的注释中了。

tcp_client.cc的整体代码如下。

#include<iostream>
using namespace std;
#include<string.h>//提供memset
#include <unistd.h>//提供close
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体

void usage(char* c)
{
    printf("usage:%s ip port\n", c);
}

// ./tcp_client ServerIp ServerPort
int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        usage(argv[0]);
        exit(1);
    }
    //创建套接字文件
    int sock = socket(AF_INET, SOCK_STREAM, 0);

    //接下来通过connect函数与服务端进程建立连接。

    //在文章中也说过,客户端调用connect函数发起连接请求时会把请求信息先发送到网络中,然后另一端的服务端进程会从网络中获取到这个请求,注意因为客户端调用connect发起连接请求时,OS会自动把客户端
    //进程绑定的ip和port信息包含进发起的请求中,而网络资源又是寸土寸金的,发送的数据越小越好,所以在客户端调用connect发送连接请求前,是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机
    //采用的字节序列不一致,所以在客户端调用connect前还需要将转化成整形的ip从主机序列转化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)的。注意因为客户端调用connect函
    //数发送连接请求信息到网络时,是OS自动把客户端进程bind绑定的ip和port包含进发送的连接请求中,所以如果想要客户端调用connect发送连接请求时ip和port是网络序列,那在客户端进程bind绑定ip和port时,
    //ip和port就应该是网络序列,这个对于客户端进程来说不必担心,对于客户端进程来说一般是不必显示调用bind函数绑定ip和port的,如果不显示调用bind函数,则客户端调用connect函数向服务端发起连接请求时,OS
    //会自动把当前机器的ip和随机分配的一个port给客户端进程进行bind,并且ip和port在绑定前也由OS自动转化成网络序列了。(关于为什么不必显示调用bind的原因在和UDP网络服务器的模拟实现相关的文章中说明过了)

    //(紧接上一段)注意虽然客户端的网络信息不需要程序员手动从主机序列编码转化成网络序列,但调用connect函数发起连接请求时是要告诉它该向谁发起请求的,这也是为什么connect函数需要sockaddr*类型的参数,这个
    //参数也是作为信息的一部分需要发送到网络中的(不然怎么在路由时问路),那么这个信息就需要从主机序列转化成网络序列。
    sockaddr_in peer;
    memset(&peer, 0, sizeof peer);
    peer.sin_port = htons(atoi(argv[2]));
    peer.sin_addr.s_addr = inet_addr(argv[1]);
    peer.sin_family = AF_INET;//千万不要只顾着ip和port,不要把这个协议家族给忘了,否则到时候send和recv函数都会中途崩溃掉
    if (connect(sock, (sockaddr*)&peer, sizeof(peer)) < 0)
    {
        cerr<<"申请连接请求失败,客户端即将退出"<<endl;
        exit(1);
    }
    //走到这里连接已经建立成功,接下来开始与服务端通信
    while(1)
    {
        string message;
        cout<<"请输入要发给服务端的信息#:";
        getline(cin, message);
        //这边收发信息时换一套接口,从【write和read】换成【send和recv】,两套接口都是可以的,这边换的原因只是为了多样化。
        send(sock, message.c_str(), message.size(), 0);//函数原型为:ssize_t send(int sockfd, const void *buf, size_t len, int flags),可以看出这里第三个参数传message.size()是很巧妙的。
        char buffer[1024];
        ssize_t s = recv(sock, buffer, sizeof(buffer)-1, 0);
        if(s > 0)
        {
            buffer[s] = 0;//注意'\0'的阿斯克码就是0
            cout<<"服务端原模原样返回给我的信息为:"<<buffer<<endl;
        }
        else if(s == 0)
        {
            cout<<"服务端的套接字被关闭,双方的连接断开,客户端即将退出"<<endl;
            break;
        }
        else
        {
            cout<<"客户端读取错误,即将退出"<<endl;
            break;
        }
    }
    close(sock);
    return 0;
}

加上客户端的代码后再对【通过多进程版本的服务端类TcpServer实现的网络服务器】进行测试

和上文中标题为对通过【多进程版本的服务端类TcpServer】实现的网络服务器的测试的部分相比,这里的测试只是把充当客户端的程序给变了,其他的测试环境和之前相比没有发生任何变化,比如在上文中我们没有自己实现客户端的代码,所以上文中是让telnet指令(或者说程序)充当客户端;而现在咱们模拟实现出了tcp_client.cc,将它编译后咱们可以让它去充当客户端。

将tcp_server.cc文件编译成可执行文件tcp_server(即服务端程序)后,直接在一个ssh渠道中运行它,在运行时要传入命令行参数:

  • 如果没有特殊需求,不必传ip地址,把端口号8080设置成命令行参数传给服务端进程即可,因为我们的编码逻辑是在不显示传ip时,ip会被设置成缺省参数的值(即INADDR_ANY),之后服务端进程会bind绑定INADDR_ANY地址,这样一来服务端进程就能从任意一个网卡中读取数据、就能向任意一个网卡中发送数据,从而提高IO效率。

将tcp_client.cc文件编译成可执行文件tcp_client后,因为测试的是多进程模式下的工作情况,所以分别在多个ssh渠道中各自运行tcp_client(即客户端程序)。我们先测试本地通信,在<<套接字socket编程的基础知识点>>一文中说过,某个进程bind绑定INADDR_ANY地址后,不光可以让其他主机上的进程访问该进程,也是可以让本地(即本机)上的其他进程访问该进程的,当前的服务端进程bind绑定的ip就是INADDR_ANY地址,所以本地上的客户端进程tcp_client是可以访问同属于本地的服务端进程tcp_server的。在运行tcp_client时要传入命令行参数:

  • 把本地环回地址(即ip地址127.0.0.1)和服务端进程的端口号8080设置成命令行参数传给tcp_client,让充当客户端的tcp_client进程知道服务端进程在哪。

这样一来,客户端进程tcp_client和服务端进程tcp_server收发数据时就只在本地协议栈中进行数据流动,不会把我们的数据发送到网络中。

测试结果如下,可以发现在下图1中,不再发生和上文中在测试【通过单进程版本的服务端类TcpServer实现的网络服务器】时一样的情况了,而是即使同时有多个客户端进程来连接服务端进程,也可以成功连接,图1中的红框处有两条【获取连接成功】的语句就是证明。

并且在下图2中可以发现,不仅连接建立成功了,多个客户端也是可以同时给服务端发信息的,服务端也收到信息并将信息原模原样地返回给了对应的客户端;图2中的红框处有3个tcp_server进程也能很好的证明了多个客户端与服务端的连接建立成功了,因为3个tcp_server进程中,只有一个是父进程,其他两个都是子进程,这两个子进程是父进程创建出来替父进程和客户端进程通信的,而刚好咱们也只创建了两个ssh渠道,也就是只创建了两个tcp_client进程去充当客户端,数量上也都对的上。

多线程版本的服务端类TcpServer的模拟实现

学了系统的我们知道创建进程的代价太大了,比如每创建一个进程要创建出该进程PCB、虚拟地址空间、页表等等内核数据结构,所以比起多进程版本,我们的服务端类设计成多线程版本无疑是更优的,如何实现呢?

在标题为前言的部分中就说过,本文中分类分成单进程版本的服务端类TcpServer、多进程版本的服务端类TcpServer、多线程版本的服务端类TcpServer、线程池版本的服务端类TcpServer只是因为这四个版本的TcpServer类中有一个成员函数start的实现不太一样,至于其他的成员变量或者成员函数在四个版本中都是完全一致的,所以只要将start函数修改成多线程版本,那多线程版本的服务端类TcpServer的模拟实现也就完毕了,整体代码也就出来了,所以首先说一说如何实现start成员函数,请往下看。

服务端类TcpServer的start函数(多线程版本)

(tips:如果对以下内容感到疑惑,建议结合<<套接字socket编程的基础知识点>>一文阅读)

对于我们当前模拟实现的服务器类TcpServer,如果想让服务器跑起来,则调用其成员函数的顺序是【TcpServer类的构造函数--->TcpServer类的初始化函数initServer--->TcpServer类的start函数--->TcpServer类的析构函数】,可以看到start函数是在初始化函数之后的,走完初始化函数后,已经完成了socket文件的创建、将【当前进程】和【转化成网络字节序后的某个ip地址和某个端口port外加刚创建好的socket文件】进行绑定,剩下的工作就是start函数需要完成的了,需要做的事情为:

  • (结合下图思考)设置一个死循环,每次循环都要做的事情是【调用accept函数从一直处于监听状态的listen_sock中尝试获取其他客户端进程通过connect函数发过来的连接请求,如果accept成功,则accpet函数会创建一个服务套接字文件并返回指向该服务套接字文件的文件描述符service_sock,期望让这个服务套接字文件替监听套接字文件打工,即期望让服务套接字文件去和客户端通信,所以接下来就需要程序员编码控制来实现这个期望。既然现在实现的是多线程版本的start成员函数,所以首先需要调用pthread_create函数创建新线程,然后让service_sock对应的套接字文件在新线程中和其他主机上的客户端进程进行数据的传输;父进程就只专心完成接收其他客户端的连接请求并创建服务套接字文件,然后再创建一个去和客户端通信的新线程即可。那如何让service_sock对应的套接字文件在新线程中和其他主机上的客户端进程进行数据的传输呢?在上文中(具体在标题为服务端类TcpServer的start函数(单进程版本)的部分中)我们已经实现了一个用于让服务端的服务套接字文件给客户端的套接字文件通信(即收发信息)的service函数,现在我们只需要在新线程的线程函数中调用service函数即可让service_sock对应的套接字文件在新线程中和其他主机上的客户端进程进行数据的传输。注意主线程在调用accept函数接收客户端发来的连接请求时,需要创建一个sockaddr类的对象作为输出型参数,以拿到客户端的ip和port信息,这样当前进程(即服务端进程)才能在需要向客户端发送信息时知道目的地(即客户端)在哪。

关于【服务端类TcpServer的start函数(多线程版本)】的剩余说明,都在下面代码的注释中了,请结合代码思考。

结合上面的理论,服务端类TcpServer的start函数(多线程版本)的代码如下。

//当前service的逻辑就是接收客户端发来的信息并原模原样返回给客户端
void service(int service_sock, sockaddr_in other_side)
{
    char* ip = inet_ntoa(other_side.sin_addr);
    uint16_t port = ntohs(other_side.sin_port);
    char buffer[64];
    while(1)
    {
        ssize_t size = read(service_sock, buffer, sizeof(buffer)-1);
        if(size > 0)
        {
            buffer[size] = '\0';//因为在设计客户端与服务端通信时,设计的逻辑是双方都只发送C语言字符串,所以通信通道中的信息就全是C语言字符串,所以这里需要在读到的数据的末尾设置0,防止打印时出现乱码
            printf("ip为%s、port为%d的客户端发给我(即服务端进程)的信息为:%s\n", ip, port, buffer);
        }
        else if(size == 0)//如果返回值为0,则表示给我write发送信息的对端进程中的套接字文件对应的文件描述符被close关闭了,既然没人给我发数据了,那这边我们也就不必再读了,直接break
        {
            cout<<"客户端的套接字被关闭,客户端不会再给我(服务端)发送信息了,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        else
        {
            cout<<"服务端读取数据异常,发生了未知错误,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        //走到这里一定是read接客户端发来的信息成功了,将数据原模原样返回给客户端即可
        write(service_sock, buffer, sizeof(buffer)-1);

    }
}

struct threadData
{
    sockaddr_in x;
    int sockfd;
};

class TcpServer
{
private:
    //注意必须是静态函数,因为作为线程函数必须只能有一个void*类型的参数,而类内非静态成员函数都是有this指针的,所以需要用static修饰去掉this指针
    static void* threadRoutine(void* args)//不要把void和void*混淆了,前者表示函数没有参数;后者表示函数是有参数的,表示可以接收任意类型的地址
    {
        pthread_detach(pthread_self());//用于线程分离的函数,这样一来主线程就无需关心新线程的回收问题了(即主线程无需调用pthread_join函数阻塞等待回收新线程了),新线程退出时相关资源自动被释放(类似于进程中僵尸进程的自动释放)
        threadData* td = (threadData*)args;
        service(td->sockfd, td->x);
        close(td->sockfd);//服务套接字文件是用于在新线程中和客户端中的套接字文件通信的,新线程退出结束后,该服务套接字文件也就没用了,直接close避免文件描述符泄漏
        delete td;
        return nullptr;
    }
public:
    void start()
    {
        while(1)
        {
            //监听成功后,这里需要从监听套接字中获取连接
            sockaddr_in other_side;
            socklen_t len = sizeof other_side;
            int service_sock = accept(_listen_sock, (sockaddr*)&other_side, &len);
            if (service_sock < 0)
            {
                cout<<"获取监听套接字中的连接失败,即将重新获取"<<endl;
                continue;
            }
            //获取连接成功,开始通信
            //version 3 -- 多线程版本
            cout<<"获取连接成功"<<endl;
            //开始创建新线程,让新线程在线程函数中帮主线程去收发信息(即通信)

            //说一下,如果想让新线程在线程函数中帮主线程去收发信息(即通信),那么是一定要把主线程获取到的服务端套接字文件对应的文件描述符service_sock作为参数传给新线程的线程函数的,
            //需要传的原因是:虽然所有线程共用一个文件描述符表,即任意一个线程打开的文件在所有线程中都可以访问,但现在的问题是因为线程之间并不共享局部变量,而service_sock是主线程中
            //创建的局部变量,所以新线程并不知道主线程创建的服务套接字文件对应的文件描述符service_sock是多少,所以新线程就不知道该服务套接字文件是哪一个,也就不知道该访问哪一个,所
            //以主线程需要将service_sock作为参数传给新线程的线程函数。除此之外,因为我们在类外设计出了service函数(并不是线程函数),期望让service函数去完成收发信息的功能,比如让新
            //线程在线程函数中再调用service函数去收发信息,而service函数还需要一个sockaddr_in类型的参数去在函数内打印客户端的ip和port信息,提示这是哪个客户端在给服务端发送信息,以此
            //方便调试、观察,所以除了需要将主线程中的局部变量service_sock作为参数传给新线程的线程函数,还要将主线程中的表示对端(即客户端)网络属性信息的局部变量 —— sockaddr_in类型
            //的other_side也作为参数传给新线程的线程函数,所以此时就需要设计一个可以表示这两个数据的结构体(或者说类),我们叫它threadData类。

            //注意在主线程中创建threadData类的变量时,必须在堆上创建,因为如果在栈上创建,则该对象就是一个局部变量,每次出了循环进入下一次循环时都会将该变量销毁,会将变量里的成员都设置
            //成随机值,假如在新线程的线程函数中,在线程函数还没有使用该变量前,该变量就因主线程进入下一次循环而被销毁了,那么后序在新线程的线程函数中通过这个已经销毁的变量的地址去访问该
            //变量肯定是不合法的,会解引用野指针报错,这是典型的线程安全问题;正确的方式是在堆上创建该变量,这时该对象就不会因为出了循环而被销毁,每次销毁的只不过是指向该变量的指针变量罢了,
            //指针变量被销毁对新线程来说是没有影响的,因为只要线程函数启动成功了,参数肯定是早就传递完毕的,即该指针变量的值早就被线程函数的void*类型的形参给拷贝成功了,所以也就不需要该指
            //针变量了。说一下,每个线程都有自己的线程ID,线程库会自动将新线程的ID存储在pthread_t变量中以便主线程能够跟踪它们,pthread_t变量仅用于在主线程中识别和管理新创建的线程,并不影
            //响新线程的任意行为,即新线程中并不需要使用该pthread_t类型的变量,所以即使该变量的值中途可能会随时发生变化,该变量对于新线程来说也是线程安全的,对于主线程来说,当前主线程代码
            //的逻辑也不需要使用该pthread_t类型的变量,所以即使该变量的值中途可能会随时发生变化,该变量对于主线程来说也是线程安全的,综上可以发现对于所有线程来说该变量都是线程安全的,所以
            //在下面创建pthread_t类型的变量时就不必从堆上开辟空间了。
                        
            // //根据上一段的理论,错误写法如下
            // threadData data;
            // data.sockfd = service_sock;
            // data.x = other_side;
 
            //根据上一段的理论,正确写法如下
            threadData *p = new threadData;
            p->sockfd = service_sock;
            p->x = other_side;

            pthread_t tid;
            pthread_create(&tid, nullptr, threadRoutine, (void*)p);//说一下,和子进程不太一样,新线程的线程函数执行完后新线程就自动退出了,不会继续向后执行代码;而在子进程中如果不手动调用exit函数,则子进程不会自动退出         

            //注意在多线程模式下和多进程模式下有一点不一样。在多进程中,我们需要在子进程中关闭监听套接字文件对应的文件描述符,需要在父进程中关闭服务套接字文件对应的描述符,否则会导致
            //文件描述符泄漏;但在多线程模式下,是一定不能这样做的,因为所有的线程只组成了一个进程,每个进程只有一个文件描述符表,所以所有的线程共同使用同一个文件描述符表,也就是说如果
            //在主线程中close关闭了服务套接字文件对应的文件描述符,那么在新线程中就没有媒介去和客户端进程通信了,同理如果在新线程中close关闭了监听套接字文件对应的文件描述符,那在主线
            //程中也就没有媒介去接收客户端的连接请求了。
        }
    }
private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
};

多线程版本的服务端类TcpServer的整体代码(因为start成员函数是多线程版本,所以TcpServer类就是多线程版本)

在标题为前言的部分中就说过,本文中分类分成单进程版本的服务端类TcpServer、多进程版本的服务端类TcpServer、多线程版本的服务端类TcpServer、线程池版本的服务端类TcpServer只是因为这四个版本的TcpServer类中有一个成员函数start的实现不太一样,至于其他的成员变量或者成员函数在四个版本中都是完全一致的,所以现在既然start函数修改成多线程版本的了,多线程版本的服务端类TcpServer的整体代码也就出来了,如下。(以下是Tcp_server.h文件的整体代码)

#include<iostream>
using namespace std;
#include<string.h>//提供bzero函数
#include<memory>//提供智能指针的库
#include<cstdlib>//提供atoi函数、exit函数
#include <unistd.h>//提供close
#include<signal.h>//提高signal函数
#include <sys/wait.h>//提供wait函数
#include<pthread.h>//线程库
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体

//当前service的逻辑就是接收客户端发来的信息并原模原样返回给客户端
void service(int service_sock, sockaddr_in other_side)
{
    char* ip = inet_ntoa(other_side.sin_addr);
    uint16_t port = ntohs(other_side.sin_port);
    char buffer[64];
    while(1)
    {
        ssize_t size = read(service_sock, buffer, sizeof(buffer)-1);
        if(size > 0)
        {
            buffer[size] = '\0';//因为在设计客户端与服务端通信时,设计的逻辑是双方都只发送C语言字符串,所以通信通道中的信息就全是C语言字符串,所以这里需要在读到的数据的末尾设置0,防止打印时出现乱码
            printf("ip为%s、port为%d的客户端发给我(即服务端进程)的信息为:%s\n", ip, port, buffer);
        }
        else if(size == 0)//如果返回值为0,则表示给我write发送信息的对端进程中的套接字文件对应的文件描述符被close关闭了,既然没人给我发数据了,那这边我们也就不必再读了,直接break
        {
            cout<<"客户端的套接字被关闭,客户端不会再给我(服务端)发送信息了,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        else
        {
            cout<<"服务端读取数据异常,发生了未知错误,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        //走到这里一定是read接客户端发来的信息成功了,将数据原模原样返回给客户端即可
        write(service_sock, buffer, sizeof(buffer)-1);

    }
}

struct threadData
{
    sockaddr_in x;
    int sockfd;
};

class TcpServer
{
private:
    //注意必须是静态函数,因为作为线程函数必须只能有一个void*类型的参数,而类内非静态成员函数都是有this指针的,所以需要用static修饰去掉this指针
    static void* threadRoutine(void* args)//不要把void和void*混淆了,前者表示函数没有参数;后者表示函数是有参数的,表示可以接收任意类型的地址
    {
        pthread_detach(pthread_self());//用于线程分离的函数,这样一来主线程就无需关心新线程的回收问题了(即主线程无需调用pthread_join函数阻塞等待回收新线程了),新线程退出时相关资源自动被释放(类似于进程中僵尸进程的自动释放)
        threadData* td = (threadData*)args;
        service(td->sockfd, td->x);
        close(td->sockfd);//服务套接字文件是用于在新线程中和客户端中的套接字文件通信的,新线程退出结束后,该服务套接字文件也就没用了,直接close避免文件描述符泄漏
        delete td;
        return nullptr;
    }
public:
    TcpServer(uint16_t port, string ip = "")
        :_port(port)
        ,_ip(ip)
    {}

    void InitServer()
    {
        _listen_sock = socket(AF_INET, SOCK_STREAM, 0);

        //bind,作用为将【ip和port和socket文件】和当前进程绑定,为什么要bind绑定呢?套接字sock文件用于通信,首先,如果想要网络通信,则必须通过网卡,所
        //以你必须得指定从哪个网卡(ip)读取数据送到socket文件,这就是bind ip的原因,送到哪个socket文件呢?这是bind sockfd的原因,数据读取完毕后,送到
        //哪个端口(进程)呢?所以你必须指定一个端口号。
       
        //在TCP通信中,通信双方会在3次握手建立连接时交换各自的ip和port信息,比如客户端在调用connect函数发起SYN连接请求的时候,OS会自动把客户端进程绑定的
        //ip和port信息包含进连接请求中发给服务端进程,服务端进程调用accept函数接收连接队列中的连接对象时如果成功,则通过传给accept函数的输出型参数就能知
        //道客户端的ip和port了。
 
        //根据上一段的理论,因为通信双方会在3次握手建立连接时交换各自的ip和port信息,即服务端的OS会在3次握手时自动把服务端进程bind绑定的ip和port信息发送
        //给客户端进程,注意这个包含ip和port的信息是先经过网络,再被客户端进程接收的,而网络资源又是寸土寸金的,发送的数据越小越好,所以在双方进行3次握手
        //建立连接前(3次握手发生在调用listen函数后、调用accept函数前这个时间段内),是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机采用的字
        //节序列不一致,所以在双方进行3次握手建立连接前(3次握手发生在调用listen函数后、调用accept函数前这个时间段内),还需要将转化成整形的ip从主机序列转
        //化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)。注意因为服务端和客户端3次握手交换ip和port信息时、服务端把自己的
        //ip和port的信息发送给客户端时,是OS自动把服务端进程bind绑定的ip和port包含进发送的交换信息中,所以如果想要服务端在进行3次握手时发送出去的ip和port
        //是网络序列,那在服务端进程bind绑定ip和port时,ip和port就应该是网络序列,所以在服务端bind绑定ip和port前,需要将这些信息转化成网络序列。
 
        //根据上上段的理论,客户端调用connect函数发起连接请求时会把请求信息先发送到网络中,然后另一端的服务端进程会从网络中获取到这个请求,注意因为客户端调
        //用connect发起连接请求时,OS会自动把客户端进程绑定的ip和port信息包含进发起的请求中,而网络资源又是寸土寸金的,发送的数据越小越好,所以在客户端调用
        //connect发送连接请求前,是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机采用的字节序列不一致,所以在客户端调用connect前还需要将转化成
        //整形的ip从主机序列转化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)的。注意因为客户端调用connect函数发送连接请求信
        //息到网络时,是OS自动把客户端进程bind绑定的ip和port包含进发送的连接请求中,所以如果想要客户端调用connect发送连接请求时ip和port是网络序列,那在客户
        //端进程bind绑定ip和port时,ip和port就应该是网络序列,这个对于客户端进程来说不必担心,对于客户端进程来说一般是不必显示调用bind函数绑定ip和port的,
        //如果不显示调用bind函数,则客户端调用connect函数向服务端发起连接请求时,OS会自动把当前机器的ip和随机分配的一个port给客户端进程进行bind,并且ip和
        //port在绑定前也由OS自动转化成网络序列了。(关于为什么不必显示调用bind的原因在和UDP网络服务器的模拟实现相关的文章中说明过了)
       
        //说一下,bind绑定这些信息(即ip和port)时,是先把需要绑定的信息全填充到一个sockaddr类的对象中,之后bind只需要绑定这个sockaddr类的对象即可,由于该
        //结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
        sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = (_ip.empty()==true?INADDR_ANY:inet_addr(_ip.c_str()));
        local.sin_port = htons(_port);
        if (bind(_listen_sock, (sockaddr*)&local, sizeof local) < 0)
        {
            exit(1);
        }
        //TCP是面向连接的,正式通信前需要先建立连接
        if(listen(_listen_sock, 20) < 0)
        { 
            exit(1);
        }
        cout<<"服务端初始化成功"<<endl;
    }

    void start()
    {
        while(1)
        {
            //监听成功后,这里需要从监听套接字中获取连接
            sockaddr_in other_side;
            socklen_t len = sizeof other_side;
            int service_sock = accept(_listen_sock, (sockaddr*)&other_side, &len);
            if (service_sock < 0)
            {
                cout<<"获取监听套接字中的连接失败,即将重新获取"<<endl;
                continue;
            }
            //获取连接成功,开始通信
            //version 3 -- 多线程版本
            cout<<"获取连接成功"<<endl;
            //开始创建新线程,让新线程在线程函数中帮主线程去收发信息(即通信)

            //说一下,如果想让新线程在线程函数中帮主线程去收发信息(即通信),那么是一定要把主线程获取到的服务端套接字文件对应的文件描述符service_sock作为参数传给新线程的线程函数的,
            //需要传的原因是:虽然所有线程共用一个文件描述符表,即任意一个线程打开的文件在所有线程中都可以访问,但现在的问题是因为线程之间并不共享局部变量,而service_sock是主线程中
            //创建的局部变量,所以新线程并不知道主线程创建的服务套接字文件对应的文件描述符service_sock是多少,所以新线程就不知道该服务套接字文件是哪一个,也就不知道该访问哪一个,所
            //以主线程需要将service_sock作为参数传给新线程的线程函数。除此之外,因为我们在类外设计出了service函数(并不是线程函数),期望让service函数去完成收发信息的功能,比如让新
            //线程在线程函数中再调用service函数去收发信息,而service函数还需要一个sockaddr_in类型的参数去在函数内打印客户端的ip和port信息,提示这是哪个客户端在给服务端发送信息,以此
            //方便调试、观察,所以除了需要将主线程中的局部变量service_sock作为参数传给新线程的线程函数,还要将主线程中的表示对端(即客户端)网络属性信息的局部变量 —— sockaddr_in类型
            //的other_side也作为参数传给新线程的线程函数,所以此时就需要设计一个可以表示这两个数据的结构体(或者说类),我们叫它threadData类。

            //注意在主线程中创建threadData类的变量时,必须在堆上创建,因为如果在栈上创建,则该对象就是一个局部变量,每次出了循环进入下一次循环时都会将该变量销毁,会将变量里的成员都设置
            //成随机值,假如在新线程的线程函数中,在线程函数还没有使用该变量前,该变量就因主线程进入下一次循环而被销毁了,那么后序在新线程的线程函数中通过这个已经销毁的变量的地址去访问该
            //变量肯定是不合法的,会解引用野指针报错,这是典型的线程安全问题;正确的方式是在堆上创建该变量,这时该对象就不会因为出了循环而被销毁,每次销毁的只不过是指向该变量的指针变量罢了,
            //指针变量被销毁对新线程来说是没有影响的,因为只要线程函数启动成功了,参数肯定是早就传递完毕的,即该指针变量的值早就被线程函数的void*类型的形参给拷贝成功了,所以也就不需要该指
            //针变量了。说一下,每个线程都有自己的线程ID,线程库会自动将新线程的ID存储在pthread_t变量中以便主线程能够跟踪它们,pthread_t变量仅用于在主线程中识别和管理新创建的线程,并不影
            //响新线程的任意行为,即新线程中并不需要使用该pthread_t类型的变量,所以即使该变量的值中途可能会随时发生变化,该变量对于新线程来说也是线程安全的,对于主线程来说,当前主线程代码
            //的逻辑也不需要使用该pthread_t类型的变量,所以即使该变量的值中途可能会随时发生变化,该变量对于主线程来说也是线程安全的,综上可以发现对于所有线程来说该变量都是线程安全的,所以
            //在下面创建pthread_t类型的变量时就不必从堆上开辟空间了。
                        
            // //根据上一段的理论,错误写法如下
            // threadData data;
            // data.sockfd = service_sock;
            // data.x = other_side;
 
            //根据上一段的理论,正确写法如下
            threadData *p = new threadData;
            p->sockfd = service_sock;
            p->x = other_side;

            pthread_t tid;
            pthread_create(&tid, nullptr, threadRoutine, (void*)p);//说一下,和子进程不太一样,新线程的线程函数执行完后新线程就自动退出了,不会继续向后执行代码;而在子进程中如果不手动调用exit函数,则子进程不会自动退出

            //注意在多线程模式下和多进程模式下有一点不一样。在多进程中,我们需要在子进程中关闭监听套接字文件对应的文件描述符,需要在父进程中关闭服务套接字文件对应的描述符,否则会导致
            //文件描述符泄漏;但在多线程模式下,是一定不能这样做的,因为所有的线程只组成了一个进程,每个进程只有一个文件描述符表,所以所有的线程共同使用同一个文件描述符表,也就是说如果
            //在主线程中close关闭了服务套接字文件对应的文件描述符,那么在新线程中就没有媒介去和客户端进程通信了,同理如果在新线程中close关闭了监听套接字文件对应的文件描述符,那在主线
            //程中也就没有媒介去接收客户端的连接请求了。但注意是需要新线程在其线程函数中close关闭服务套接字文件对应的文件描述符的,否则会造成严重的文件描述符污染,导致accept再也无法创
            //建出服务套接字文件进而accept失败。
        }
    }


    ~TcpServer()
    {
        if (_listen_sock > 0)
            close(_listen_sock);
    }

private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
};

对通过【多线程版本的服务端类TcpServer】实现的网络服务器的测试

在测试通过【多线程版本的服务端类TcpServer】实现的网络服务器时,tcp_server.cc文件不需要经过任何修改,直接把上文中的拿来用即可,将tcp_server.cc文件编译成可执行文件tcp_server(即服务端程序)后,直接在一个ssh渠道中运行它,在运行时要传入命令行参数:

  • 如果没有特殊需求,不必传ip地址,把端口号8080设置成命令行参数传给服务端进程即可,因为我们的编码逻辑是在不显示传ip时,ip会被设置成缺省参数的值(即INADDR_ANY),之后服务端进程会bind绑定INADDR_ANY地址,这样一来服务端进程就能从任意一个网卡中读取数据、就能向任意一个网卡中发送数据,从而提高IO效率。

在测试通过【多线程版本的服务端类TcpServer】实现的网络服务器时,tcp_client.cc文件也不需要经过任何修改,直接把上文中的拿来用即可,将tcp_client.cc文件编译成可执行文件tcp_client后,因为测试的是多线程模式下的工作情况,所以分别在多个ssh渠道中各自运行tcp_client(即客户端程序)。我们先测试本地通信,在<<套接字socket编程的基础知识点>>一文中说过,某个进程bind绑定INADDR_ANY地址后,不光可以让其他主机上的进程访问该进程,也是可以让本地(即本机)上的其他进程访问该进程的,当前的服务端进程bind绑定的ip就是INADDR_ANY地址,所以本地上的客户端进程tcp_client是可以访问同属于本地的服务端进程tcp_server的。在运行tcp_client时要传入命令行参数:

  • 把本地环回地址(即ip地址127.0.0.1)和服务端进程的端口号8080设置成命令行参数传给tcp_client,让充当客户端的tcp_client进程知道服务端进程在哪。

这样一来,客户端进程tcp_client和服务端进程tcp_server收发数据时就只在本地协议栈中进行数据流动,不会把我们的数据发送到网络中。

测试结果如下,可以发现在下图1中,不再发生和上文中在测试【通过单进程版本的服务端类TcpServer实现的网络服务器】时一样的情况了,而是即使同时有多个客户端进程来连接服务端进程,也可以成功连接,图1中有两条【获取连接成功】的语句就是证明。

并且在下图2中可以发现,不仅连接建立成功了,多个客户端也是可以同时给服务端发信息的,服务端也收到信息并将信息原模原样地返回给了对应的客户端;图2中的红框处有3个tcp_server线程(在下文中会证明是线程)也能很好的证明了多个客户端与服务端的连接建立成功了,因为3个tcp_server线程中,只有一个是主线程,其他两个都是新线程,这两个新线程是主线程创建出来替主线程和客户端进程通信的,而刚好咱们也只创建了两个ssh渠道,也就是只创建了两个tcp_client进程去充当客户端,数量上也都对的上。

如何证明图2中的红框处的3个tcp_server是线程而不是进程呢?答案:首先图2中的指令为ps - aL,注意-L选项就是用于查看线程的情况的,所以可以通过这个证明是线程;除此之外,观察3个tcp_server的PID(即进程ID)也都是相同的,但LWP(即线程ID)是不同的,所以还可以通过这个证明是线程。

线程池版本的服务端类TcpServer的模拟实现

在标题为前言的部分中就说过,本文中分类分成单进程版本的服务端类TcpServer、多进程版本的服务端类TcpServer、多线程版本的服务端类TcpServer、线程池版本的服务端类TcpServer只是因为这四个版本的TcpServer类中有一个成员函数start的实现不太一样,至于其他的成员变量或者成员函数在四个版本中都是完全一致的,所以只要将start函数修改成多进程版本,那多进程版本的服务端类TcpServer的模拟实现也就完毕了,整体代码也就出来了。

在讲解如何实现start函数前,首先要说一些前置知识点。

上文中模拟实现的多线程版本的服务端类TcpServer存在的问题:

  • 每当服务端接收客户端的连接请求时,服务端的主线程都会创建一个新线程,让新线程在线程函数中通过再调用service函数和客户端进行通信,当通信结束后又会将该新线程销毁。这样做不仅麻烦,而且效率低下,因为每当连接到来的时候服务端才创建对应提供服务的线程,这就太慢了。在服务端只收到少量的客户端发来的连接请求时,可能体现不出来这个“慢”,但当服务端收到大量的客户端发来的连接请求时,需要创建大量的线程时,这个“慢”就体现出来了。

针对这个问题,我们可以引入线程池的方案来解决,如果对线程池感到陌生了,请回顾<<线程池的介绍以及【基于线程池的生产者消费者模型的模拟实现】>>一文。通过引入线程池解决多线程版本的服务端类TcpServer存在的问题的大概版思路(下文中还有精细版)为:

  • 在该篇文章中说过线程池本质就是生产者消费者模型,所以线程池类中肯定是有一个该模型标配的交易场所的,即有一个阻塞队列queue<T>成员,未来我们期望把服务端提供给客户端的通信服务当成一个任务,主线程(即生产者)把这个通信任务封装成一个对象(这是生产的过程)后往阻塞队列中放(这是访问临界区的过程),然后线程池中的线程(即消费者)从阻塞队列中获取任务对象(这是访问临界区的过程)后处理任务(这是消费的过程)

因为在<<线程池的介绍以及【基于线程池的生产者消费者模型的模拟实现】>>一文中已经对线程池类ThreadPool和任务对象类Task的代码的实现进行过讲解,所以本章就不再赘述,说一下,下面Task.h的代码和该篇文章中的代码并不一致,是做了大幅调整的,并且ThreadPool.h的代码也做了稍微的修改,这里先直接给出经过修改后的整体代码,关于这两份代码做了哪些修改,在下文中都会进行讲解。

以下是整个Task.h的代码。

#pragma once
#include<iostream>
using namespace std;
#include<functional>
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体
#include <unistd.h>//提供close函数

typedef void (*func_t)(int, sockaddr_in);

class Task
{
public:
    Task()
    {}
 
    ~Task()
    {}
   
    Task(int sock, sockaddr_in x, func_t f)
        :_sock(sock)
        ,_x(x)
        ,_f(f)
    {}
 
    void operator()()
    {
        _f(_sock, _x);
    }
    
    int _sock;
    sockaddr_in _x;
    func_t _f;
};

以下是整个ThreadPool.h的代码。

#pragma once
#include<iostream>
using namespace std;
#include<pthread.h>
#include<unistd.h>
#include<string>
#include<vector>
#include<queue>

 
template<class T>
class ThreadPool;//前置声明
 
//线程池中所有的线程(即消费者线程)的例行程序
template<class K>
void* routine(void* args)
{
    ThreadPool<K>* tp = (ThreadPool<K>*)args;
    cout<<"我是消费者线程,线程ID为:"<<pthread_self()<<",启动成功!"<<endl;
    K task;
 
    while(1)
    {
        //从任务队列中获取任务
        //消费者线程进入交易场所接取任务时先加锁
        pthread_mutex_lock(&(tp->_lock));
        //如果任务队列中为空,即没有任务时,那就让消费者线程在条件变量下陷入阻塞,等待资源就绪
        while(tp->_task_queue.empty() == true)
            pthread_cond_wait(&(tp->_cond), &tp->_lock);
        task = tp->_task_queue.front();
        tp->_task_queue.pop();
        //消费者线程接取完任务后再解锁并处理刚接取的任务
        pthread_mutex_unlock(&(tp->_lock));
        task();
        close(task._sock);//注意某个新线程为客户端提供完服务后一定要在新线程中close关闭服务套接字文件对应的文件描述符,因为该新线程是不退出的,那么就没法在新线程退出、执行流返回到主线程时,让主线程帮新线程close关闭服务套接字文件对应的文件描述符,所以如果不在某个新线程为客户端提供完服务后close关闭服务套接字文件对应的文件描述符,则会造成严重的文件描述符泄漏,后序客户端也就再也无法与服务端建立连接了。

        //注意和正常的生产者消费者模型不太一样的是:这里消费者处理完任务后不用pthread_cond_signal唤醒生产者,线程池的宗旨是有任务就处理,没任务就摸鱼,并不需要催促生产者
    }
}
 
template<class T>
class ThreadPool//用于处理T类型数据的线程池
{
    template<class K> friend void* routine(void* args);
public:
    ThreadPool(int num)//num表示线程池中需要多少线程
        :_num(num)
    {
        //是需要在构造函数中初始化锁的
        pthread_mutex_init(&_lock,nullptr);
        //是需要在构造函数中初始化条件变量的
        pthread_cond_init(&_cond,nullptr);
        for(int i=0;i<num;i++)
        {
            _v.push_back(new pthread_t);
        }
 
        for(pthread_t* x:_v)
        {
            //传this指针是因为在类外部的例行函数中需要访问类内的成员。
            pthread_create(x, nullptr, routine<T>, (void*)this);
        }
    }
 
    //该函数只被作为生产者的主线程调用
    void pushTask(const T& task)
    {
        pthread_mutex_lock(&_lock);
        _task_queue.push(task);
        pthread_mutex_unlock(&_lock);
        //生产者(即主线程)往交易场所中放置完任务后,需要唤醒某个消费者线程去接取任务
        pthread_cond_signal(&_cond);
    }
 
    ~ThreadPool()
    {
        for(vector<pthread_t*>::iterator it = _v.begin(); it != _v.end(); it++)
        {          
           //(*it)是vector中存的元素、注意每个元素只是线程ID的地址,而不是线程ID
            pthread_join(*(*it), nullptr);
            //join函数会释放绝大多数和线程相关的资源,但因为这里的线程ID对象是在构造函数中new出来的在堆上的变量,所以需要手动delete释放。
            delete (*it);
        }
        //是需要在析构函数中销毁锁的
        pthread_mutex_destroy(&_lock);
        //是需要在析构函数中销毁条件变量的
        pthread_cond_destroy(&_cond);
    }
 
private:
    vector<pthread_t*> _v;//线程池
    int _num;//统计_v中有多少个线程
    queue<T> _task_queue;//消费者(即线程池中的所有线程,注意线程池中的线程都是非主线程)都需要通过该任务队列获取任务;生产者(即主线程)需要往该任务队列中放置任务。所以本质上这个队列就是生产者消费者模型中的临界资源,形象点说叫交易场所
    pthread_mutex_t _lock;//分配给任务队列(即临界资源或者说交易场所)的锁,需要该锁的原因是需要维护【生产者和消费者的互斥关系】,避免发生【生产者放置任务的动作还只做到一半,消费者就跑进交易场所中接取任务】和【消费者接取任务的动作还只做到一半,生产者就跑进交易场所中放置任务】
    pthread_cond_t _cond;//分配给消费者(线程池中的所有线程都是消费者)的条件变量,当生产者(即主线程)不断往任务队列中push时,每push一个任务都应该试图唤醒一个线程,避免任务没有消费者接取从而全在任务队列中堆积。注意即使所有消费者线程都在工作,那我唤醒它也没关系,因为此时它会忽略这个唤醒信息,所以这里无脑唤醒即可。
};
 
 
 

(紧接上文)通过引入线程池解决多线程版本的服务端类TcpServer存在的问题的精细版思路如下:

  • 可以让服务端预先创建一批线程(从代码层面上说就是在服务端类TcpSercer的构造函数中new一个线程池对象,然后在服务端类TcpSercer中增加一个智能指针成员变量unique_ptr<ThreadPool<Task>> _threadPool_ptr,用于指向在构造函数中new创建出的线程池对象),当有客户端请求连接时就让这些线程为客户端提供服务,此时客户端一来就有线程为其提供服务,而不是当客户端来了才创建对应的服务线程(从代码层面上说就是服务端进程的主线程如果通过accept函数接收客户端的连接请求成功,则主线程就把【通过accept函数的输出型参数拿到的对端(即客户端)的网络属性信息】和【为新线程创建出的服务套接字文件对应的文件描述符service_sock】和【用于让新线程和对端(即客户端)通信的service函数】这3个数据打包封装成一个任务对象,然后主线程将该任务对象放进线程池对象的阻塞队列成员中并唤醒新线程去阻塞队列中获取任务对象,这样早早在服务端类TcpServer的构造函数调用完毕开始就已经存在了的沉睡了许久的新线程获取到任务对象后就终于有活干了,可以处理任务了)
  • 当某个新线程为客户端提供完服务后,不要让该线程退出,而是让该线程继续为下一个客户端提供服务,如果当前没有客户端连接请求,则可以让该线程先进入休眠状态,当有客户端连接到来时再将该线程唤醒(从代码层面上说就是在新线程的线程函数中继续循环,即继续执行竞争锁、等待条件变量、接取任务、处理任务的逻辑;如果没有客户端连接请求,那在上一段中提过的阻塞队列中就没有任务对象存在,那么新线程就会发现访问阻塞队列的条件不满足,然后新线程就会因为执行pthread_cond_wait函数而陷入休眠状态,直到主线程在建立连接请求成功、封装任务对象完成并放置进阻塞队列、调用pthread_cond_signal唤醒新线程时,新线程才会被唤醒)注意某个新线程为客户端提供完服务后一定要在新线程中close关闭服务套接字文件对应的文件描述符,因为该新线程是不退出的,那么就没法在新线程退出、执行流返回到主线程时,让主线程帮新线程close关闭服务套接字文件对应的文件描述符,所以如果不在某个新线程为客户端提供完服务后close关闭服务套接字文件对应的文件描述符,则会造成严重的文件描述符泄漏,后序客户端也就再也无法与服务端建立连接了。(从代码层面上说就是在新线程的线程函数中,在进入下一次while循环前去close服务套接字对应的文件描述符)
  • 服务端创建的这一批线程的数量不能太多,此时CPU的压力也就不会太大。此外,如果有客户端连接到来,但此时这一批线程都在给其他客户端提供服务,这时服务端不应该再创建线程,而应该让这个新来的连接请求在全连接队列进行排队,等服务端这一批线程中有空闲线程后,再将该连接请求获取上来并为其提供服务。(从代码层面上很好控制这些点,比如因为我们是在服务端类TcpServer类的构造函数中创建线程池对象,整体代码中只会在该构造函数中创建线程,而同一个服务端对象后序不会再次调用构造函数,所以不可能再新创建线程;而排队功能也很好实现,通过listen函数的第二个参数backlog即可做到)

根据上面的大概版思路和精细版思路,我们可知在tcp_server.h的代码中,首先需要包含咱们自己实现的“ThreadPool.h”和“Task.h”文件,然后将服务端类TcpSercer的构造函数进行修改,即需要在服务端类TcpSercer的构造函数中new一个线程池对象,然后在服务端类TcpSercer中增加一个智能指针成员变量unique_ptr<ThreadPool<Task>> _threadPool_ptr,用于指向在构造函数中new创建出的线程池对象,如下。

#include<iostream>
using namespace std;
#include<string.h>//提供bzero函数
#include<memory>//提供智能指针的库
#include<cstdlib>//提供atoi函数、exit函数
#include <unistd.h>//提供close
#include<signal.h>//提高signal函数
#include <sys/wait.h>//提供wait函数
#include<pthread.h>//线程库
#include"threadPool.h"//咱们自己模拟实现的线程池类
#include"task.h"//咱们自己模拟实现的任务类
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体

class TcpServer
{
public:
    TcpServer(uint16_t port, string ip = "")
        :_port(port)
        ,_ip(ip)
        ,_threadPool_ptr(new ThreadPool<Task>(5))
    {}  

private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
    unique_ptr<ThreadPool<Task>> _threadPool_ptr;
};

根据上面的大概版思路和精细版思路,我们还可知需要对<<线程池的介绍以及【基于线程池的生产者消费者模型的模拟实现】>>一文中的ThreadPool.h的所有线程共同的线程函数routine的代码稍作修改,只需要在routine函数最后加上close(_sock)即可(原因在注释中也再次进行了说明),修改后如下所示。

//线程池中所有的线程(即消费者线程)的例行程序
template<class K>
void* routine(void* args)
{
    ThreadPool<K>* tp = (ThreadPool<K>*)args;
    cout<<"我是消费者线程,线程ID为:"<<pthread_self()<<",启动成功!"<<endl;
    K task;
 
    while(1)
    {
        //从任务队列中获取任务
        //消费者线程进入交易场所接取任务时先加锁
        pthread_mutex_lock(&(tp->_lock));
        //如果任务队列中为空,即没有任务时,那就让消费者线程在条件变量下陷入阻塞,等待资源就绪
        while(tp->_task_queue.empty() == true)
            pthread_cond_wait(&(tp->_cond), &tp->_lock);
        task = tp->_task_queue.front();
        tp->_task_queue.pop();
        //消费者线程接取完任务后再解锁并处理刚接取的任务
        pthread_mutex_unlock(&(tp->_lock));
        task();
        close(task._sock);//注意某个新线程为客户端提供完服务后一定要在新线程中close关闭服务套接字文件对应的文件描述符,因为该新线程是不退出的,那么就没法在新线程退出、执行流返回到主线程时,让主线程帮新线程close关闭服务套接字文件对应的文件描述符,所以如果不在某个新线程为客户端提供完服务后close关闭服务套接字文件对应的文件描述符,则会造成严重的文件描述符泄漏,后序客户端也就再也无法与服务端建立连接了。

        //注意和正常的生产者消费者模型不太一样的是:这里消费者处理完任务后不用pthread_cond_signal唤醒生产者,线程池的宗旨是有任务就处理,没任务就摸鱼,并不需要催促生产者
    }
}

根据上面的大概版思路和精细版思路,因为思路中是让主线程把【通过accept函数的输出型参数拿到的对端(即客户端)的网络属性信息】和【为新线程创建出的服务套接字文件对应的文件描述符service_sock】和【用于让新线程和对端(即客户端)通信的service函数】这3个数据打包封装成一个任务对象,所以和<<线程池的介绍以及【基于线程池的生产者消费者模型的模拟实现】>>一文中的Task类相比,这里的Task类显然需要进行修改,修改的思路是:

  • 因为Task任务对象是让新线程去阻塞队列中获取以此让新线程和客户端通信的,而在本文中所有版本的网络服务器中用于让新线程和客户端通信的函数都是service函数,所以Task类需要一个可以接收service函数的成员变量,所以Task类需要func_t类型的成员,func_t是别名,typedef void (*func_t)(int, sockaddr_in);
  • 因为新线程调用service函数时需要知道对端(即客户端)的网络信息,否则新线程不知道该和谁通信了,所以Task任务类中需要包含客户端的网络信息,所以Task类需要sockaddr_in类型的成员。
  • 同理,因为新线程调用service函数时需要知道该用哪个服务套接字文件去和对端(客户端)通信,所以Task类需要一个可以接收文件描述符的int类型的成员。
  • 从这里可以看出,Task类需要哪些类型的成员,完全只看Task类中的可调用对象成员(目前是函数指针)在被调用时需要哪些类型的参数。

综上经过修改后的Task.h的代码如下。

#pragma once
#include<iostream>
using namespace std;
#include<functional>
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体
#include <unistd.h>//提供close函数

typedef void (*func_t)(int, sockaddr_in);

class Task
{
public:
    Task()
    {}
 
    ~Task()
    {}
   
    Task(int sock, sockaddr_in x, func_t f)
        :_sock(sock)
        ,_x(x)
        ,_f(f)
    {}
 
    void operator()()
    {
        _f(_sock, _x);
    }
    
    int _sock;
    sockaddr_in _x;
    func_t _f;
};

根据上面的大概版思路和精细版思路,让主线程把【通过accept函数的输出型参数拿到的对端(即客户端)的网络属性信息】和【为新线程创建出的服务套接字文件对应的文件描述符service_sock】和【用于让新线程和对端(即客户端)通信的service函数】这3个数据打包封装成一个任务对象后,需要主线程将该任务对象放进线程池对象的阻塞队列成员中并唤醒新线程去阻塞队列中获取任务对象,这样早早在服务端类TcpServer的构造函数调用完毕开始就已经存在了的沉睡了许久的新线程才能获取到任务对象,才能在之后有活干,所以在tcp_server.h的代码中,需要将start成员函数修改成下面这样。

    void start()
    {
        while(1)
        {
            //监听成功后,这里需要从监听套接字中获取连接
            sockaddr_in other_side;
            socklen_t len = sizeof other_side;
            int service_sock = accept(_listen_sock, (sockaddr*)&other_side, &len);
            if (service_sock < 0)
            {
                cout<<"获取监听套接字中的连接失败,即将重新获取"<<endl;
                continue;
            }
            //获取连接成功,开始通信
            //version 4 -- 线程池版本
            cout<<"获取连接成功"<<endl;
            //在TcpServer的构造函数调用完毕时,线程池对象就已经存在并且其中所有线程的线程函数已经启动了,只不过因为线程池对象中的任务队列成员中没有任务,为空,导致了所有线程
            //都在分配给任务队列的条件变量下阻塞等待,所以接下来我们需要让主线程去创建任务对象并将任务放置进线程池的任务队列成员中,然后再唤醒线程池中的线程。
            Task t(service_sock, other_side, service);
            _threadPool_ptr->pushTask(t);
        }
    }

服务端类TcpServer的start函数(线程池版本)

经过以上的种种努力,最后终于得到了start函数的代码。

#include<iostream>
using namespace std;
#include<string.h>//提供bzero函数
#include<memory>//提供智能指针的库
#include<cstdlib>//提供atoi函数、exit函数
#include <unistd.h>//提供close
#include<signal.h>//提高signal函数
#include <sys/wait.h>//提供wait函数
#include<pthread.h>//线程库
#include"threadPool.h"//咱们自己模拟实现的线程池类
#include"task.h"//咱们自己模拟实现的任务类
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体


class TcpServer
{
public:
    TcpServer(uint16_t port, string ip = "")
        :_port(port)
        ,_ip(ip)
        ,_threadPool_ptr(new ThreadPool<Task>(5))
    {}

    void start()
    {
        while(1)
        {
            //监听成功后,这里需要从监听套接字中获取连接
            sockaddr_in other_side;
            socklen_t len = sizeof other_side;
            int service_sock = accept(_listen_sock, (sockaddr*)&other_side, &len);
            if (service_sock < 0)
            {
                cout<<"获取监听套接字中的连接失败,即将重新获取"<<endl;
                continue;
            }
            //获取连接成功,开始通信
            //version 4 -- 线程池版本
            cout<<"获取连接成功"<<endl;
            //在TcpServer的构造函数调用完毕时,线程池对象就已经存在并且其中所有线程的线程函数已经启动了,只不过因为线程池对象中的任务队列成员中没有任务,为空,导致了所有线程
            //都在分配给任务队列的条件变量下阻塞等待,所以接下来我们需要让主线程去创建任务对象并将任务放置进线程池的任务队列成员中,然后再唤醒线程池中的线程。
            Task t(service_sock, other_side, service);
            _threadPool_ptr->pushTask(t);
        }
    }


private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
    unique_ptr<ThreadPool<Task>> _threadPool_ptr;
};

线程池版本的服务端类TcpServer的整体代码(因为start成员函数是线程池版本,所以TcpServer类就是线程池版本)

在标题为前言的部分中就说过,本文中分类分成单进程版本的服务端类TcpServer、多进程版本的服务端类TcpServer、多线程版本的服务端类TcpServer、线程池版本的服务端类TcpServer只是因为这四个版本的TcpServer类中有一个成员函数start的实现不太一样,至于其他的成员变量或者成员函数在四个版本中都是完全一致的,所以现在既然start函数修改成线程池版本的了,线程池版本的服务端类TcpServer的整体代码也就出来了,如下。(以下是tcp_server.h文件的整体代码,注意tcp_server.h文件中是包含了咱们编写的ThreadPool.h文件和task.h文件的)

#include<iostream>
using namespace std;
#include<string.h>//提供bzero函数
#include<memory>//提供智能指针的库
#include<cstdlib>//提供atoi函数、exit函数
#include <unistd.h>//提供close
#include<signal.h>//提高signal函数
#include <sys/wait.h>//提供wait函数
#include<pthread.h>//线程库
#include"threadPool.h"//咱们自己模拟实现的线程池类
#include"task.h"//咱们自己模拟实现的任务类
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体

//当前service的逻辑就是接收客户端发来的信息并原模原样返回给客户端
void service(int service_sock, sockaddr_in other_side)
{
    char* ip = inet_ntoa(other_side.sin_addr);
    uint16_t port = ntohs(other_side.sin_port);
    char buffer[64];
    while(1)
    {
        ssize_t size = read(service_sock, buffer, sizeof(buffer)-1);
        if(size > 0)
        {
            buffer[size] = '\0';//因为在设计客户端与服务端通信时,设计的逻辑是双方都只发送C语言字符串,所以通信通道中的信息就全是C语言字符串,所以这里需要在读到的数据的末尾设置0,防止打印时出现乱码
            printf("ip为%s、port为%d的客户端发给我(即服务端进程)的信息为:%s\n", ip, port, buffer);
        }
        else if(size == 0)//如果返回值为0,则表示给我write发送信息的对端进程中的套接字文件对应的文件描述符被close关闭了,既然没人给我发数据了,那这边我们也就不必再读了,直接break
        {
            cout<<"客户端的套接字被关闭,客户端不会再给我(服务端)发送信息了,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        else
        {
            cout<<"服务端读取数据异常,发生了未知错误,即将结束服务端对【套接字被关闭的客户端】的服务"<<endl;
            break;
        }
        //走到这里一定是read接客户端发来的信息成功了,将数据原模原样返回给客户端即可
        write(service_sock, buffer, sizeof(buffer)-1);
    }
}


class TcpServer
{
public:
    TcpServer(uint16_t port, string ip = "")
        :_port(port)
        ,_ip(ip)
        ,_threadPool_ptr(new ThreadPool<Task>(5))
    {}

    void InitServer()
    {
        _listen_sock = socket(AF_INET, SOCK_STREAM, 0);

        //bind,作用为将【ip和port和socket文件】和当前进程绑定,为什么要bind绑定呢?套接字sock文件用于通信,首先,如果想要网络通信,则必须通过网卡,所
        //以你必须得指定从哪个网卡(ip)读取数据送到socket文件,这就是bind ip的原因,送到哪个socket文件呢?这是bind sockfd的原因,数据读取完毕后,送到
        //哪个端口(进程)呢?所以你必须指定一个端口号。
       
        //在TCP通信中,通信双方会在3次握手建立连接时交换各自的ip和port信息,比如客户端在调用connect函数发起SYN连接请求的时候,OS会自动把客户端进程绑定的
        //ip和port信息包含进连接请求中发给服务端进程,服务端进程调用accept函数接收连接队列中的连接对象时如果成功,则通过传给accept函数的输出型参数就能知
        //道客户端的ip和port了。
 
        //根据上一段的理论,因为通信双方会在3次握手建立连接时交换各自的ip和port信息,即服务端的OS会在3次握手时自动把服务端进程bind绑定的ip和port信息发送
        //给客户端进程,注意这个包含ip和port的信息是先经过网络,再被客户端进程接收的,而网络资源又是寸土寸金的,发送的数据越小越好,所以在双方进行3次握手
        //建立连接前(3次握手发生在调用listen函数后、调用accept函数前这个时间段内),是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机采用的字
        //节序列不一致,所以在双方进行3次握手建立连接前(3次握手发生在调用listen函数后、调用accept函数前这个时间段内),还需要将转化成整形的ip从主机序列转
        //化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)。注意因为服务端和客户端3次握手交换ip和port信息时、服务端把自己的
        //ip和port的信息发送给客户端时,是OS自动把服务端进程bind绑定的ip和port包含进发送的交换信息中,所以如果想要服务端在进行3次握手时发送出去的ip和port
        //是网络序列,那在服务端进程bind绑定ip和port时,ip和port就应该是网络序列,所以在服务端bind绑定ip和port前,需要将这些信息转化成网络序列。
 
        //根据上上段的理论,客户端调用connect函数发起连接请求时会把请求信息先发送到网络中,然后另一端的服务端进程会从网络中获取到这个请求,注意因为客户端调
        //用connect发起连接请求时,OS会自动把客户端进程绑定的ip和port信息包含进发起的请求中,而网络资源又是寸土寸金的,发送的数据越小越好,所以在客户端调用
        //connect发送连接请求前,是需要将ip从字符ip转换成整形ip的,同时为了防止通信的双方主机采用的字节序列不一致,所以在客户端调用connect前还需要将转化成
        //整形的ip从主机序列转化成网络序列、将端口号从主机序列(可能是大端、可能是小端)转化成网络序列(大端)的。注意因为客户端调用connect函数发送连接请求信
        //息到网络时,是OS自动把客户端进程bind绑定的ip和port包含进发送的连接请求中,所以如果想要客户端调用connect发送连接请求时ip和port是网络序列,那在客户
        //端进程bind绑定ip和port时,ip和port就应该是网络序列,这个对于客户端进程来说不必担心,对于客户端进程来说一般是不必显示调用bind函数绑定ip和port的,
        //如果不显示调用bind函数,则客户端调用connect函数向服务端发起连接请求时,OS会自动把当前机器的ip和随机分配的一个port给客户端进程进行bind,并且ip和
        //port在绑定前也由OS自动转化成网络序列了。(关于为什么不必显示调用bind的原因在和UDP网络服务器的模拟实现相关的文章中说明过了)
       
        //说一下,bind绑定这些信息(即ip和port)时,是先把需要绑定的信息全填充到一个sockaddr类的对象中,之后bind只需要绑定这个sockaddr类的对象即可,由于该
        //结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。
        sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = (_ip.empty()==true?INADDR_ANY:inet_addr(_ip.c_str()));
        local.sin_port = htons(_port);
        if (bind(_listen_sock, (sockaddr*)&local, sizeof local) < 0)
        {
            exit(1);
        }
        //TCP是面向连接的,正式通信前需要先建立连接
        if(listen(_listen_sock, 20) < 0)
        { 
            exit(1);
        }
        cout<<"服务端初始化成功"<<endl;
    }

    void start()
    {
        while(1)
        {
            //监听成功后,这里需要从监听套接字中获取连接
            sockaddr_in other_side;
            socklen_t len = sizeof other_side;
            int service_sock = accept(_listen_sock, (sockaddr*)&other_side, &len);
            if (service_sock < 0)
            {
                cout<<"获取监听套接字中的连接失败,即将重新获取"<<endl;
                continue;
            }
            //获取连接成功,开始通信
            //version 4 -- 线程池版本
            cout<<"获取连接成功"<<endl;
            //在TcpServer的构造函数调用完毕时,线程池对象就已经存在并且其中所有线程的线程函数已经启动了,只不过因为线程池对象中的任务队列成员中没有任务,为空,导致了所有线程
            //都在分配给任务队列的条件变量下阻塞等待,所以接下来我们需要让主线程去创建任务对象并将任务放置进线程池的任务队列成员中,然后再唤醒线程池中的线程。
            Task t(service_sock, other_side, service);
            _threadPool_ptr->pushTask(t);
        }
    }


    ~TcpServer()
    {
        if (_listen_sock > 0)
            close(_listen_sock);
    }

private:
    //一个服务器是需要ip和端口号的,不然其他机器找不到该服务器
    string _ip;
    uint16_t _port;//port表示端口,uint16_t是C语言中stdint.h头文件中定义的一种数据类型,它占据16个二进制位,范围从0到65535。它是无符号整数类型,即只能表示非负整数,没有符号位。
    int _listen_sock;//_listen_sock是调用socket函数创建套接字时返回的值,本质就是文件描述符fd
    unique_ptr<ThreadPool<Task>> _threadPool_ptr;
};

如何更换线程池中新线程的服务呢?

观察上面的整体代码可以发现,线程池中的新线程提供的服务就是把客户端发给服务端的信息原模原样的返回过去,那如果我想让新线程提供的服务(或者业务)发生变化,代码该如何修改呢?

很简单,所有的逻辑都不用修改,只需要在主线程中创建Task任务对象时,把你想要新线程提供的服务封装成一个函数,然后把该函数作为参数传给Task任务对象即可,后序主线程把该任务对象放进线程池对象的阻塞队列成员中后,会有新线程在新线程的线程函数routine中通过Task t = queue.pop()来获取该任务,然后通过 t(),即t.operator()为客户端提供服务(咱们设计的函数的业务是什么,该服务的业务或者说内容就是什么)。

对通过【线程池版本的服务端类TcpServer】实现的网络服务器的测试

在测试通过【线程池版本的服务端类TcpServer】实现的网络服务器时,tcp_server.cc文件不需要经过任何修改,直接把上文中的拿来用即可,将tcp_server.cc文件编译成可执行文件tcp_server(即服务端程序)后,直接在一个ssh渠道中运行它,在运行时要传入命令行参数:

  • 如果没有特殊需求,不必传ip地址,把端口号8080设置成命令行参数传给服务端进程即可,因为我们的编码逻辑是在不显示传ip时,ip会被设置成缺省参数的值(即INADDR_ANY),之后服务端进程会bind绑定INADDR_ANY地址,这样一来服务端进程就能从任意一个网卡中读取数据、就能向任意一个网卡中发送数据,从而提高IO效率。

在测试通过【线程池版本的服务端类TcpServer】实现的网络服务器时,tcp_client.cc文件也不需要经过任何修改,直接把上文中的拿来用即可,将tcp_client.cc文件编译成可执行文件tcp_client后,因为测试的是线程池模式下(本质也是多个线程)的工作情况,所以分别在多个ssh渠道中各自运行tcp_client(即客户端程序)。我们先测试本地通信,在<<套接字socket编程的基础知识点>>一文中说过,某个进程bind绑定INADDR_ANY地址后,不光可以让其他主机上的进程访问该进程,也是可以让本地(即本机)上的其他进程访问该进程的,当前的服务端进程bind绑定的ip就是INADDR_ANY地址,所以本地上的客户端进程tcp_client是可以访问同属于本地的服务端进程tcp_server的。在运行tcp_client时要传入命令行参数:

  • 把本地环回地址(即ip地址127.0.0.1)和服务端进程的端口号8080设置成命令行参数传给tcp_client,让充当客户端的tcp_client进程知道服务端进程在哪。

这样一来,客户端进程tcp_client和服务端进程tcp_server收发数据时就只在本地协议栈中进行数据流动,不会把我们的数据发送到网络中。

测试结果如下,可以发现在下图1中,不再发生和上文中在测试【通过单进程版本的服务端类TcpServer实现的网络服务器】时一样的情况了,而是即使同时有多个客户端进程来连接服务端进程,也可以成功连接,图1中有两条【获取连接成功】的语句就是证明。并且可以发现,在客户端进程连接服务端进程前,服务端进程在启动时就已经创建了5个线程,图1中线程函数启动时的打印语句在语句【获取连接成功】之前就是证明。

并且在下图2中可以发现,不仅连接建立成功了,多个客户端也是可以同时给服务端发信息的,服务端也收到信息并将信息原模原样地返回给了对应的客户端;图2中的红框处有6个tcp_server线程(在下文中会证明是线程)也能很好的证明了多个客户端与服务端的连接建立成功了,因为6个tcp_server线程中,只有1个是主线程,其他5个都是新线程,这5个新线程是主线程创建出来替主线程和客户端进程通信的。

如何证明图2中的红框处的6个tcp_server是线程而不是进程呢?答案:首先图2中的指令为ps - aL,注意-L选项就是用于查看线程的情况的,所以可以通过这个证明是线程;除此之外,观察6个tcp_server的PID(即进程ID)也都是相同的,但LWP(即线程ID)是不同的,所以还可以通过这个证明是线程。

通过【线程池版本的服务端类TcpServer】实现的网络服务器的优缺点

线程池版本的服务端类TcpServer的特点:通过线程池版本的服务端类TcpServer的代码可以看出,如果服务端的线程池中所有的线程都在和客户端通信,那么后序如果再有客户端进程向服务端发起连接请求,虽然此时服务端进程的主线程能accept接收客户端发起的连接请求,双方能建立连接成功、主线程能完成任务对象的创建并将该任务对象放置进线程池对象的阻塞队列成员中,但因为服务端进程的线程池中的所有新线程都正在和客户端通信,所以没有一个新线程能够去阻塞队列中获取这个后面才来的任务对象,更别谈处理这个后面才来的任务了,所以服务端也就没法和这个后面才来的客户端通信了。

而通过多进程版本或者多线程版本的服务端类TcpServer的代码都可以看出,这两个版本的服务端类TcpServer是不会发生上一段中所发生的情况的,而是在服务端进程所在的服务器(即一台主机)的配置够高的情况下,有多少个客户端向服务端发起连接,服务端进程就会创建出多少个子进程(或者是新线程)去和这若干个客户端进程通信。

问题:那线程池版本的服务端类TcpServer的这个特点到底是优点还是缺点呢?

答案:既是缺点又是优点。

  • 从灵活性方面来说是缺点,因为线程池中的新线程的数量是固定的,能提供服务的新线程只有这么多,所以自然是不如多进程版本或者多线程版本的服务端类TcpServer灵活;
  • 但从安全角度上来说是优点,将服务端设计成线程池模式本就有保护服务端进程的目的在里面,因为我无论你客户端有多少,我服务端的新线程就这么多,当我服务端的新线程都已经在工作的时候,再有客户端来连服务端,服务端也不会再创建新的线程去为该客户端提供服务,而是将和该客户端通信的任务对象放置在阻塞队列中,等到服务端中的某个新线程忙完之前的通信任务了,再来和该客户端通信,这样做能防止有人恶意向服务端发起海量连接时,服务端因为在一瞬间创建海量的进程或者线程而导致服务端进程因为占用大量的内存而导致OS将它杀死,甚至能防止可能因为内存被服务端进程大量的占用,OS本身所需的内存不够,导致OS受到影响。
  • 还有一点要说的是:是有办法缓解线程池模式下的服务端的灵活性不够带来的负面影响的,在我们自己编写的线程池版本的服务端类TcpServer中,代码的逻辑是双方在通信时,就算客户端迟迟不给服务端中用于提供服务的新线程发信息,那通信也不会结束,这样不就是占着茅坑不(你懂得)吗,显然这是不合理的,所以实际中大多数服务端都会有相关应对这样情况的方案,比如如果某段时间内客户端没有发送任何信息给服务端时,服务端就主动断开和客户端的连接,让其他客户端也能来连接我服务端。

说一下,像本文中所说的这样的服务端与客户端的连接,在通信中双方即使并没有发送信息,但通信的连接也不会断开,这样的连接被称为长链接;与之对应的就是短链接了,即通信双方每完成一次收发信息就都close关掉各自的套接字文件以此断开连接,如果双方还想继续通信,就得重新建立连接,即客户端重新通过socket函数打开套接字文件、重新通过connect函数向服务端发起连接请求;服务端重新通过accept函数接收这个连接请求。

  • 有人可能会说【对于客户端来说,想要通过connect函数重新和服务端建立连接直接通过之前的套接字文件不就行了,为什么要先close关掉之前的套接字,然后又重新调用socket创建新的套接字文件呢?这不是多次一举吗?】我想说的是,的确,通常情况下客户端可以直接使用已存在的套接字进行新的连接请求,而不必关闭并重新创建套接字。但有一种情况下,比如如果服务端还没来得及close断开连接,如果此时客户端也不主动close,那双方的连接压根没有断开,客户端调用connect函数重新建立连接就会失败,所以为了保证不出现这样的情况,为了保证连接能建立成功,所以客户端需要主动close。
  • 对于服务端需要close关掉自己的套接字文件的的原因就更明显了,客户端重新向服务端发起连接请求时,服务端进程会通过accept函数重新获得一个和该客户端通信的套接字文件以及文件对应的文件描述符,之前旧的文件描述符已经不具备和该客户端通信的能力了,没有用不说,如果每次双方在重连时,服务端都不close关掉旧的文件描述符,很快服务端进程的文件描述符表就会被占满(即发生了严重的文件描述符泄漏),也就再也不能成功的accept接收其他客户端的连接请求了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值