「网络编程」第二讲:网络编程socket套接字(三)_ 简单TCP网络通信程序的实现

「前言」文章是关于网络编程的socket套接字方面的,上一篇是网络编程socket套接字(二),下面开始讲解! 

「归属专栏」网络编程

「笔者」枫叶先生(fy)

「座右铭」前行路上修真我

「枫叶先生有点文青病」「每篇一句」

I do not know where to go,but I have been on the road.

我不知道将去何方,但我已在路上。 
——宫崎骏《千与千寻》

目录

四、 简单的TCP网络程序

4.1 服务端创建

4.1.1 创建套接字

4.1.2 绑定端口

4.1.3 监听

4.1.4 获取新链接

4.1.5 服务端代码(版本一)

4.2 客户端创建

4.2.1 创建套接字

4.2.2 连接服务器

4.2.3 客户端代码

4.3 服务端和客户端测试

4.4 多进程版的TCP网络程序

4.4.1 方法一:捕捉SIGCHLD信号

4.4.2 方法二:孙子进程

4.5 多线程版的TCP网络程序

4.6 线程池版的TCP网络程序


四、 简单的TCP网络程序

首先回顾一下TCP的特点:

  • 传输层协议
  • 有连接
  • 可靠传输
  • 面向字节流

注:到TCP原理再详细解释这些特点,这里只是简单了解

接下来进行编写socket套接字代码,使用的是TCP,也是边写代码边讲一下TCP的接口,还有一些原理。

4.1 服务端创建

首先明确,这个简单的TCP网络程序分客户端和服务端,所以我们要生成两个可执行程序,一个是客户端的,另一个是服务端的,服务端充当的是服务器,暂时实现的功能是客户端和服务端简单进行通信,服务端要可以收到客户端发送给服务端的信息,并把消息回显给客户端,目前就先简单实现这样的功能

下面进行编写服务端的代码

4.1.1 创建套接字

创建套接字的函数是socket,TCP/UDP 均可使用该函数进行创建套接字,该函数在上一篇编写UDP代码的时候已经详细介绍过了,这里就不再介绍,直接使用

 int socket(int domain, int type, int protocol);
  • 第一个参数:如果要选择网络通信,则选择 AF_INET(IPv4)或者 AF_INET6(IPv6)
  • 第二个参数:如果是基于TCP的网络通信,我们采用的就是 SOCK_STREAM,流式套接字,提供的是流式服务(对应TCP的特点:面向字节流)
  • 第三个参数:设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议

服务端创建套接字编写代码暂时如下:

tcpServer.hpp 

#pragma once

#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>

using namespace std;

// 错误类型枚举
enum
{
    UAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR
};

class tcpServer
{
public:
    tcpServer(const uint16_t &port)
        : _sockfd(-1), _port(port)
    {}

    // 初始化服务器
    void initServer()
    {
        // 1.创建套接字
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_sockfd == -1)
        {
            cout << "create socket error" << endl;
            exit(SOCKET_ERR);
        }
        cout << "socket success: " << _sockfd << endl;
    }

    // 启动服务器
    void start()
    {}

    ~tcpServer()
    {}

private:
    int _sockfd;    // 文件描述符
    uint16_t _port; // 端口号
};

注:创建套接字失败,没必要继续执行代码了,直接退出程序即可 

 tcpServer.cc

#include "tcpServer.hpp"
#include <memory>

// 使用手册
// ./tcpServer port
static void Uage(string proc)
{
    cout << "\nUage:\n\t" << proc << " local_port\n\n";
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Uage(argv[0]);
        exit(UAGE_ERR);
    }

    uint16_t port = atoi(argv[1]); // string to int

    unique_ptr<tcpServer> tsvr(new tcpServer(port));
    tsvr->initServer(); // 初始化服务器
    tsvr->start();      // 启动服务器

    return 0;
}

注:

  • TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务
  • 析构服务器时,可以将服务器对应的 _sockfd 文件描述符进行关闭,也可以不用处理,因为服务器的生命周期随进程,服务器一般是我们手动结束的,进程结束,资源也就归还给OS了

4.1.2 绑定端口

TCP 也是需要绑定端口号的,绑定端口号的函数是bind,TCP/UDP 均可使用进行该函数绑定端口

该函数在上一篇编写UDP代码的时候已经详细介绍过了,这里就不再介绍,直接使用

 int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • bind函数的第一个参数是sockfd,用于绑定套接字创建成功返回的文件描述符
  • bind函数的第二个参数是addr,用于填充网络相关的属性信息,比如IP地址、端口号等。我们要做的工作就是:定义一个 sockaddr_in 的结构体,然后对该结构体进行内容填充,填完就把给结构体传给 第二个参数addr,需要强制类型转换
  • 第三参数addrlen:传入的addr结构体的长度

注:当定义好 sockaddr_in 结构体后,最好先用memset函数对该结构体进行清空,也可以用bzero函数进行清空。bzero函数也可以对特定的一块内存区域进行清空,bzero函数的函数原型如下:

void bzero(void *s, size_t n);

bzero()函数将从s开始的区域的前n个字节设置为零(包含“\0”的字节)。 

头文件是:

 #include <strings.h>

注:绑定失败,就直接退出程序了,不必要再执行 

 服务端绑定编写代码如下:

 tcpServer.hpp 

 // 初始化服务器
    void initServer()
    {
        // 1.创建套接字
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_sockfd == -1)
        {
            cout << "create socket error" << endl;
            exit(SOCKET_ERR);
        }
        cout << "create socket success: " << _sockfd << endl;

        // 2.绑定端口
        // 2.1 填充 sockaddr_in 结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local));       // 把 sockaddr_in结构体全部初始化为0
        local.sin_family = AF_INET;         // 未来通信采用的是网络通信
        local.sin_port = htons(_port);      // htons(_port)主机字节序转网络字节序
        local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是  0x00000000

        // 2.2 绑定
        int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
        if (n == -1)
        {
            cout << "bind socket error" << endl;
            exit(BIND_ERR);
        }
        cout << "bind socket success" << endl;
    }

tcpServer.cc 没有改变

注意:设置服务器的IP地址时,不需要显示绑定IP地址,直接将IP地址设置 INADDR_ANY (全0),这个问题在上一节已经谈过

说一下前面UDP发送和接收的问题

因为UDP是面向数据报的,所以要用特定的接口进行收发消息。

为什么发送没有进行主机序列转为网络序列,接收消息没有把网络序列转为主机序列??

因为 recvfrom 和 sendto 是系统调用,这两个函数在函数内部已经帮我们做了,即主机序列转为网络序列和网络序列转为主机序列的工作

4.1.3 监听

UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信

因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态

listen函数

listen函数的作用是设置套接字为监听状态, man 2 listen查看:

listen for connections on a socket:监听套接字上的连接

函数:listen
 
头文件:
        #include <sys/types.h>
        #include <sys/socket.h>
 
函数原型:
         int listen(int sockfd, int backlog);
 
参数:
    第一个参数sockfd:需要设置为监听状态的套接字对应的文件描述符
    第二个参数backlog:全连接队列的最大长度
 
返回值:
    成功返回0,失败返回-1,同时错误码会被设置

注意:

  • 第二个参数backlog:全连接队列的最大长度。。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不能设置太大
  • 这个参数暂时解释不了,等后面讲 TCP协议原理的时候再谈,TODO
  • 这个参数暂时直接设置为5

TCP服务器在创建完套接字和绑定后,需要再进一步将套接字设置为监听状态,监听是否有新的连接到来。如果监听失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可

 服务监听代码编写如下:

  tcpServer.hpp 

static const int gbacklog = 5;
// 初始化服务器
    void initServer()
    {
        // 1.创建套接字
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_sockfd == -1)
        {
            cout << "create socket error" << endl;
            exit(SOCKET_ERR);
        }
        cout << "create socket success: " << _sockfd << endl;

        // 2.绑定端口
        // 2.1 填充 sockaddr_in 结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local));       // 把 sockaddr_in结构体全部初始化为0
        local.sin_family = AF_INET;         // 未来通信采用的是网络通信
        local.sin_port = htons(_port);      // htons(_port)主机字节序转网络字节序
        local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是  0x00000000

        // 2.2 绑定
        int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
        if (n == -1)
        {
            cout << "bind socket error" << endl;
            exit(BIND_ERR);
        }
        cout << "bind socket success" << endl;

        // 3. 把_sockfd套接字设置为监听状态
        if(listen(_sockfd, gbacklog) == -1)
        {
              cout << "listen socket error" << endl;
               exit(LISTEN_ERR);
        }
        cout << "listen socket error" << endl;
    }

 tcpServer.cc 没有变化

在初始化TCP服务器时,只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成

4.1.4 获取新链接

上面的代码已经把服务器初始化完成了,客户端有新链接到来,服务端可以获取到新链接,这一步需要死循环获取客户端新链接

获取新链接的函数是 accept

accept函数

 accept 函数的作用是用于获取客户端的链接,man 2 accept查看:

ccept a connection on a socket:接受套接字上的连接

函数:accept
 
头文件:
        #include <sys/types.h>
        #include <sys/socket.h>
 
函数原型:
          int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
 
参数:
    第一个参数sockfd:从该监听套接字中获取连接
    第二个参数addr:对方一端网络相关的属性信息
    第三个参数addrlen:addr的长度
 
返回值:
    获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置
  •  accept 的第二个参数是addr,类型是 struct sockaddr *
  •  第二个参数是addr 和第三个参数addrlen 是一个输入输出型参数
  •  第二个参数addr 用于返回发送数据一方的信息,比如IP、端口号等。就好比别人发消息给你,你得知道对方是谁
  • 我们要做的也是定义一个 sockaddr_in 的结构体,初始化该结构体,把结构体传给第二个参数addr,需要强制类型转换

accept函数返回值问题

accept获取连接成功返回接收到的套接字的文件描述符

问题:为什么又返回一个新的文件描述符??返回的这个新的文件描述符跟旧的文件描述符_sockfd有什么关系??

下面用一个小栗子进行说明,比如有一家小餐厅,张三是站在店外面招揽客人的,有客人的到来张三就说:我们这店是这里最好的,您要进来吃吗?有的客人进去的,有的客人没进去。而进去的客人,店里面又会叫服务员A、B、C...进行一对一服务。而张三并没有进去,依旧是在外面招揽客人

  • 这个揽客的张三就相当于旧的文件描述符_sockfd,不断获取客户端发来的连接请求;
  • 这些客户就是客户端的客户,向服务端发起请求;
  • 服务员A、B、C...就是新创建的文件描述符,新创建的文件描述符的核心工作就是提供服务
  • 有服务员A、B、C...就意味着,文件描述符会随用户的增多而增长

listen监听套接字与accept函数返回的套接字的作用(新创建的文件描述符):

  • listen监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接
  • accept函数返回的套接字:用于为本次accept获取到的连接提供服务。
  • 而listen监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字

所以代码中的 _sockfd 套接字全部改为 _listensock

服务获取新链接代码编写如下: 

未来通信就使用sockfd 

 tcpServer.hpp 

 // 启动服务器
    void start()
    {
        for (;;)
        {
            // 4. 获取新链接,accept从_listensock套接字里面获取新链接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 这里的sockfd才是真正为客户端请求服务
            int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);
            if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行
            {
                cout << "accept error, next!" << endl;
                continue;
            }
             cout << "accept a new line success" << endl;
        }
    }

 服务端在获取连接时需要注意:accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接

 tcpServer.cc 没有变化

4.1.5 服务端代码(版本一)

接下来就是补充完整服务端的代码了,收取客户端发来的消息,并回显给客户端

后序通信就使用sockfd,而这个sockfd是面向字节流的,也就意味后序的操作全部是文件操作,也就是进行文件读写

服务端代码如下:

 tcpServer.hpp  

#pragma once

#include <iostream>
#include <string>
#include <strings.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

using namespace std;

static const int gbacklog = 5;

// 错误类型枚举
enum
{
    UAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR
};

class tcpServer
{
public:
    tcpServer(const uint16_t &port)
        : _listensock(-1), _port(port)
    {}

    // 初始化服务器
    void initServer()
    {
        // 1.创建套接字
        _listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (_listensock == -1)
        {
            cout << "create socket error" << endl;
            exit(SOCKET_ERR);
        }
        cout << "create socket success: " << _listensock << endl;

        // 2.绑定端口
        // 2.1 填充 sockaddr_in 结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local));       // 把 sockaddr_in结构体全部初始化为0
        local.sin_family = AF_INET;         // 未来通信采用的是网络通信
        local.sin_port = htons(_port);      // htons(_port)主机字节序转网络字节序
        local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是  0x00000000

        // 2.2 绑定
        int n = bind(_listensock, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
        if (n == -1)
        {
            cout << "bind socket error" << endl;
            exit(BIND_ERR);
        }
        cout << "bind socket success" << endl;

        // 3. 把_listensock套接字设置为监听状态
        if (listen(_listensock, gbacklog) == -1)
        {
            cout << "listen socket error" << endl;
            exit(LISTEN_ERR);
        }
        cout << "listen socket success" << endl;
    }

    // 启动服务器
    void start()
    {
        for (;;)
        {
            // 4. 获取新链接,accept从_listensock套接字里面获取新链接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 这里的sockfd才是真正为客户端请求服务
            int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);
            if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行
            {
                cout << "accept error, next!" << endl;
                continue;
            }
            cout << "accept a new line success, sockfd: " << sockfd << endl;

            // 5. 为sockfd提供服务,即为客户端提供服务
            serviceIo(sockfd);
            // 走到这里。服务已经提供完成,必须关闭 sockfd
            close(sockfd);
        }
    }

    // 提供服务
    void serviceIo(int sockfd)
    {
        char buffer[1024];
        while (true)
        {
            // 读取客户端发来的消息
            ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
            if (n > 0) // 读取成功
            {
                buffer[n] = 0;
                cout << "recv a message: " << buffer << endl;

                // 回显消息给客户端
                string outbuffer = buffer;
                outbuffer += "server[echo]";
                write(sockfd, outbuffer.c_str(), outbuffer.size());
            }
            else if (n == 0) // 客户端退出
            {
                cout << "client qiut, me too!" << endl;
                break;
            }
        }
    }

    ~tcpServer()
    {}

private:
    int _listensock; // listen套接字,不是用来数据通信的,是用来监听链接到来
    uint16_t _port;  // 端口号
};

  tcpServer.cc 没有变化

注意

  • 这里暂时使用 read、write 的接口,读取是有问题的, 
  • 但是在这样先这样用,没有错,后序再使用 TCP 专用读写接口

 服务端已经可以编译运行了,运行结果如下:

netstat -atlp 查看

服务器已经处于监听状态了 

 netstat -atlpn 查看,n 以数字显示

  

注意:这个服务端读取是有问题的,后序再谈,目前就把内容当作字符串读取,也能满足目前需求

4.2 客户端创建

客户端的功能是可以发送消息给服务端,并收到服务端回显的消息,目前就先简单实现这样的功能

4.2.1 创建套接字

客户端也是使用socket函数创建套接字,与TCP服务端一样

客户端创建套接字代码编写如下:

tcpServer.hpp  

class tcpClient
{
public:
    tcpClient(const string &serverip, const uint16_t serverport)
        : _serverip(serverip), _serverport(serverport), _sockfd(-1)
    {
    }

    // 初始化客户端
    void initClient()
    {
        // 创建套接字
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_sockfd == -1)
        {
            cerr << "socket create error" << endl;
            exit(2);
        }

        // 2.绑定
        // 客户端必须要进行bind绑定,但是不需要我们自己bind,OS帮我们完成

        // 3.listen
        // 客户端不需要listen

        // 4. accept
        //客户端不需要
    }

    // 启动客户端
    void start()
    {
         //5. 客户端需要发起链接,链接服务端
    }
    ~tcpClient()
    {
    }

private:
    uint16_t _serverport; // 端口号
    string _serverip;     // ip地址
    int _sockfd;          // 文件描述符
};

注意:

  • 客户端必须要进行bind绑定,但是不需要我们自己bind,OS帮我们完成
  • 客户端也不需要listen进行监听
  • 客户端也不需要accept获取新链接
  • 但是客户端需要发起链接,连接服务端

tcpServer.cc

#include "tcpClient.hpp"
#include <memory>

// 使用手册
// ./tcpClient ip port
static void Uage(string proc)
{
    cout << "\nUage:\n\t" << proc << " server_ip server_port\n\n";
}

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Uage(argv[0]);
        exit(1);
    }

    // 客户端需要服务端的 IP 和 port
    string serverip = argv[1];
    uint16_t serverport = atoi(argv[2]); // string to int
    std::unique_ptr<tcpClient> tcli(new tcpClient(serverip, serverport));
    tcli->initClient(); // 初始化服务器
    tcli->start();        // 启动服务器

    return 0;
}

4.2.2 连接服务器

由于客户端不需要绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求。

connect函数

 connect函数用于发起连接请求,man 2 connect查看:

initiate a connection on a socket:在套接字上启动连接

函数:connect
 
头文件:
        #include <sys/types.h>
        #include <sys/socket.h>
 
函数原型:
        int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
 
参数:
    第一个参数sockfd:表示通过该套接字发起连接请求
    第二个参数addr:对方一端网络相关的属性信息
    第三个参数addrlen:addr的长度
 
返回值:
    连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置
  • 第二个参数是addr,表示对端网络相关的属性信息,包括协议家族、IP地址、端口号等。类型是 struct sockaddr *
  •  第二个参数是addr 和第三个参数addrlen 是一个输入型参数

客户连接代码编写如下:

tcpServer.hpp   

void start()
    {
        // 5. 客户端需要发起链接,链接服务端
        struct sockaddr_in server;
        memset(&server, 0, sizeof(server));
        server.sin_family = AF_INET;
        server.sin_port = htons(_serverport);                  // 主机转网络序列
        server.sin_addr.s_addr = inet_addr(_serverip.c_str()); // 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)

        if (connect(_sockfd, (struct sockaddr *)&server, sizeof(server)) != 0)
        {
            cerr << "socket connect error" << endl;
        }
        else // 连接成功
        {
           
        }
    }

 tcpServer.cc 没有变化

4.2.3 客户端代码

接下来就是补充完整服务端的代码了,客户端可以发送消息给服务端,客户端并且可以接收服务端回显的消息

后序操作也全部是文件操作

客户端代码如下:

tcpServer.hpp    

#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <strings.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

using namespace std;

static const int gnum = 1024;

class tcpClient
{
public:
    tcpClient(const string &serverip, const uint16_t serverport)
        : _serverip(serverip), _serverport(serverport), _sockfd(-1)
    {}

    // 初始化客户端
    void initClient()
    {
        // 创建套接字
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_sockfd == -1)
        {
            cerr << "socket create error" << endl;
            exit(2);
        }

        // 2.绑定
        // 客户端必须要进行bind绑定,但是不需要我们自己bind,OS帮我们完成
        // 3.listen
        // 客户端不需要listen
        // 4. accept
        // 客户端不需要
    }

    // 启动客户端
    void start()
    {
        // 5. 客户端需要发起链接,链接服务端
        struct sockaddr_in server;
        memset(&server, 0, sizeof(server));
        server.sin_family = AF_INET;
        server.sin_port = htons(_serverport);                  // 主机转网络序列
        server.sin_addr.s_addr = inet_addr(_serverip.c_str()); // 1.string类型转int类型 2.把int类型转换成网络字节序 (这两个工作inet_addr已完成)

        if (connect(_sockfd, (struct sockaddr *)&server, sizeof(server)) != 0)
        {
            cerr << "socket connect error" << endl;
        }
        else // 连接成功
        {
            string message;
            while (true)
            {
                // 发送消息
                cout << "Enter# ";
                getline(cin, message);
                write(_sockfd, message.c_str(), message.size());

                // 接收服务端回显的消息
                char buffer[gnum];
                int n = read(_sockfd, buffer, sizeof(buffer) - 1);
                if (n > 0) // 读取成功
                {
                    buffer[n] = 0;
                    cout << "Server回显# " << buffer << endl;
                }
                else // 读取出错
                {
                    break; 
                }
            }
        }
    }
    ~tcpClient()
    {}

private:
    uint16_t _serverport; // 端口号
    string _serverip;     // ip地址
    int _sockfd;          // 文件描述符
};

  tcpServer.cc 没有变化

注意:这个服务端读取是有问题的,后序再谈,目前就把内容当作字符串读取,也能满足目前需求

4.3 服务端和客户端测试

然后进行整体编译,编译没有问题

Makefile

新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试 

其中文件描述符会随着客户端的链接而递增 

发送消息测试,测试正常

查看一下进程信息

 netstat -atlp 查看

 客户端和服务端在同一台机器上跑,就会查到三个:

如果是两台主机,在服务端就会查到两个信息:

  • 第一个是服务器处于监听状态的
  • 第二个是其中处于链接状态的, ESTABLISHED 的意思是:建立连接;
  • 第二个的Foreign Address:127.0.0.1:40874,代表的是客户端的IP和端口号

如果是两台主机,在客户端就会查到一个信息: 

测试,客户端关闭,服务端相应的文件描述符也要随之关闭

该服务器的弊端 

当我们仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务 

但在这个客户端正在享受服务端的服务时,我们让另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端

只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。

通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。

当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端

 客户端为什么会显示连接成功?

当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上

实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。

如何解决这个问题 

单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务器一般是不会写成单执行流的。要解决这个问题就需要将服务器改为多执行流的,即多进程或多线程

4.4 多进程版的TCP网络程序

客户端没有变化,要改的是服务端代码

把当前的单执行流服务器改为多进程版的服务器

  • 当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。
  • 由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕。
  • 文件描述符表是隶属于一个进程的,子进程创建后会继承父进程的文件描述符表。比如父进程打开了一个文件,该文件对应的文件描述符是3,此时父进程创建的子进程的3号文件描述符也会指向这个打开的文件,而如果子进程再创建一个子进程,那么子进程创建的子进程的3号文件描述符也同样会指向这个打开的文件
  • 对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务

等待子进程问题

当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏

阻塞式等待与非阻塞式等待子进程:

  • 如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。
  • 如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出。
  • 总之,服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不尽人意。此时我们可以考虑让服务端不等待子进程退出

不等待子进程退出的方式

常见的方式有两种:

  1. 捕捉SIGCHLD信号,将其处理动作设置为忽略。
  2. 让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务

4.4.1 方法一:捕捉SIGCHLD信号

SIGCHLD信号是在子进程退出时由内核发送给父进程的信号。默认情况下,父进程会等待子进程退出并进行处理。但是,可以通过捕捉SIGCHLD信号并将其处理动作设置为忽略,来实现父进程不等待子进程退出的效果,这样父进程就不必关心子进程了

服务端代码修改如下

 只需要更改start函数 

// 启动服务器
    void start()
    {
        // 忽略SIGCHLD信号
        signal(SIGCHLD, SIG_IGN);
        for (;;)
        {
            // 4. 获取新链接,accept从_listensock套接字里面获取新链接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 这里的sockfd才是真正为客户端请求服务
            int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);
            if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行
            {
                cout << "accept error, next!" << endl;
                continue;
            }
            cout << "accept a new line success, sockfd: " << sockfd << endl;

            // 5. 为sockfd提供服务,即为客户端提供服务
            // serviceIo(sockfd);
            // // 走到这里。服务已经提供完成,必须关闭 sockfd
            // close(sockfd);

            // 多进程版(忽略信号)
            pid_t id = fork();
            if (id == 0) // 子进程
            {
                close(_listensock);
                serviceIo(sockfd);
                close(sockfd);
                exit(0);
            }
            // 父进程无需等待
        }
    }

编译没有问题

新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试

这样服务端就可以响应多个客户端的请求

注:读取可能会出现问题,这个后序再谈 

把客户端关掉,重新连接,文件描述符正常关闭

查看一下进程信息 

 ps axj | head -1 && ps axj | grep tcpServer | grep -v grep

这两个客户端分别由两个不同的执行流提供服务,因此这两个客户端可以同时享受到服务,它们发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应 

 

当客户端全部退出后,在服务端对应为之提供服务的子进程也会相继退出,但无论如何服务端都至少会有一个服务进程,这个服务进程的任务就是不断获取新连接

4.4.2 方法二:孙子进程

让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务

对于父进程来说,子进程创建的进程与父进程的关系是孙子关系(站在父进程的视角)

由于子进程进程创建完孙子进程后就立刻退出了,因此实际为客户端提供服务的孙子进程就变成了孤儿进程,该进程就会被OS领养,当孙子进程为客户端提供完服务退出后OS会回收孙子进程,所以父进程是不需要等待孙子进程退出的

子进程需要关闭对应不用的文件描述符,否则就会造成文件描述符泄漏,或者子进程可能会对不需要的文件描述符进行某种误操作

  • 父进程的accept函数获取到新连接后,会让孙子进程为该连接提供服务,此时服务进程已经将文件描述符表继承给了子进程,而子进程又会调用fork函数创建出孙子进程,然后再将文件描述符表继承给孙子进程
  • 子进程继承的文件描述符是一个副本,它们指向相同的文件表项。当父进程或子进程关闭文件描述符时,只会关闭其副本,并不会影响到另一个进程的文件描述符
  • 父子进程创建后,它们各自的文件描述符表是独立的,不会相互影响。因此父进程在调用fork函数后,子进程就不需要再关心从父进程继承下来的文件描述符_listensock,此时子进程就可以调用close函数将该文件描述符进行关闭,即_listensock
  • 不关闭就会造成文件描述符泄漏

父进程也需要关掉不用的文件描述符,否则就会造成文件描述符泄漏

服务端代码修改如下:

tcpServer.hpp     

只需要更改start函数 

// 启动服务器
    void start()
    {
        for (;;)
        {
            // 4. 获取新链接,accept从_listensock套接字里面获取新链接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 这里的sockfd才是真正为客户端请求服务
            int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);
            if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行
            {
                cout << "accept error, next!" << endl;
                continue;
            }
            cout << "accept a new line success, sockfd: " << sockfd << endl;

            // 5. 为sockfd提供服务,即为客户端提供服务
            // serviceIo(sockfd);
            // // 走到这里。服务已经提供完成,必须关闭 sockfd
            // close(sockfd);

            // 多进程版(孙子进程)
            pid_t id = fork();
            if (id == 0) // 子进程
            {
                close(_listensock);
                // 创建孙子进程,让子进程退出
                if (fork() > 0)
                    exit(0);
                // 孙子进程执行后序代码
                serviceIo(sockfd);
                close(sockfd);
                exit(0);
            }
            // 父进程
            pid_t ret = waitpid(id, nullptr, 0);
            if (ret > 0)
            {
                cout << "wait success" << endl;
            }
            close(sockfd); // 必须关掉
        }
    }

 编译没有问题

新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试

这样服务端就可以响应多个客户端的请求

注:读取可能会出现问题,这个后序再谈 

把客户端关掉,重新连接,文件描述符正常关闭

查看一下进程信息 

 ps axj | head -1 && ps axj | grep tcpServer | grep -v grep

 这两个客户端是由两个不同的孤儿进程提供服务的,因此它们也是能够同时享受到服务的,可以看到这两个客户端发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。

PPID为1,表明这是一个孤儿进程

当客户端全部退出后,对应为客户端提供服务的孤儿进程也会跟着退出,这时这些孤儿进程会被系统回收,而最终剩下那个获取连接的父进程

注:如果服务端先退,客户端后退,下一次运行服务端就会绑定端口失败,换一个端口绑定就好了,至于原理后序再谈 

  

4.5 多线程版的TCP网络程序

频繁的创建进程会给OS带来巨大的负担,并且创建线程的成本比创建线程高得多。因此在实现多执行流的服务器时最好采用多线程进行实现。这块在多线程已经谈过,不再赘述

当服务进程调用accept函数获取到一个新连接后,就可以直接创建一个线程,让该线程为对应客户端提供服务。

主线程创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但对于线程来说,如果不想让主线程等待新线程退出,直接线程分离即可,当这个线程退出时系统会自动回收该线程所对应的资源。

主线程和新线程对文件描述符的态度

各个线程共享是同一张文件描述符表,也就是说服务进程(主线程)调用accept函数获取到一个文件描述符后,其他创建的新线程是能够直接访问这个文件描述符的。

文件描述符关闭的问题

对于主线程accept上来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭

注意:线程的回调方法在类内需要设置为静态,至于原因在linux系统编程多线程已经解释过了,不再赘述

tcpServer.hpp      

#pragma once

#include <iostream>
#include <string>
#include <strings.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

using namespace std;

static const int gbacklog = 5;

// 错误类型枚举
enum
{
    UAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR
};

class tcpServer; // 声明
class ThreadDate
{
public:
    ThreadDate(tcpServer *self, int sockfd)
        : _self(self), _sockfd(sockfd)
    {}

public:
    tcpServer *_self;
    int _sockfd;
};

class tcpServer
{
public:
    tcpServer(const uint16_t &port)
        : _listensock(-1), _port(port)
    {}

    // 初始化服务器
    void initServer()
    {
        // 1.创建套接字
        _listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (_listensock == -1)
        {
            cout << "create socket error" << endl;
            exit(SOCKET_ERR);
        }
        cout << "create socket success: " << _listensock << endl;

        // 2.绑定端口
        // 2.1 填充 sockaddr_in 结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local));       // 把 sockaddr_in结构体全部初始化为0
        local.sin_family = AF_INET;         // 未来通信采用的是网络通信
        local.sin_port = htons(_port);      // htons(_port)主机字节序转网络字节序
        local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是  0x00000000

        // 2.2 绑定
        int n = bind(_listensock, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
        if (n == -1)
        {
            cout << "bind socket error" << endl;
            exit(BIND_ERR);
        }
        cout << "bind socket success" << endl;

        // 3. 把_listensock套接字设置为监听状态
        if (listen(_listensock, gbacklog) == -1)
        {
            cout << "listen socket error" << endl;
            exit(LISTEN_ERR);
        }
        cout << "listen socket success" << endl;
    }

    // 启动服务器
    void start()
    {
        for (;;)
        {
            // 4. 获取新链接,accept从_listensock套接字里面获取新链接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 这里的sockfd才是真正为客户端请求服务
            int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);
            if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行
            {
                cout << "accept error, next!" << endl;
                continue;
            }
            cout << "accept a new line success, sockfd: " << sockfd << endl;

            // 5. 为sockfd提供服务,即为客户端提供服务
            // serviceIo(sockfd);
            // // 走到这里。服务已经提供完成,必须关闭 sockfd
            // close(sockfd);

            // 多线程版
            pthread_t tid;
            ThreadDate *td = new ThreadDate(this, sockfd);
            pthread_create(&tid, nullptr, threadRoutine, td);
        }
    }

    static void *threadRoutine(void *args)
    {
        pthread_detach(pthread_self()); // 线程分离
        ThreadDate *td = static_cast<ThreadDate *>(args);
        td->_self->serviceIo(td->_sockfd);
        close(td->_sockfd); // 必须关闭,由新线程关闭
        delete td;
        return nullptr;
    }

    // 提供服务
    void serviceIo(int sockfd)
    {
        char buffer[1024];
        while (true)
        {
            // 读取客户端发来的消息
            ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
            if (n > 0) // 读取成功
            {
                buffer[n] = 0;
                cout << "recv a message: " << buffer << endl;

                // 回显消息给客户端
                string outbuffer = buffer;
                outbuffer += "  server[echo]";
                write(sockfd, outbuffer.c_str(), outbuffer.size());
            }
            else if (n == 0) // 客户端退出
            {
                cout << "client qiut, me too!" << endl;
                break;
            }
        }
    }

    ~tcpServer()
    {}

private:
    int _listensock; // listen套接字,不是用来数据通信的,是用来监听链接到来
    uint16_t _port;  // 端口号
};

 其他没有发送变化

注意:编译要带 -lpthread,因为使用了线程库

注意一下:前面客户端代码写漏了一个.... 刚发现。

编译没有问题

新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试

这样服务端就可以响应多个客户端的请求

把客户端关掉,重新连接,文件描述符正常关闭

查看一下线程信息  

ps -aL

 

当客户端全部退出后,服务端的服务线程也随之退出

4.6 线程池版的TCP网络程序

多线程存在的问题

  • 每当有新连接到来时,服务端的主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁。这样做不仅麻烦,而且效率低下,每当连接到来的时候服务端才创建对应提供服务的线程。
  • 如果有大量的客户端连接请求,此时服务端要为每一个客户端创建对应的服务线程。
  • 计算机当中的线程越多,CPU的压力就越大

解决方法:线程池

  • 可以在服务端预先创建一批线程,当有客户端请求连接时就让这些线程为客户端提供服务,此时客户端一来就有线程为其提供服务,而不是当客户端来了才创建对应的服务线程(频繁创建线程的开销)
  • 当某个线程为客户端提供完服务后,不要让该线程退出,而是让该线程继续为下一个客户端提供服务,如果当前没有客户端连接请求,则可以让该线程先进入休眠状态,当有客户端连接到来时再将该线程唤醒。
  • 服务端创建的这一批线程的数量不能太多,此时CPU的压力也就不会太大

需要在服务端引入线程池,因为线程池的存在就是为了避免处理短时间任务时创建与销毁线程的代价,此外,线程池还能够保证内核充分利用,防止过分调度

线程池在系统编程部分已经谈过,不再赘述

服务端代码编写如下:

#pragma once

#include <iostream>
#include <string>
#include <memory>
#include <strings.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

#include "ThreadPool.hpp"
#include "Task.hpp"

using namespace std;

static const int gbacklog = 5;

// 错误类型枚举
enum
{
    UAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR
};

class tcpServer
{
public:
    tcpServer(const uint16_t &port)
        : _listensock(-1), _port(port)
    {}

    // 初始化服务器
    void initServer()
    {
        // 1.创建套接字
        _listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (_listensock == -1)
        {
            cout << "create socket error" << endl;
            exit(SOCKET_ERR);
        }
        cout << "create socket success: " << _listensock << endl;

        // 2.绑定端口
        // 2.1 填充 sockaddr_in 结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local));       // 把 sockaddr_in结构体全部初始化为0
        local.sin_family = AF_INET;         // 未来通信采用的是网络通信
        local.sin_port = htons(_port);      // htons(_port)主机字节序转网络字节序
        local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY 就是  0x00000000

        // 2.2 绑定
        int n = bind(_listensock, (struct sockaddr *)&local, sizeof(local)); // 需要强转,(struct sockaddr*)&local
        if (n == -1)
        {
            cout << "bind socket error" << endl;
            exit(BIND_ERR);
        }
        cout << "bind socket success" << endl;

        // 3. 把_listensock套接字设置为监听状态
        if (listen(_listensock, gbacklog) == -1)
        {
            cout << "listen socket error" << endl;
            exit(LISTEN_ERR);
        }
        cout << "listen socket success" << endl;
    }

    // 启动服务器
    void start()
    {
        // 初始化线程池
        unique_ptr<ThreadPool<Task>> tp(new ThreadPool<Task>());
        tp->run();

        for (;;)
        {
            // 4. 获取新链接,accept从_listensock套接字里面获取新链接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 这里的sockfd才是真正为客户端请求服务
            int sockfd = accept(_listensock, (struct sockaddr *)&peer, &len);
            if (sockfd < 0) // 获取新链接失败,但不会影响服务端运行
            {
                cout << "accept error, next!" << endl;
                continue;
            }
            cout << "accept a new line success, sockfd: " << sockfd << endl;

            // 5. 为sockfd提供服务,即为客户端提供服务
            // 构建任务
            tp->push(Task(sockfd, serviceIo));
        }
    }

    ~tcpServer()
    {}

private:
    int _listensock; // listen套接字,不是用来数据通信的,是用来监听链接到来
    uint16_t _port;  // 端口号
};

 由于代码过多就不一一贴出来了,上传到Gitee

 Gitee链接:code_linux/code_202306_16/2_tcp/4_tcpthpool · Maple_fylqh/code - 码云 - 开源中国 (gitee.com)

代码测试 

编译没有问题

新建窗口,先运行服务端,再启动客户端,客户端先用本地环回进行测试

把客户端关掉,重新连接,文件描述符正常关闭

pa -aL 查看一下线程信息  

运行服务端后,就算没有客户端发来连接请求,此时在服务端就已经有了6个线程,其中有一个是接收新连接的服务线程,而其余的5个是线程池当中为客户端提供服务的线程

与之前不同的是,无论现在有多少客户端发来请求,在服务端都只会有线程池当中的5个线程为之提供服务,线程池当中的线程个数不会随着客户端连接的增多而增多,这些线程也不会因为客户端的退出而退出,也就是说目前写的代码只适合用户访问量少。

写这个代码的目的是结合以前所学知识,对知识进行容纳贯穿

注: 内容过多,还未写完,下篇见

--------------------- END ----------------------

「 作者 」 枫叶先生
「 更新 」 2023.6.21
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
          或有谬误或不准确之处,敬请读者批评指正。

猜你喜欢

转载自blog.csdn.net/m0_64280701/article/details/131275355