网络编程『简易TCP网络程序』

🔭个人主页: 北 海
🛜所属专栏: Linux学习之旅神奇的网络世界
💻操作环境: CentOS 7.6 阿里云远程服务器

成就一亿技术人

文章目录


🌤️前言

随着数字时代的来临,TCP网络程序已成为程序员不可或缺的技术领域。本博客将带领读者深入研究,从最基础的字符串回响开始,逐步探索至多进程、多线程服务器的高级实践。我们将详细探讨每个环节的核心功能和实现细节,致力于帮助读者深刻理解网络编程的本质。通过系统学习本博客内容,读者将获得构建稳健网络应用的重要技能,更加自信地应对日益复杂的软件开发挑战。这里将为你的编程旅程提供扎实的基础和深远的启示。


🌦️正文

TCP网络程序

接下来实现一批基于 TCP 协议的网络程序


1.字符串回响

1.1.核心功能

字符串回响程序类似于 echo 指令,客户端向服务器发送消息,服务器在收到消息后会将消息发送给客户端,该程序实现起来比较简单,同时能很好的体现 socket 套接字编程的流程

1.2.程序结构

这个程序我们已经基于 UDP 协议实现过了,换成 TCP 协议实现时,程序的结构是没有变化的,同样需要 server.hppserver.ccclient.hppclient.cc 这几个文件

创建 server.hpp 服务器头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号

    class TcpServer
    {
    public:
        TcpServer(const uint16_t port = default_port)
            :port_(port)
        {}

        ~TcpServer()
        {}

        // 初始化服务器
        void InitServer()
        {}

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

    private:
        int sock_; // 套接字(存疑)
        uint16_t port_; // 端口号
    };
}

注意: 这里的 sock_ 套接字成员后面需要修改

创建 server.cc 服务器源文件

cpp 复制代码
#include <memory> // 智能指针头文件
#include "server.hpp"

using namespace std;
using namespace nt_server;

int main()
{
    unique_ptr<TcpServer> usvr (new TcpServer());

    usvr->InitServer();
    usvr->StartServer();
    return 0;
}

创建 client.hpp 客户端头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_client
{
    class TcpClient
    {
    public:
        TcpClient(const std::string& ip, const uint16_t port)
            :server_ip_(ip), server_port_(port)
        {}

        ~TcpClient()
        {}

        // 初始化客户端
        void InitClient()
        {}

        // 启动客户端
        void StartClient()
        {}
        
    private:
        int sock_; // 套接字
        std::string server_ip_; // 服务器IP
        uint16_t server_port_; // 服务器端口号
    };
}

创建 client.cc 客户端源文件

cpp 复制代码
#include <memory>
#include "client.hpp"

using namespace std;
using namespace nt_client;

void Usage(const char *program)
{
    cout << "Usage:" << endl;
    cout << "\t" << program << " ServerIP ServerPort" << endl;
}

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        // 错误的启动方式,提示错误信息
        Usage(argv[0]);
        return USAGE_ERR;
    }

    // 服务器IP与端口号
    string ip(argv[1]);
    uint16_t port = stoi(argv[2]);

    unique_ptr<TcpClient> usvr(new TcpClient(ip, port));

    usvr->InitClient();
    usvr->StartClient();
    return 0;
}

同时需要一个 Makefile 文件,用于快速编译和清理可执行程序

创建 Makefile 文件

bash 复制代码
.PHONY:all
all:server client

server:server.cc
	g++ -o $@ $^ -std=c++11

	
client:client.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -rf server client

最后为了方便判断程序错误,可以增加上一篇文章中的 err.hpp 头文件,里面包含错误码与简易错误信息

创建 err.hpp 错误码头文件

cpp 复制代码
#pragma once

enum
{
    USAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR
};

接下来开始填充代码内容


服务器


1.3.初始化服务器

基于 TCP 协议实现的网络程序也需要 创建套接字、绑定 IP 和端口号

在使用 socket 函数创建套接字时,UDP 协议需要指定参数2为 SOCK_DGRAMTCP 协议则是指定参数2为 SOCK_STREAM

注:关于 socketbindsockaddr 的细节,可以看看这篇文章《网络编程『socket套接字 ‖ 简易UDP网络程序』

InitServer() 初始化服务器函数 --- 位于 server.hpp 服务器头文件中的 TcpServer

cpp 复制代码
const uint16_t default_port = 8888; // 默认端口号
    
// 初始化服务器
void InitServer()
{
    // 1.创建套接字
    sock_ = socket(AF_INET, SOCK_STREAM, 0);
    if(sock_ == -1)
    {
        std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;
        exit(SOCKET_ERR);
    }

    std::cout << "Create Socket Success! " << sock_ << std::endl;

    // 2.绑定IP地址与端口号
    struct sockaddr_in local;
    memset(&local, 0, sizeof(local)); // 清零
    local.sin_family = AF_INET;
    local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
    local.sin_port = htons(port_);
    
    if(bind(listensock_, (const sockaddr*)&local, sizeof(local)))
    {
        std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
        exit(BIND_ERR);
    }
            
    // 3.TODO
}

注意: 在绑定端口号时,一定需要把主机序列转换为网络序列

为什么在绑定端口号阶段需要手动转换为网络序列,而在发送信息阶段则不需要?
这是因为在发送信息阶段,recvfrom / sendto 等函数会自动将需要发送的信息转换为网络序列,接收信息时同样会将其转换为主机序列,所以不需要手动转换

如果使用的 UDP 协议,那么初始化服务器到此就结束了,但我们本文中使用的是 TCP 协议,这是一个 面向连接 的传输层协议,意味着在初始化服务器时,需要设置服务器为 监听 状态

使用到的函数是 listen 函数

cpp 复制代码
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int listen(int sockfd, int backlog);

参数解读:

  • sockfd 通过该套接字进行监听
  • backlog 全连接队列最大长度

返回值:监听成功返回 0,失败返回 -1

这里的参数2需要设置一个整数,通常为 16、32、64... ,表示 全连接队列 的最大长度,关于 全连接队列 的详细知识放到后续博客中讲解,这里只需要直接使用

server.hpp 服务器头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32; // 全连接队列的最大长度

    class TcpServer
    {
    public:
        TcpServer(const uint16_t port = default_port)
            :port_(port)
        {}

        ~TcpServer()
        {}

        // 初始化服务器
        void InitServer()
        {
            // 1.创建套接字
            sock_ = socket(AF_INET, SOCK_STREAM, 0);
            if(sock_ == -1)
            {
                std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create Socket Success! " << sock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);

            if(bind(listensock_, (const sockaddr*)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }
     
            // 3.监听
            if(listen(sock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

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

    private:
        int sock_; // 套接字(存疑)
        uint16_t port_; // 端口号
    };
}

至此基于 TCP 协议实现的初始化服务器函数就填充完成了,编译并运行服务器,显示初始化服务器成功

1.4.启动服务器

1.4.1.处理连接请求

TCP 是面向连接,当有客户端发起连接请求时,TCP 服务器需要正确识别并尝试进行连接,当连接成功时,与其进行通信,可使用 accept 函数进行连接

cpp 复制代码
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数解读:

  • sockfd 服务器用于处理连接请求的 socket 套接字
  • addr 客户端的 sockaddr 结构体信息
  • addrlen 客户端的 sockaddr 结构体大写

其中 addraddrlen 是一个 输入输出型 参数,类似于 recvfrom 中的参数

返回值:连接成功返回一个用于通信的 socket 套接字(文件描述符),失败返回 -1

这也就意味着之前我们在 TcpServer 中创建的类内成员 sock_ 并非是用于通信,而是专注于处理连接请求,在 TCP 服务器中,这种套接字称为 监听套接字

使用 accept 函数处理连接请求

server.hpp 服务器头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32; // 全连接队列的最大长度

    class TcpServer
    {
    public:
        TcpServer(const uint16_t port = default_port)
            :port_(port), quit_(false)
        {}

        ~TcpServer()
        {}

        // 初始化服务器
        void InitServer()
        {
            // 1.创建监听套接字
            listensock_ = socket(AF_INET, SOCK_STREAM, 0);
            if(listensock_ == -1)
            {
                std::cerr << "Create ListenSocket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create ListenSocket Success! " << listensock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);


            if(bind(listensock_, (const sockaddr*)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }
     
            // 3.监听
            if(listen(listensock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {
            while(!quit_)
            {
                // 1.处理连接请求
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int sock = accept(listensock_, (struct sockaddr*)&client, &len);

                // 2.如果连接失败,继续尝试连接
                if(sock == -1)
                {
                    std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
                    continue;
                }

                // 连接成功,获取客户端信息
                std::string clientip = inet_ntoa(client.sin_addr);
                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

                // 3.根据 sock 套接字进行通信
                Service(sock, clientip, clientport);
            }
        }

    private:
        int listensock_; // 监听套接字
        uint16_t port_; // 端口号
        bool quit_; // 判断服务器是否结束运行
    };
}
1.4.2.业务处理

对于 TCP 服务器来说,它是面向字节流传输的,我们之前使用的文件相关操作也是面向字节流,凑巧的是在 Linux 中网络是以挂接在文件系统的方式实现的,种种迹象表明:可以通过文件相关接口进行通信

  • read 从文件中读取信息(接收消息)
  • write 向文件中写入信息(发送消息)

这两个系统调用的核心参数是 fd文件描述符 ),即服务器与客户端在连接成功后,获取到的 socket 套接字,所以接下来可以按文件操作的套路,完成业务处理

Service() 业务处理函数 --- 位于 server.hpp 服务器头文件中的 TcpServer

cpp 复制代码
// 业务处理
void Service(int sock, const std::string& clientip, const uint16_t& clientport)
{
    char buff[1024];
    std::string who = clientip + "-" + std::to_string(clientport);
    while (true)
    {
        ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
        if (n > 0)
        {
            // 读取成功
            buff[n] = '\0';
            std::cout << "Server get: " << buff << " from " << who << std::endl;

            std::string respond = func_(buff); // 实际业务处理由上层指定

            // 发送给服务器
            write(sock, buff, strlen(buff));
        }
        else if (n == 0)
        {
            // 表示当前读取到文件末尾了,结束读取
            std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
            close(sock); // 关闭文件描述符
            break;
        }
        else
        {
            // 读取出问题(暂时)
            std::cerr << "Read Fail!" << strerror(errno) << std::endl;
            close(sock); // 关闭文件描述符
            break;
        }
    }
}
1.4.3.回调函数

为了更好的实现功能解耦,这里将真正的业务处理函数交给上层处理,编写完成后传给 TcpServer 对象即可,当然,在 TcpServer 类中需要添加对应的类型

这里设置回调函数的返回值为 string,参数同样为 string
server.hpp 服务器头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32;             // 全连接队列的最大长度

    using func_t = std::function<std::string(std::string)>; // 回调函数类型

    class TcpServer
    {
    public:
        TcpServer(const func_t &func, const uint16_t port = default_port)
            : func_(func), port_(port), quit_(false)
        {
        }

        ~TcpServer()
        {
        }

        // 初始化服务器
        void InitServer()
        {
            // 1.创建监听套接字
            listensock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (listensock_ == -1)
            {
                std::cerr << "Create ListenSocket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create ListenSocket Success! " << listensock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);

            if (bind(listensock_, (const sockaddr *)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 3.监听
            if (listen(listensock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {
            while (!quit_)
            {
                // 1.处理连接请求
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int sock = accept(listensock_, (struct sockaddr *)&client, &len);

                // 2.如果连接失败,继续尝试连接
                if (sock == -1)
                {
                    std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
                    continue;
                }

                // 连接成功,获取客户端信息
                std::string clientip = inet_ntoa(client.sin_addr);
                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

                // 3.根据 sock 套接字进行通信
                Service(sock, clientip, clientport);
            }
        }

        // 业务处理
        void Service(int sock, const std::string& clientip, const uint16_t& clientport)
        {
            char buff[1024];
            std::string who = clientip + "-" + std::to_string(clientport);
            while (true)
            {
                ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
                if (n > 0)
                {
                    // 读取成功
                    buff[n] = '\0';
                    std::cout << "Server get: " << buff << " from " << who << std::endl;

                    std::string respond = func_(buff); // 实际业务处理由上层指定

                    // 发送给服务器
                    write(sock, buff, strlen(buff));
                }
                else if (n == 0)
                {
                    // 表示当前读取到文件末尾了,结束读取
                    std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
                else
                {
                    // 读取出问题(暂时)
                    std::cerr << "Read Fail!" << strerror(errno) << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
            }
        }

    private:
        int listensock_; // 监听套接字
        uint16_t port_;  // 端口号
        bool quit_;      // 判断服务器是否结束运行
        func_t func_;    // 回调函数
    };
}

服务器头文件准备完成,接下来就是填充 server.cc 服务器源文件

1.5.服务器源文件

对于当前的 TCP 网络程序(字符串回响)来说,业务处理函数逻辑非常简单,无非就是直接将客户端发送过来的消息,重新转发给客户端

server.cc 服务器源文件

cpp 复制代码
#include <memory> // 智能指针头文件
#include <string>
#include "server.hpp"

using namespace std;
using namespace nt_server;

// 业务处理回调函数(字符串回响)
string echo(string request)
{
    return request;
}

int main()
{
    unique_ptr<TcpServer> usvr (new TcpServer(echo)); // 将回调函数进行传递

    usvr->InitServer();
    usvr->StartServer();
    return 0;
}

尝试编译并运行服务器,可以看到当前 bash 已经被我们的服务器程序占用了,重新打开一个终端,并通过 netstat 命令查看网络使用情况(基于 TCP 协议)

bash 复制代码
netstat -nltp

当前服务确实使用的是 8888 端口,并且采用的是 TCP 协议


客户端


1.6.初始化客户端

对于客户端来说,服务器的 IP 地址与端口号是两个不可或缺的元素,因此在客户端类中,server_ipserver_port 这两个成员是少不了的,当然得有 socket 套接字

初始化客户端只需要干一件事:创建套接字 ,客户端是主动发起连接请求的一方,也就意味着它不需要使用 listen 函数设置为监听状态

注意: 客户端也是需要 bind 绑定的,但不需要自己手动绑定,由操作系统帮我们自动完成

client.hpp 客户端头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_client
{
    class TcpClient
    {
    public:
        TcpClient(const std::string& ip, const uint16_t port)
            :server_ip_(ip), server_port_(port)
        {}

        ~TcpClient()
        {}

        // 初始化客户端
        void InitClient()
        {
            // 创建套接字
            sock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (sock_ == -1)
            {
                std::cerr << "Create Socket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }
            
            std::cout << "Create Sock Succeess! " << sock_ << std::endl;
        }

        // 启动客户端
        void StartClient()
        {}
        
    private:
        int sock_; // 套接字
        std::string server_ip_; // 服务器IP
        uint16_t server_port_; // 服务器端口号
    };
}

编译并运行客户端,显示 socket 套接字创建成功

1.7.启动客户端

1.7.1.尝试进行连接

因为 TCP 协议是面向连接的,服务器已经处于处理连接请求的状态了,客户端现在需要做的就是尝试进行连接,使用 connect 函数进行连接

cpp 复制代码
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数解读:

  • sockfd 需要进行连接的套接字
  • addr 服务器的 sockaddr 结构体信息
  • addrlen 服务器的 sockaddr 结构体大小

返回值:连接成功返回 0,连接失败返回 -1


在连接过程中,可能遇到很多问题,比如 网络传输失败、服务器未启动 等,这些问题的最终结果都是客户端连接失败,如果按照之前的逻辑(失败就退出),那么客户端的体验感会非常不好,因此在面对连接失败这种常见问题时,客户端应该尝试重连,如果重连数次后仍然失败,才考虑终止进程

注意: 在进行重连时,可以使用 sleep() 等函数使程序睡眠一会,给网络恢复留出时间

StartClient() 启动客户端函数 --- 位于 client.hpp 中的 TcpClient

cpp 复制代码
// 启动客户端
void StartClient()
{
    // 填充服务器的 sockaddr_in 结构体信息
    struct sockaddr_in server;
    socklen_t len = sizeof(server);
    memset(&server, 0, len);
    server.sin_family = AF_INET;
    inet_aton(server_ip_.c_str(), &server.sin_addr); // 将点分十进制转化为二进制IP地址的另一种方法
    server.sin_port = htons(server_port_);

    // 尝试重连 5 次
    int n = 5;
    while(n)
    {
        int ret = connect(sock_, (const struct sockaddr*)&server, len);

        if(ret == 0)
        {
            // 连接成功,可以跳出循环
            break;
        }

        // 尝试进行重连
        std::cerr << "网络异常,正在进行重连... 剩余连接次数: " << --n << std::endl;
        sleep(1);
    }

    // 如果剩余重连次数为 0,证明连接失败
    if(n == 0)
    {
        std::cerr << "连接失败! " << strerror(errno) << std::endl;
        close(sock_);
        exit(CONNECT_ERR);
    }

    // 连接成功
    std::cout << "连接成功!" << std::endl;

    // 进行业务处理
    // Service();
}  

当然相应的错误码也得添加

err.hpp 错误码头文件

cpp 复制代码
#pragma once

enum
{
    USAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    CONNECT_ERR
};

现在先不启动服务器,编译并启动客户端,模拟连接失败的情况

如果在数秒之后启动再服务器,可以看到重连成功

这种重连机制在实际中非常常见,出现这种

1.7.2.业务处理

客户端在进行业务处理时,同样可以使用 readwrite 进行网络通信

Service() 业务处理函数 --- 位于 client.hpp 客户端头文件中的 TcpClient

cpp 复制代码
// 业务处理
void Service()
{
    char buff[1024];
    std::string who = server_ip_ + "-" + std::to_string(server_port_);
    while(true)
    {
        // 由用户输入信息
        std::string msg;
        std::cout << "Please Enter >> ";
        std::getline(std::cin, msg);

        // 发送信息给服务器
        write(sock_, msg.c_str(), msg.size());

        // 接收来自服务器的信息
        ssize_t n = read(sock_, buff, sizeof(buff) - 1);
        if(n > 0)
        {
            // 正常通信
            buff[n] = '\0';
            std::cout << "Client get: " << buff << " from " << who << std::endl;
        }
        else if(n == 0)
        {
            // 读取到文件末尾(服务器关闭了)
            std::cout << "Server " << who  << " quit!" << std::endl;
            close(sock_); // 关闭文件描述符
            break;
        }
        else
        {
            // 读取异常
            std::cerr << "Read Fail!" << strerror(errno) << std::endl;
            close(sock_); // 关闭文件描述符
            break;
        }
    }
}

至此整个 基于 TCP 协议的字符串回响程序 就完成了,下面来看看效果

可以看到,当客户端向服务器发起连接请求时,服务器可以识别并接受连接 ,双方建立连接关系后,可以正常进行通信;当客户端主动退出(断开连接),服务器也能感知到,并判断出是谁断开了连接

如果在通信过程中,服务器主动断开了连接,客户端也能感知到

如果我们此时立马重启服务器,会发现短期内无法再次启动服务(显示端口正在被占用),这是由于 TCP 协议断开连接时的特性导致的(正在处于 TIME_WAIT 状态),详细原因将会在后续博客中讲解


2.多进程版服务器

2.1.核心功能

对于之前编写的 字符串回响程序 来说,如果只有一个客户端进行连接并通信,是没有问题的,但如果有多个客户端发起连接请求,并尝试进行通信,服务器是无法应对的

原因在于 服务器是一个单进程版本,处理连接请求 和 业务处理 是串行化执行的,如果想处理下一个连接请求,需要把当前的业务处理完成

具体表现为下面这种情况

为什么客户端B会显示当前已经连接成功?
这是因为是客户端是主动发起连接请求的一方,在请求发出后,如果出现连接错误,客户端就认为已经连接成功了,但实际上服务器还没有处理这个连接请求

这显然是服务器的问题,处理连接请求业务处理 应该交给两个不同的执行流完成,可以使用多进程或者多线程解决,这里先采用多进程的方案

所以当前需要实现的网络程序核心功能为:当服务器成功处理连接请求后,fork 新建一个子进程,用于进行业务处理,原来的进程专注于处理连接请求

2.2.创建子进程

注:当前的版本的修改只涉及 StartServer() 函数

创建子进程使用 fork() 函数,它的返回值含义如下

  • ret == 0 表示创建子进程成功,接下来执行子进程的代码
  • ret > 0 表示创建子进程成功,接下来执行父进程的代码
  • ret < 0 表示创建子进程失败

子进程创建成功后,会继承父进程的文件描述符表,能轻而易举的获取客户端的 socket 套接字,从而进行网络通信

当然不止文件描述符表,得益于 写时拷贝 机制,子进程还会共享父进程的变量,当发生修改行为时,才会自己创建

注意: 当子进程取走客户端的 socket 套接字进行通信后,父进程需要将其关闭(因为它不需要了),避免文件描述符泄漏

StartServer() 服务器启动函数 --- 位于 server.hppTcpServer

cpp 复制代码
// 进程创建、等待所需要的头文件
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

// 启动服务器
void StartServer()
{
    while (!quit_)
    {
        // 1.处理连接请求
        struct sockaddr_in client;
        socklen_t len = sizeof(client);

        int sock = accept(listensock_, (struct sockaddr *)&client, &len);

        // 2.如果连接失败,继续尝试连接
        if (sock == -1)
        {
            std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
            continue;
        }

        // 连接成功,获取客户端信息
        std::string clientip = inet_ntoa(client.sin_addr);
        uint16_t clientport = ntohs(client.sin_port);

        std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

        // 3.创建子进程
        pid_t id = fork();

        if(id < 0)
        {
            // 创建子进程失败,暂时不与当前客户端建立通信会话
            close(sock);
            std::cerr << "Fork Fail!" << std::endl;
        }
        else if(id == 0)
        {
            // 子进程内
            close(listensock_); // 子进程不需要监听(建议关闭)

            // 执行业务处理函数
            Service(sock, clientip, clientport);

            exit(0); // 子进程退出
        }
        else
        {
            // 父进程需要等待子进程
            pid_t ret = waitpid(id, nullptr, 0); // 默认为阻塞式等待

            if(ret == id)
                std::cout << "Wait " << id << " success!";
        }

    }
}

虽然此时成功创建了子进程,但父进程(处理连接请求)仍然需要等待子进程退出后,才能继续运行,说白了就是 父进程现在处于阻塞等待状态 ,需要设置为 非阻塞等待

2.3.设置非阻塞

设置父进程为非阻塞的方式有很多,这里来一一列举

方式一:通过参数设置为非阻塞等待(不推荐)

可以直接给 waitpid() 函数的参数3传递 WNOHANG,表示当前为 非阻塞等待

详见 《Linux进程控制【创建、终止、等待】

cpp 复制代码
pid_t ret = waitpid(id, nullptr, WNOHANG); // 设置为非阻塞式等待


这种方法可行,但不推荐,原因如下:虽然设置成了非阻塞式等待,但父进程终究是需要通过 waitpid() 函数来尝试等待子进程,倘若父进程一直卡在 accept() 函数处,会导致子进程退出后暂时无人收尸,进而导致资源泄漏


方式二:忽略 SIGCHLD 信号(推荐使用)

这是一个子进程在结束后发出的信号,默认动作是什么都不做;父进程需要检测并回收子进程,我们可以直接忽略该信号,这里的忽略是个特例,只是父进程不对其进行处理,转而由 操作系统 对其负责,自动清理资源并进行回收,不会产生 僵尸进程

详见 《Linux进程信号【信号处理】

直接在 StartServer() 服务器启动函数刚开始时,使用 signal() 函数设置 SIGCHLD 信号的执行动作为 忽略

忽略了该信号后,就不需要父进程等待子进程退出了(由操作系统承担)

cpp 复制代码
#include <signal.h> // 信号处理相关头文件

// 启动服务器
void StartServer()
{
    // 忽略 SIGCHLD 信号
    signal(SIGCHLD, SIG_IGN);
    
    while (!quit_)
    {
       	// ...
       	
        // 3.创建子进程
        pid_t id = fork();

        if(id < 0)
        {
            // 创建子进程失败,暂时不与当前客户端建立通信会话
            close(sock);
            std::cerr << "Fork Fail!" << std::endl;
        }
        else if(id == 0)
        {
            // 子进程内
            close(listensock_); // 子进程不需要监听(建议关闭)

            // 执行业务处理函数
            Service(sock, clientip, clientport);

            exit(0); // 子进程退出
        }

    }
}

强烈推荐使用该方案,因为操作简单,并且没有后患之忧


方式三:设置 SIGCHLD 信号的处理动作为子进程回收(不是很推荐)

当子进程退出并发送该信号时,执行父进程回收子进程的操作

详见 《Linux进程信号【信号处理】

设置 SIGCHLD 信号的处理动作为 回收子进程后,父进程同样不必再考虑回收子进程的问题

注意: 因为现在处于 TcpServer 类中,handler() 函数需要设置为静态(避免隐含的 this 指针),避免不符合 signal() 函数中信号处理函数的参数要求

cpp 复制代码
#include <signal.h> // 信号处理相关头文件

// 需要设置为静态
static void handler(int signo)
{
    printf("进程 %d 捕捉到了 %d 号信号\n", getpid(), signo);

    // 这里的 -1 表示父进程等待时,只要是已经退出了的子进程,都可以进行回收
    while (1)
    {
        pid_t ret = waitpid(-1, NULL, WNOHANG);
        if (ret > 0)
            printf("父进程: %d 已经成功回收了 %d 号进程\n", getpid(), ret);
        else
            break;
    }
    printf("子进程回收成功\n");
}

// 启动服务器
void StartServer()
{
    // 设置 SIGCHLD 信号的处理动作
    signal(SIGCHLD, handler);
    
    while (!quit_)
    {
       	// ...
       	
        // 3.创建子进程
        pid_t id = fork();

        if(id < 0)
        {
            // 创建子进程失败,暂时不与当前客户端建立通信会话
            close(sock);
            std::cerr << "Fork Fail!" << std::endl;
        }
        else if(id == 0)
        {
            // 子进程内
            close(listensock_); // 子进程不需要监听(建议关闭)

            // 执行业务处理函数
            Service(sock, clientip, clientport);

            exit(0); // 子进程退出
        }

    }
}


为什么不是很推荐这种方法?因为这种方法实现起来比较麻烦,不如直接忽略 SIGCHLD 信号


方式四:设置孙子进程(不是很推荐)

众所周知,父进程只需要对子进程负责,至于孙子进程交给子进程负责,如果某个子进程的父进程终止运行了,那么它就会变成 孤儿进程 ,父进程会变成 1 号进程,也就是由操作系统领养,回收进程的重担也交给了操作系统

可以利用该特性,在子进程内部再创建一个子进程(孙子进程),然后子进程退出,父进程可以直接回收(不必阻塞),子进程(孙子进程)的父进程变成 1 号进程

这种实现方法比较巧妙,而且与我们后面即将学到的 守护进程 有关

注意: 使用这种方式时,父进程是需要等待子进程退出的

cpp 复制代码
// 启动服务器
void StartServer()
{
    while (!quit_)
    {
   		// ...
   		
        // 3.创建子进程
        pid_t id = fork();

        if(id < 0)
        {
            // 创建子进程失败,暂时不与当前客户端建立通信会话
            close(sock);
            std::cerr << "Fork Fail!" << std::endl;
        }
        else if(id == 0)
        {
            // 子进程内
            close(listensock_); // 子进程不需要监听(建议关闭)

            // 再创建孙子进程
            if(fork() > 0)
                exit(0); // 子进程退出

            // 执行业务处理函数
            Service(sock, clientip, clientport);

            exit(0); // 子进程退出
        }
        else
        {
            // 父进程需要等待子进程
            pid_t ret = waitpid(id, nullptr, 0);

            if(ret == id)
                std::cout << "Wait " << id << " success!";
        }

    }
}


这种方法代码也很简单,但依旧不推荐,因为倘若连接请求变多,会导致孤儿进程变多,孤儿进程由操作系统接管,数量变多会给操作系统带来负担

以上就是设置 非阻塞 的四种方式,推荐使用方式二:忽略 SIGCHLD 信号

至此我们的 字符串回响程序 可以支持多客户端了



细节补充:当子进程取走 sock 套接字进行网络通信后,父进程就不需要使用 sock 套接字了,可以将其进行关闭,下次连接时继续使用,避免文件描述符不断增长

StartServer() 服务器启动函数 --- 位于 server.hpp 服务器头文件中的 TcpServer

cpp 复制代码
// 启动服务器
void StartServer()
{
    // 忽略 SIGCHLD 信号
    signal(SIGCHLD, SIG_IGN);

    while (!quit_)
    {
        // 1.处理连接请求
        // ...

        // 2.如果连接失败,继续尝试连接
        // ...

        // 连接成功,获取客户端信息
        // ...

        // 3.创建子进程
        // ...

        close(sock); // 父进程不再需要资源(建议关闭)
    }
}

这个补丁可以减少资源消耗,建议加上,前面是忘记加了,并且不太好修改,server.hpp 服务器头文件完整代码如下

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <signal.h> // 信号处理相关头文件
#include <sys/wait.h> // 进程等待时需要包含该头文件
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32;             // 全连接队列的最大长度

    using func_t = std::function<std::string(std::string)>; // 回调函数类型

    class TcpServer
    {
    public:
        TcpServer(const func_t &func, const uint16_t port = default_port)
            : func_(func), port_(port), quit_(false)
        {
        }

        ~TcpServer()
        {
        }

        // 初始化服务器
        void InitServer()
        {
            // 1.创建监听套接字
            listensock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (listensock_ == -1)
            {
                std::cerr << "Create ListenSocket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create ListenSocket Success! " << listensock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);

            if (bind(listensock_, (const sockaddr *)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 3.监听
            if (listen(listensock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {
            // 忽略 SIGCHLD 信号
            signal(SIGCHLD, SIG_IGN);

            while (!quit_)
            {
                // 1.处理连接请求
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int sock = accept(listensock_, (struct sockaddr *)&client, &len);

                // 2.如果连接失败,继续尝试连接
                if (sock == -1)
                {
                    std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
                    continue;
                }

                // 连接成功,获取客户端信息
                std::string clientip = inet_ntoa(client.sin_addr);
                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

                // 3.创建子进程
                pid_t id = fork();

                if(id < 0)
                {
                    // 创建子进程失败,暂时不与当前客户端建立通信会话
                    close(sock);
                    std::cerr << "Fork Fail!" << std::endl;
                }
                else if(id == 0)
                {
                    // 子进程内
                    close(listensock_); // 子进程不需要监听(建议关闭)

                    // 执行业务处理函数
                    Service(sock, clientip, clientport);

                    exit(0); // 子进程退出
                }

                close(sock); // 父进程不再需要资源(必须关闭)
            }
        }

        // 业务处理
        void Service(int sock, const std::string& clientip, const uint16_t& clientport)
        {
            char buff[1024];
            std::string who = clientip + "-" + std::to_string(clientport);
            while (true)
            {
                ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
                if (n > 0)
                {
                    // 读取成功
                    buff[n] = '\0';
                    std::cout << "Server get: " << buff << " from " << who << std::endl;

                    std::string respond = func_(buff); // 实际业务处理由上层指定

                    // 发送给服务器
                    write(sock, buff, strlen(buff));
                }
                else if (n == 0)
                {
                    // 表示当前读取到文件末尾了,结束读取
                    std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
                else
                {
                    // 读取出问题(暂时)
                    std::cerr << "Read Fail!" << strerror(errno) << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
            }
        }

    private:
        int listensock_; // 监听套接字
        uint16_t port_;  // 端口号
        bool quit_;      // 判断服务器是否结束运行
        func_t func_;    // 回调函数
    };
}

3.多线程版服务器

3.1.核心功能

通过多线程,实现支持多客户端同时通信的服务器

核心功能:服务器与客户端成功连接后,创建一个线程,服务于客户端的业务处理

这里先通过 原生线程库 模拟实现

3.2.使用原生线程库

关于 原生线程库 中对于线程的操作可以看看这篇文章《Linux多线程【线程控制】

线程的回调函数中需要 Service() 业务处理函数中的所有参数,同时也需要具备访问 Service() 业务处理函数的能力,单凭一个 void* 的参数是无法解决的,为此可以创建一个类,里面可以包含我们所需要的参数

ThreadData 类 --- 位于 server.hpp 服务器头文件中

cpp 复制代码
// 包含我们所需参数的类型
class ThreadData
{
public:
    ThreadData(int sock, const std::string& ip, const uint16_t& port, TcpServer* ptr)
        :sock_(sock), clientip_(ip), clientport_(port), current_(ptr)
    {}

// 设置为公有是为了方便访问
public:
    int sock_;
    std::string clientip_;
    uint16_t clientport_;
    TcpServer* current_; // 指向 TcpServer 对象的指针
};

接下来就可以考虑如何借助多线程了

线程创建后,需要关闭不必要的 socket 套接字吗?

  • 不需要,线程之间是可以共享这些资源的,无需关闭

如何设置主线程不必等待次线程退出?

  • 可以把次线程进行分离

所以接下来我们需要在连接成功后,创建次线程,利用已有信息构建 ThreadData 对象,为次线程编写回调函数(最终目的是为了执行 Service() 业务处理函数)

注意: 因为当前在类中,线程的回调函数需要使用 static 设置为静态函数

server.hpp 服务器头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <pthread.h> // 原生线程库
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32;             // 全连接队列的最大长度

    using func_t = std::function<std::string(std::string)>; // 回调函数类型

    class TcpServer; // 前置声明

    // 包含我们所需参数的类型
    class ThreadData
    {
    public:
        ThreadData(int sock, const std::string& ip, const uint16_t& port, TcpServer* ptr)
            :sock_(sock), clientip_(ip), clientport_(port), current_(ptr)
        {}

    // 设置为公有是为了方便访问
    public:
        int sock_;
        std::string clientip_;
        uint16_t clientport_;
        TcpServer* current_; // 指向 TcpServer 对象的指针
    };

    class TcpServer
    {
    public:
        TcpServer(const func_t &func, const uint16_t port = default_port)
            : func_(func), port_(port), quit_(false)
        {
        }

        ~TcpServer()
        {
        }

        // 初始化服务器
        void InitServer()
        {
            // 1.创建监听套接字
            listensock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (listensock_ == -1)
            {
                std::cerr << "Create ListenSocket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create ListenSocket Success! " << listensock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);

            if (bind(listensock_, (const sockaddr *)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 3.监听
            if (listen(listensock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {
            while (!quit_)
            {
                // 1.处理连接请求
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int sock = accept(listensock_, (struct sockaddr *)&client, &len);

                // 2.如果连接失败,继续尝试连接
                if (sock == -1)
                {
                    std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
                    continue;
                }

                // 连接成功,获取客户端信息
                std::string clientip = inet_ntoa(client.sin_addr);
                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

                // 3.创建线程及所需要的线程信息类
                ThreadData* td = new ThreadData(sock, clientip, clientport, this);
                pthread_t p;
                pthread_create(&p, nullptr, Routine, td);
            }
        }

        // 线程回调函数
        static void* Routine(void* args)
        {
            // 线程分离
            pthread_detach(pthread_self());

            ThreadData* td = static_cast<ThreadData*>(args);

            // 调用业务处理函数
            td->current_->Service(td->sock_, td->clientip_, td->clientport_);

            // 销毁对象
            delete td;
        }

        // 业务处理
        void Service(int sock, const std::string& clientip, const uint16_t& clientport)
        {
            char buff[1024];
            std::string who = clientip + "-" + std::to_string(clientport);
            while (true)
            {
                ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
                if (n > 0)
                {
                    // 读取成功
                    buff[n] = '\0';
                    std::cout << "Server get: " << buff << " from " << who << std::endl;

                    std::string respond = func_(buff); // 实际业务处理由上层指定

                    // 发送给服务器
                    write(sock, buff, strlen(buff));
                }
                else if (n == 0)
                {
                    // 表示当前读取到文件末尾了,结束读取
                    std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
                else
                {
                    // 读取出问题(暂时)
                    std::cerr << "Read Fail!" << strerror(errno) << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
            }
        }

    private:
        int listensock_; // 监听套接字
        uint16_t port_;  // 端口号
        bool quit_;      // 判断服务器是否结束运行
        func_t func_;    // 回调函数
    };
}

因为当前使用了 原生线程库 ,所以在编译时,需要加上 -lpthread

Makefile 文件

bash 复制代码
.PHONY:all
all:server client

server:server.cc
	g++ -o $@ $^ -std=c++11 -lpthread

	
client:client.cc
	g++ -o $@ $^ -std=c++11 -lpthread

.PHONY:clean
clean:
	rm -rf server client

接下来就是编译并运行程序,可以看到 当前只有一个进程,同时有五个线程在运行


使用 原生线程库 过于单薄了,并且这种方式存在问题:连接都准备好了,才创建线程,如果创建线程所需要的资源较多,会拖慢服务器整体连接效率

为此可以改用之前实现的 线程池

3.3.使用线程池

之前在 《Linux多线程【线程池】》一文中实现了多个版本的线程池,这里我们直接使用最终版,也就是 单例模式版线程池

部分组件不需要修改,代码如下:

ThreadPool.hpp 线程池头文件

cpp 复制代码
#pragma once

#include <vector>
#include <string>
#include <memory>
#include <functional>
#include <unistd.h>
#include <pthread.h>
#include "Task.hpp"
#include "Thread.hpp"
#include "BlockingQueue.hpp" // CP模型

namespace Yohifo
{
#define THREAD_NUM 10

    template<class T>
    class ThreadPool
    {
    private:
        ThreadPool(int num = THREAD_NUM)
            :_num(num)
        {
        }

        ~ThreadPool()
        {
            // 等待线程退出
            for(auto &t : _threads)
                t.join();
        }

        // 删除拷贝构造
        ThreadPool(const ThreadPool<T> &) = delete;

    public:
        static ThreadPool<T>* getInstance()
        {
            // 双检查
            if(_inst == nullptr)
            {
                // 加锁
                LockGuard lock(&_mtx);
                if(_inst == nullptr)
                {
                    // 创建对象
                    _inst = new ThreadPool<T>();

                    // 初始化及启动服务
                    _inst->init();
                    _inst->start();
                }
            }

            return _inst;
        }

    public:
        void init()
        {
            // 创建一批线程
            for(int i = 0; i < _num; i++)
                _threads.push_back(Thread(i, threadRoutine, this));
        }

        void start()
        {
            // 启动线程
            for(auto &t : _threads)
                t.run();
        }

        // 提供给线程的回调函数(已修改返回类型为 void)
        static void threadRoutine(void *args)
        {
            // 避免等待线程,直接剥离
            pthread_detach(pthread_self());

            auto ptr = static_cast<ThreadPool<T>*>(args);

            while (true)
            {
                // 从CP模型中获取任务
                T task = ptr->popTask();

                task(); // 回调函数
            }
        }

        // 装载任务
        void pushTask(const T& task)
        {
            _blockqueue.Push(task);
        }
    
    protected:
        T popTask()
        {
            T task;
            _blockqueue.Pop(&task);

            return task;
        }

    private:
        std::vector<Thread> _threads;
        int _num; // 线程数量
        BlockQueue<T> _blockqueue; // 阻塞队列

        // 创建静态单例对象指针及互斥锁
        static ThreadPool<T> *_inst;
        static pthread_mutex_t _mtx;
    };

    // 初始化指针
    template<class T>
    ThreadPool<T>* ThreadPool<T>::_inst = nullptr;

    // 初始化互斥锁
    template<class T>
    pthread_mutex_t ThreadPool<T>::_mtx = PTHREAD_MUTEX_INITIALIZER;
}

Thread.hpp 封装实现的线程库头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <pthread.h>

enum class Status
{
    NEW = 0, // 新建
    RUNNING, // 运行中
    EXIT // 已退出
};

// 参数、返回值为 void 的函数类型
typedef void (*func_t)(void*);

class Thread
{
public:
    Thread(int num = 0, func_t func = nullptr, void* args = nullptr)
        :_tid(0), _status(Status::NEW), _func(func), _args(args)
    {
        // 根据编号写入名字
        char name[128];
        snprintf(name, sizeof name, "thread-%d", num);
        _name = name;
    }

    ~Thread()
    {}

    // 获取 ID
    pthread_t getTID() const
    {
        return _tid;
    }

    // 获取线程名
    std::string getName() const
    {
        return _name;
    }

    // 获取状态
    Status getStatus() const
    {
        return _status;
    }

    // 回调方法
    static void* runHelper(void* args)
    {
        Thread* myThis = static_cast<Thread*>(args);

        // 很简单,回调用户传进来的 func 函数即可
        myThis->_func(myThis->_args);
    }

    // 启动线程
    void run()
    {
        int ret = pthread_create(&_tid, nullptr, runHelper, this);
        if(ret != 0)
        {
            std::cerr << "create thread fail!" << std::endl;
            exit(1); // 创建线程失败,直接退出
        }
        _status =  Status::RUNNING; // 更改状态为 运行中
    }

    // 线程等待
    void join()
    {
        int ret = pthread_join(_tid, nullptr);
        if(ret != 0)
        {
            std::cerr << "thread join fail!" << std::endl;
            exit(1); // 等待失败,直接退出
        }
        _status = Status::EXIT; // 更改状态为 退出
    }

private:
    pthread_t _tid; // 线程 ID
    std::string _name; // 线程名
    Status _status; // 线程状态
    func_t _func; // 线程回调函数
    void* _args; // 传递给回调函数的参数
};

BlockingQueue.hpp 生产者消费者模型头文件

cpp 复制代码
#pragma once

#include <queue>
#include <mutex>
#include <pthread.h>
#include "LockGuard.hpp"

// 命名空间,避免冲突
namespace Yohifo
{
#define DEF_SIZE 10

    template<class T>
    class BlockQueue
    {
    public:
        BlockQueue(size_t cap = DEF_SIZE)
            :_cap(cap)
        {
            // 初始化锁与条件变量
            pthread_mutex_init(&_mtx, nullptr);
            pthread_cond_init(&_pro_cond, nullptr);
            pthread_cond_init(&_con_cond, nullptr);
        }

        ~BlockQueue()
        {
            // 销毁锁与条件变量
            pthread_mutex_destroy(&_mtx);
            pthread_cond_destroy(&_pro_cond);
            pthread_cond_destroy(&_con_cond);
        }

        // 生产数据(入队)
        void Push(const T& inData)
        {
            // 加锁(RAII风格)
            LockGuard lock(&_mtx);

            // 循环判断条件是否满足
            while(IsFull())
            {
                pthread_cond_wait(&_pro_cond, &_mtx);
            }

            _queue.push(inData);

            // 可以加策略唤醒,比如生产一半才唤醒消费者
            pthread_cond_signal(&_con_cond);

            // 自动解锁

        }

        // 消费数据(出队)
        void Pop(T* outData)
        {
            // 加锁(RAII 风格)
            LockGuard lock(&_mtx);

            // 循环判读条件是否满足
            while(IsEmpty())
            {
                pthread_cond_wait(&_con_cond, &_mtx);
            }

            *outData = _queue.front();
            _queue.pop();

            // 可以加策略唤醒,比如消费完后才唤醒生产者
            pthread_cond_signal(&_pro_cond);

            // 自动解锁
        }

    private:
        // 判断是否为满
        bool IsFull()
        {
            return _queue.size() == _cap;
        }
        
        // 判断是否为空
        bool IsEmpty()
        {
            return _queue.empty();
        }

    private:
        std::queue<T> _queue;
        size_t _cap; // 阻塞队列的容量
        pthread_mutex_t _mtx; // 互斥锁
        pthread_cond_t _pro_cond; // 生产者条件变量
        pthread_cond_t _con_cond; // 消费者条件变量
    };
}

LockGuard.hpp 自动化锁头文件

cpp 复制代码
#pragma once

#include <pthread.h>

class LockGuard
{
public:
    LockGuard(pthread_mutex_t*pmtx)
        :_pmtx(pmtx)
    {
        // 加锁
        pthread_mutex_lock(_pmtx);
    }

    ~LockGuard()
    {
        // 解锁
        pthread_mutex_unlock(_pmtx);
    }

private:
    pthread_mutex_t* _pmtx;
};

现在需要修改 Task.hpp 任务头文件中的 Task 任务类,将其修改为一个服务于 网络通信中业务处理 的任务类(也就是 Service() 业务处理函数)

Service() 业务处理函数中,需要包含 socket 套接字、客户端 IP、客户端端口号 等必备信息,除此之外,我们还可以将 可调用对象(Service() 业务处理函数) 作为参数传递给 Task 对象

Task.hpp 任务类

cpp 复制代码
#pragma once

#include <string>
#include <functional>

namespace Yohifo
{
    // Service() 业务处理函数的类型
    using cb_t = std::function<void(int, std::string, uint16_t)>;

    class Task
    {
    public:
        // 可以再提供一个默认构造(防止部分场景中构建对象失败)
        Task()
        {}

        Task(int sock, const std::string& ip, const uint16_t& port, const cb_t& cb)
            :sock_(sock), ip_(ip), port_(port), cb_(cb)
        {}

        // 重载运算操作,用于回调 [业务处理函数]
        void operator()()
        {
            // 直接回调 cb [业务处理函数] 即可
            cb_(sock_, ip_, port_);
        }

    private:
        int sock_;
        std::string ip_;
        uint16_t port_;
        cb_t cb_; // 回调函数
    };
}

准备工作完成后,接下来就是往 server.hpp 服务器头文件中添加组件了

注意:

  • 在构建 Task 对象时,需要使用 bind 绑定类内函数,避免参数不匹配
  • 当前的线程池是单例模式,在 Task 任务对象构建后,通过线程池操作句柄 push 对象即可

其实也就是在 StartServer.hpp 中增加了这两句代码

cpp 复制代码
// 3.构建任务对象 注意:使用 bind 绑定 this 指针
Yohifo::Task t(sock, clientip, clientport, std::bind(&TcpServer::Service, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));

// 4.通过线程池操作句柄,将任务对象 push 进线程池中处理
Yohifo::ThreadPool<Yohifo::Task>::getInstance()->pushTask(t);

完整的服务器代码如下

server.hpp 服务器头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <functional>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"
#include "ThreadPool.hpp" // 线程池
#include "Task.hpp" // 任务类

namespace nt_server
{
    const uint16_t default_port = 8888; // 默认端口号
    const int backlog = 32;             // 全连接队列的最大长度

    using func_t = std::function<std::string(std::string)>; // 回调函数类型

    class TcpServer; // 前置声明

    // 包含我们所需参数的类型
    class ThreadData
    {
    public:
        ThreadData(int sock, const std::string& ip, const uint16_t& port, TcpServer* ptr)
            :sock_(sock), clientip_(ip), clientport_(port), current_(ptr)
        {}

    // 设置为公有是为了方便访问
    public:
        int sock_;
        std::string clientip_;
        uint16_t clientport_;
        TcpServer* current_; // 指向 TcpServer 对象的指针
    };

    class TcpServer
    {
    public:
        TcpServer(const func_t &func, const uint16_t port = default_port)
            : func_(func), port_(port), quit_(false)
        {
        }

        ~TcpServer()
        {
        }

        // 初始化服务器
        void InitServer()
        {
            // 1.创建监听套接字
            listensock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (listensock_ == -1)
            {
                std::cerr << "Create ListenSocket Fail!" << strerror(errno) << std::endl;
                exit(SOCKET_ERR);
            }

            std::cout << "Create ListenSocket Success! " << listensock_ << std::endl;

            // 2.绑定IP地址与端口号
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local)); // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY; // 绑定任意可用IP地址
            local.sin_port = htons(port_);

            if (bind(listensock_, (const sockaddr *)&local, sizeof(local)))
            {
                std::cout << "Bind IP&&Port Fail: " << strerror(errno) << std::endl;
                exit(BIND_ERR);
            }

            // 3.监听
            if (listen(listensock_, backlog) == -1)
            {
                std::cerr << "Listen Fail!" << strerror(errno) << std::endl;
                exit(LISTEN_ERR);
            }

            std::cout << "Listen Success!" << std::endl;
        }

        // 启动服务器
        void StartServer()
        {
            while (!quit_)
            {
                // 1.处理连接请求
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int sock = accept(listensock_, (struct sockaddr *)&client, &len);

                // 2.如果连接失败,继续尝试连接
                if (sock == -1)
                {
                    std::cerr << "Accept Fail!" << strerror(errno) << std::endl;
                    continue;
                }

                // 连接成功,获取客户端信息
                std::string clientip = inet_ntoa(client.sin_addr);
                uint16_t clientport = ntohs(client.sin_port);

                std::cout << "Server accept " << clientip + "-" << clientport << " " << sock << " from " << listensock_ << " success!" << std::endl;

                // 3.构建任务对象 注意:使用 bind 绑定 this 指针
                Yohifo::Task t(sock, clientip, clientport, std::bind(&TcpServer::Service, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));

                // 4.通过线程池操作句柄,将任务对象 push 进线程池中处理
                Yohifo::ThreadPool<Yohifo::Task>::getInstance()->pushTask(t);
            }
        }

        // 业务处理
        void Service(int sock, const std::string& clientip, const uint16_t& clientport)
        {
            char buff[1024];
            std::string who = clientip + "-" + std::to_string(clientport);
            while (true)
            {
                ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
                if (n > 0)
                {
                    // 读取成功
                    buff[n] = '\0';
                    std::cout << "Server get: " << buff << " from " << who << std::endl;

                    std::string respond = func_(buff); // 实际业务处理由上层指定

                    // 发送给服务器
                    write(sock, buff, strlen(buff));
                }
                else if (n == 0)
                {
                    // 表示当前读取到文件末尾了,结束读取
                    std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
                else
                {
                    // 读取出问题(暂时)
                    std::cerr << "Read Fail!" << strerror(errno) << std::endl;
                    close(sock); // 关闭文件描述符
                    break;
                }
            }
        }

    private:
        int listensock_; // 监听套接字
        uint16_t port_;  // 端口号
        bool quit_;      // 判断服务器是否结束运行
        func_t func_;    // 回调函数
    };
}

接下来编译并运行程序,当服务器启动后(此时无客户端连接),只有一个线程,这是因为我们当前的 线程池 是基于 懒汉模式 实现的,只有当第一次使用时,才会创建线程

接下来启动客户端,可以看到确实创建了一批次线程(十个)

当然可以支持多客户端同时通信

看似程序已经很完善了,其实隐含着一个大问题:当前线程池中的线程,本质上是在回调一个 while(true) 死循环函数,当连接的客户端大于线程池中的最大线程数时,会导致所有线程始终处于满负载状态,直接影响就是连接成功后,无法再创建通信会话(倘若客户端不断开连接,线程池中的线程就无力处理其他客户端的会话)

说白了就是 线程池 比较适合用于处理短任务,对于当前的场景来说,线程池 不适合建立持久通信会话,应该将其用于处理 read 读取、write 写入 任务

如果想解决这个问题,有两个方向:Service() 函数中支持一次 [收 / 发],或者多线程+线程池,多线程用于构建通信会话,线程池则用于处理 [收 / 发] 任务

前者实现起来比较简单,无非就是把 Service() 业务处理函数中的 while(true) 循环去掉

Service() 业务处理函数

cpp 复制代码
// 业务处理
void Service(int sock, const std::string &clientip, const uint16_t &clientport)
{
    char buff[1024];
    std::string who = clientip + "-" + std::to_string(clientport);
    ssize_t n = read(sock, buff, sizeof(buff) - 1); // 预留 '\0' 的位置
    if (n > 0)
    {
        // 读取成功
        buff[n] = '\0';
        std::cout << "Server get: " << buff << " from " << who << std::endl;

        std::string respond = func_(buff); // 实际业务处理由上层指定

        // 发送给服务器
        write(sock, buff, strlen(buff));
    }
    else if (n == 0)
    {
        // 表示当前读取到文件末尾了,结束读取
        std::cout << "Client " << who << " " << sock << " quit!" << std::endl;
        close(sock); // 关闭文件描述符
    }
    else
    {
        // 读取出问题(暂时)
        std::cerr << "Read Fail!" << strerror(errno) << std::endl;
        close(sock); // 关闭文件描述符
    }
}

至于后者就比较麻烦了,需要结合 高级IO 相关知识,这里不再阐述


4.日志输出

4.1.日志的重要性

在之前的编程经历中,如果我们的程序运行出现了问题,都是通过 标准输出标准错误错误信息 直接输出到屏幕上,debug 阶段这样使用没啥问题,但如果出错的是一个不断在运行中的服务,那问题就大了,因为服务器是不间断运行中,直接将 错误信息 输出到屏幕上,会导致错误排查变得极为困难

将各种 错误信息 组织管理,就形成了日志,日志有属于自己的格式(包括时间、文件名及行号、错误等级等),利于排查问题

所以接下来我们将会实现一个简易版日志器,用于定向输出我们的日志信息

4.2.可变参数

日志需要我们指定格式并输出,依赖于可变参数

在编写简易版日志器之前,需要先认识一下 C语言 中有关可变参数的使用,主要包括这几个

c 复制代码
#include <stdarg.h>

va_list	// 指向可变参数列表的指针

va_start()	// 将指针指向起始地址

va_arg()	// 根据类型,提取可变参数列表中的参数

va_end()	// 将指针置为空 

关于 可变参数 更多知识详见 《【C语言】可变参数列表

比如我们可以通过 可变参数 实现参数遍历

cpp 复制代码
#include <stdio.h>
#include <stdarg.h>

void foreach(int format, ...)
{
    va_list p;
    va_start(p, format);

    // 接下来就是获取其中的每一个参数
    for(int i = 0; i < format; i++)
        printf("%d ", va_arg(p, int));
    printf("\n");

    // 置空
    va_end(p);
}

int main()
{
    foreach(5, 1,2,3,4,5);
    return 0;
}

这种依靠自己动手的方法比较麻烦,我们也可以借助标准库提供的 vsnprintf() 函数进行参数解析

4.3.日志器实现

日志是有等级的,一般分为五级:

  1. Debug 用于调试
  2. Info 提示信息
  3. Warning 警告
  4. Errorr 错误
  5. Fatal 致命错误

错误等级越高,代表影响越大

当然难免有不明确的错误,可以再添加一级:UnKnow 未知错误

cpp 复制代码
// 日志等级
enum
{
    Debug = 0,
    Info,
    Warning,
    Error,
    Fatal
};

string getLevel(int level)
{
    vector<string> vs = {"<Debug>", "<Info>", "<Warning>", "<Error>", "<Fatal>", "<Unknown>"};
    
    //避免非法情况
    if(level < 0 || level >= vs.size() - 1)
        return vs[vs.size() - 1];
    
    return vs[level];
}

接下来是获取时间信息,可以通过 time() 函数获取当前时间戳,然后再利用 localtime() 函数构建 struct tm 结构体对象,这个对象会将时间戳解析成 年月日 时分秒 等详细信息,直接获取即可

strcut tm 结构体的信息如下,细节:年份已经 -1900 了,使用时需要加上 1900;月份从 0 开始,使用时需要 +1

cpp 复制代码
/* Used by other time functions.  */
struct tm
{
  int tm_sec;			/* Seconds.	[0-60] (1 leap second) */
  int tm_min;			/* Minutes.	[0-59] */
  int tm_hour;			/* Hours.	[0-23] */
  int tm_mday;			/* Day.		[1-31] */
  int tm_mon;			/* Month.	[0-11] */
  int tm_year;			/* Year	- 1900.  */
  int tm_wday;			/* Day of week.	[0-6] */
  int tm_yday;			/* Days in year.[0-365]	*/
  int tm_isdst;			/* DST.		[-1/0/1]*/

# ifdef	__USE_BSD
  long int tm_gmtoff;		/* Seconds east of UTC.  */
  const char *tm_zone;		/* Timezone abbreviation.  */
# else
  long int __tm_gmtoff;		/* Seconds east of UTC.  */
  const char *__tm_zone;	/* Timezone abbreviation.  */
# endif
};

可以这样获取当前时间

cpp 复制代码
// 获取当前时间
string getTime()
{
    time_t t = time(nullptr);   //获取时间戳
    struct tm *st = localtime(&t);    //获取时间相关的结构体

    char buff[128];
    snprintf(buff, sizeof(buff), "%d-%d-%d %d:%d:%d", st->tm_year + 1900, st->tm_mon + 1, st->tm_mday, st->tm_hour, st->tm_min, st->tm_sec);

    return buff;
}

接下来就是获取进程 PID,这个简单,直接使用 getpid() 函数获取即可,最后是解析参数,需要用到 vsnprintf() 函数,只要传入缓冲区和 va_list 指针,该函数就可以自动解析出参数,并存入缓冲区中

cpp 复制代码
void logMessage(int level, const char* format, ...)
{
    //截获主体消息
    char msgbuff[1024];
    va_list p;
    va_start(p, format);    //将 p 定位至 format 的起始位置
    vsnprintf(msgbuff, sizeof(msgbuff), format, p); //自动根据格式进行读取
    va_end(p);
}

接下来就是将 日志等级 时间 PID参数 进行拼接,形成日志

log.hpp 日志头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <time.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdarg.h>

using namespace std;

enum
{
    Debug = 0,
    Info,
    Warning,
    Error,
    Fatal
};

string getLevel(int level)
{
    vector<string> vs = {"<Debug>", "<Info>", "<Warning>", "<Error>", "<Fatal>", "<Unknown>"};
    
    //避免非法情况
    if(level < 0 || level >= vs.size() - 1)
        return vs[vs.size() - 1];
    
    return vs[level];
}

string getTime()
{
    time_t t = time(nullptr);   //获取时间戳
    struct tm *st = localtime(&t);    //获取时间相关的结构体

    char buff[128];
    snprintf(buff, sizeof(buff), "%d-%d-%d %d:%d:%d", st->tm_year + 1900, st->tm_mon + 1, st->tm_mday, st->tm_hour, st->tm_min, st->tm_sec);

    return buff;
}

//处理信息
void logMessage(int level, const char* format, ...)
{
    //日志格式:<日志等级> [时间] [PID] {消息体}
    string logmsg = getLevel(level);    //获取日志等级
    logmsg += " " + getTime();  //获取时间
    logmsg += " [" + to_string(getpid()) + "]";    //获取进程PID

    //截获主体消息
    char msgbuff[1024];
    va_list p;
    va_start(p, format);    //将 p 定位至 format 的起始位置
    vsnprintf(msgbuff, sizeof(msgbuff), format, p); //自动根据格式进行读取
    va_end(p);

    logmsg += " {" + string(msgbuff) + "}";    //获取主体消息

    printf("%s\n", logmsg);

} 

为什么日志消息最后还是向屏幕输出?这样组织日志消息的好处是什么?
因为现在还在测试阶段,等测试完成后,可以将日志消息存入文件中,做到持久化存储;至于统一组织的好处不言而喻,能够确保每条日志消息都包含必要信息,便于排查错误

简单测试的效果如下

4.4.应用于程序中

接下来可以包含 log.hpp 这个日志器头文件,并进行日志输出了,比如先将 client.hpp 客户端头文件中的错误信息日志化(代码少一些,比较好改)

client.hpp 客户端头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "err.hpp"
#include "log.hpp"

namespace nt_client
{
    class TcpClient
    {
    public:
        TcpClient(const std::string& ip, const uint16_t port)
            :server_ip_(ip), server_port_(port)
        {}

        ~TcpClient()
        {}

        // 初始化客户端
        void InitClient()
        {
            // 创建套接字
            sock_ = socket(AF_INET, SOCK_STREAM, 0);
            if (sock_ == -1)
            {
                logMessage(Fatal, "Create Socket Fail! %s", strerror(errno));
                exit(SOCKET_ERR);
            }

            logMessage(Debug, "Create Sock Succeess! %d", sock_);
        }

        // 启动客户端
        void StartClient()
        {
            // 填充服务器的 sockaddr_in 结构体信息
            struct sockaddr_in server;
            socklen_t len = sizeof(server);
            memset(&server, 0, len);
            server.sin_family = AF_INET;
            inet_aton(server_ip_.c_str(), &server.sin_addr); // 将点分十进制转化为二进制IP地址的另一种方法
            server.sin_port = htons(server_port_);

            // 尝试重连 5 次
            int n = 5;
            while(n)
            {
                int ret = connect(sock_, (const struct sockaddr*)&server, len);

                if(ret == 0)
                {
                    // 连接成功,可以跳出循环
                    break;
                }

                // 尝试进行重连
                logMessage(Warning, "网络异常,正在进行重连... 剩余连接次数: %d", --n);
                sleep(1);
            }

            // 如果剩余重连次数为 0,证明连接失败
            if(n == 0)
            {
                logMessage(Fatal, "连接失败! %s", strerror(errno));
                close(sock_);
                exit(CONNECT_ERR);
            }

            // 连接成功
            logMessage(Info, "连接成功!");

            // 进行业务处理
            Service();
        }

        // 业务处理
        void Service()
        {
            char buff[1024];
            std::string who = server_ip_ + "-" + std::to_string(server_port_);
            while(true)
            {
                // 由用户输入信息
                std::string msg;
                std::cout << "Please Enter >> ";
                std::getline(std::cin, msg);

                // 发送信息给服务器
                write(sock_, msg.c_str(), msg.size());

                // 接收来自服务器的信息
                ssize_t n = read(sock_, buff, sizeof(buff) - 1);
                if(n > 0)
                {
                    // 正常通信
                    buff[n] = '\0';
                    std::cout << "Client get: " << buff << " from " << who << std::endl;
                }
                else if(n == 0)
                {
                    // 读取到文件末尾(服务器关闭了)
                    logMessage(Error, "Server %s quit! %s", who.c_str(), strerror(errno));
                    close(sock_); // 关闭文件描述符
                    break;
                }
                else
                {
                    // 读取异常
                    logMessage(Error, "Read Fail! %s", strerror(errno));
                    close(sock_); // 关闭文件描述符
                    break;
                }
            }
        }
        
    private:
        int sock_; // 套接字
        std::string server_ip_; // 服务器IP
        uint16_t server_port_; // 服务器端口号
    };
}

效果就是这个样子,至于代码中其他输出错误的地方,都可以采用 简易版日志器 进行统一输出

改造完成的程序长这个样子

4.5.持久化存储

所谓持久化存储就是将日志消息输出至文件中,修改 log.hpp 中的代码即可

  • 指定日志文件存放路径
  • 打开文件,将日志消息追加至文件中

注意: 当前的改动中并未涉及目录创建,所以需要手动创建相关目录

log.hpp 日志头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <time.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdarg.h>

using namespace std;

enum
{
    Debug = 0,
    Info,
    Warning,
    Error,
    Fatal
};

static const string file_name = "log/TcpServer.log";

string getLevel(int level)
{
    vector<string> vs = {"<Debug>", "<Info>", "<Warning>", "<Error>", "<Fatal>", "<Unknown>"};
    
    //避免非法情况
    if(level < 0 || level >= vs.size() - 1)
        return vs[vs.size() - 1];
    
    return vs[level];
}

string getTime()
{
    time_t t = time(nullptr);   //获取时间戳
    struct tm *st = localtime(&t);    //获取时间相关的结构体

    char buff[128];
    snprintf(buff, sizeof(buff), "%d-%d-%d %d:%d:%d", st->tm_year + 1900, st->tm_mon + 1, st->tm_mday, st->tm_hour, st->tm_min, st->tm_sec);

    return buff;
}

//处理信息
void logMessage(int level, const char* format, ...)
{
    //日志格式:<日志等级> [时间] [PID] {消息体}
    string logmsg = getLevel(level);    //获取日志等级
    logmsg += " " + getTime();  //获取时间
    logmsg += " [" + to_string(getpid()) + "]";    //获取进程PID

    //截获主体消息
    char msgbuff[1024];
    va_list p;
    va_start(p, format);    //将 p 定位至 format 的起始位置
    vsnprintf(msgbuff, sizeof(msgbuff), format, p); //自动根据格式进行读取
    va_end(p);

    logmsg += " {" + string(msgbuff) + "}";    //获取主体消息

    //持久化。写入文件中
    FILE* fp = fopen(file_name.c_str(), "a");   //以追加的方式写入
    if(fp == nullptr) return;   //不太可能出错

    fprintf(fp, "%s\n", logmsg.c_str());
    fflush(fp); //手动刷新一下
    fclose(fp);
    fp = nullptr;
} 



5.守护进程

5.1.会话、进程组、进程

接下来进入本文中的最后一个小节: 守护进程

守护进程 的意思就是让进程不间断的在后台运行,即便是 bash 关闭了,也能照旧运行。守护进程 就是现实生活中的服务器,因为服务器是需要 24H 不间断运行的

当前我们的程序在启动后属于 前台进程前台进程 是由 bash 进程替换而来的,因此会导致 bash 暂时无法使用

如果在启动程序时,带上 & 符号,程序就会变成 后台进程后台进程 并不会与 bash 进程冲突,bash 仍然可以使用

后台进程 也可以实现服务器不间断运行,但问题在于 如果当前 bash 关闭了,那么运行中的后台进程也会被关闭 ,最好的解决方案是使用 守护进程

在正式学习 守护进程 之前,需要先了解一组概念:会话、进程组、进程

分别运行一批 前台、后台进程,并通过指令查看进程运行情况

bash 复制代码
sleep 1000 | sleep 2000 | sleep 3000 &

sleep 100 | sleep 200 | sleep 300

ps -ajx | head -1 && ps -ajx | grep sleep | grep -v grep

其中 会话 <-> SID、进程组 <-> PGID、进程 <-> PID ,显然,sleep 1000、2000、3000 处于同一个管道中(有血缘关系),属于同一个 进程组 ,所以他们的 PGID 都是一样的,都是 4261;至于 sleep 100、200、300 属于另一个 进程组PGID4308;再仔细观察可以发现 每一组的进程组 PGID 都与当前组中第一个被创建的进程 PID 一致,这个进程被称为 组长进程

会话 >= 进程组 >= 进程

无论是 后台进程 还是 前台进程 ,都是从同一个 bash 中启动的,所以它们处于同一个 会话 中,SID 都是 1939,并且关联的 终端文件 TTY 都是 pts/1

Linux 中一切皆文件,终端文件也是如此,这里的终端其实就是当前 bash 输出结果时使用的文件(也就是屏幕),终端文件位于 dev/pts 目录下,如果向指定终端文件中写入数据,那么对方也可以直接收到

(关联终端文件说白了就是打开了文件,一方写,一方读,不就是管道吗)

根据当前的 会话 SID 查找目标进程,发现这玩意就是 bash 进程,bash 进程本质上就是一个不断运行中的 前台进程 ,并且自成 进程组

在同一个 bash 中启动前台、后台进程,它们的 SID 都是一样的,属于同一个 会话 ,关联了同一个 终端SID 其实就是 bashPID

我们使用 XShell 等工具登录 Linux 服务器时,会在服务器中创建一个 会话bash),可以在该会话内创建 进程 ,当 进程 间有关系时,构成一个 进程组组长 进程的 PID 就是该 进程组PGID

Linux 中的登录操作实际上就是创建了一个会话,Windows 中也是如此,当你的 Windows 变卡时,可以使用 [注销] 按钮结束整个会话,重新登录,电脑就会流畅如初


在同一个会话中,只允许一个前台进程在运行,默认是 bash,如果其他进程运行了,bash 就会变成后台进程(暂时无法使用),让出前台进程这个位置(后台进程与前台进程之前是可以进程切换)

如何将一个 后台进程 变成 前台进程

首先通过指令查看当前 会话 中正在运行的 后台进程 ,获取 任务号

bash 复制代码
jobs

接下来通过 任务号后台进程 变成 前台进程 ,此时 bash 就无法使用了

bash 复制代码
fg 1

那如何将 前台进程 变成 后台进程

首先是通过 ctrl + z 发送 19SIGSTOP 信号,暂停正在运行中的 前台进程

bash 复制代码
键盘输入 ctrl + z

然后通过 任务号 ,可以把暂停中的进程变成 后台进程

bash 复制代码
bg 1

5.2.守护进程化

一般网络服务器为了不受到用户登录重启的影响,会以 守护进程 的形式运行,有了上面那一批前置知识后,就可以很好的理解 守护进程 的本质了

守护进程:进程单独成一个会话,并且以后台进程的形式运行

说白了就是让服务器不间断运行,可以直接使用 daemon() 函数完成 守护进程化

cpp 复制代码
#include <unistd.h>

int daemon(int nochdir, int noclose);

参数解读:

  1. nochdir 改变进程的工作路径
  2. noclose 重定向标准输入、标准输出、标准错误

返回值:成功返回 0,失败返回 -1

一般情况下,daemon() 函数的两个参数都只需要传递 0默认工作在 / 路径下,默认重定向至 /dev/null

/dev/null 就像是一个 黑洞,可以把所有数据都丢入其中,相当于丢弃数据

使用 damon() 函数使之前的server.cc 守护进程化

server.cc 服务器源文件

cpp 复制代码
#include <memory> // 智能指针头文件
#include <string>
#include <unistd.h>
#include "server.hpp"

using namespace std;
using namespace nt_server;

// 业务处理回调函数(字符串回响)
string echo(string request)
{
    return request;
}

int main()
{
    // 直接守护进程化
    daemon(0, 0);
    
    unique_ptr<TcpServer> usvr (new TcpServer(echo)); // 将回调函数进行传递

    usvr->InitServer();
    usvr->StartServer();
    return 0;
}

现在服务器启动后,会自动变成 后台进程 ,并且自成一个 新会话 ,归操作系统管(守护进程 本质上是一种比较坚强的 孤儿进程

注意: 现在标准输出、标准错误都被重定向至 /dev/null 中了,之前向屏幕输出的数据,现在都会直接被丢弃,如果想保存数据,可以选择使用日志

如果想终止 守护进程 ,需要通过 kill pid 杀死目标进程

使用系统提供的接口一键 守护进程化 固然方便,不过大多数程序员都会选择手动 守护进程化(可以根据自己的需求定制操作)

原理是 使用 setsid() 函数新设一个会话,谁调用,会话 SID 就是谁的,成为一个新的会话后,不会被之前的会话影响

cpp 复制代码
#include <unistd.h>

pid_t setsid(void);

返回值:成功返回该进程的 pid,失败返回 -1

注意: 调用该函数的进程,不能是组长进程,需要创建子进程后调用

手动实现守护进程时需要注意以下几点:

  1. 忽略异常信号
  2. 0、1、2 要做特殊处理(文件描述符)
  3. 进程的工作路径可能要改变(从用户目录中脱离至根目录)

具体实现步骤如下:

1、忽略常见的异常信号:SIGPIPESIGCHLD

2、如何保证自己不是组长? 创建子进程 ,成功后父进程退出,子进程变成守护进程

3、新建会话,自己成为会话的 话首进程

4、(可选)更改守护进程的工作路径:chdir

5、处理后续对于 0、1、2 的问题

对于 标准输入、标准输出、标准错误 的处理方式有两种

暴力处理:直接关闭 fd

优雅处理:fd 重定向至 /dev/null,也就是 daemon() 函数的做法

这里我们选择后者,守护进程 的函数实现如下

Daemon.hpp 守护进程头文件

cpp 复制代码
#pragma once

#include <iostream>
#include <cstring>
#include <cerrno>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "err.hpp"
#include "log.hpp"

static const char *path = "/home/Yohifo";

void Daemon()
{
    // 1、忽略常见信号
    signal(SIGPIPE, SIG_IGN);
    signal(SIGCHLD, SIG_IGN);

    // 2、创建子进程,自己退休
    pid_t id = fork();
    if (id > 0)
        exit(0);
    else if (id < 0)
    {
        // 子进程创建失败
        logMessage(Error, "Fork Fail: %s", strerror(errno));
        exit(FORK_ERR);
    }

    // 3、新建会话,使自己成为一个单独的组
    pid_t ret = setsid();
    if (ret == -1)
    {
        // 守护化失败
        logMessage(Error, "Setsid Fail: %s", strerror(errno));
        exit(SETSID_ERR);
    }

    // 4、更改工作路径
    int n = chdir(path);
    if (n == -1)
    {
        // 更改路径失败
        logMessage(Error, "Chdir Fail: %s", strerror(errno));
        exit(CHDIR_ERR);
    }

    // 5、重定向标准输入输出错误
    int fd = open("/dev/null", O_RDWR);
    if (fd == -1)
    {
        // 文件打开失败
        logMessage(Error, "Open Fail: %s", strerror(errno));
        exit(OPEN_ERR);
    }

	// 重定向标准输入、标准输出、标准错误
    dup2(fd, 0);
    dup2(fd, 1);
    dup2(fd, 2);

    close(fd);
}

当然相应的错误码也需要更新

err.hpp 错误码头文件

cpp 复制代码
#pragma once

enum
{
    USAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    CONNECT_ERR,
    FORK_ERR,
    SETSID_ERR,
    CHDIR_ERR,
    OPEN_ERR
};

接下来就是在服务启动成功后,将其 守护进程化

StartServer() 服务器启动函数 --- 位于 server.hpp 服务器头文件中的 TcpServer

cpp 复制代码
#include "myDaemon.hpp"

// 启动服务器
void StartServer()
{
    // 守护进程化
    Daemon();
	
	// ...
}

现在服务器在启动后,会自动新建会话,以 守护进程 的形式运行

关于 inet_ntoa 函数的返回值(该函数的作用是将四字节的 IP 地址转化为点分十进制的 IP 地址)
inet_ntoa 返回值为 char*,转化后的 IP 地址存储在静态区,二次调用会覆盖上一次的结果,多线程场景中不是线程安全的

  • 不过在 CentOS 7 及更高版本中,接口进行了更新,新增了互斥锁,多线程场景中测试没问题

6.完整代码

下面是不同版本服务器的完整代码

「朴素版,支持单客户端连接」

「多进程版,支持多客户端连接」

「多线程版(原生线程库),支持多客户端连接」

「多线程版(线程池),支持多客户端连接」

「日志版,支持简易日志输出」

「守护进程版,支持服务部署」


🌨️总结

以上是关于『简易TCP网络程序』的全部内容,作为上一篇博客的延伸,本文重新实现了字符串回响网络程序,基于TCP协议逐步改造并引入多进程、多线程、线程池、日志输出、守护进程等技术。这使得网络程序更为成熟,为后续网络和高级IO的学习提供了有力支持。同时,对套接字编程的重要性也得到了充分体现。希望本文能为读者在网络编程领域的深入学习提供实质性帮助。


相关文章推荐

网络编程『socket套接字 ‖ 简易UDP网络程序』

网络基础『发展 ‖ 协议 ‖ 传输 ‖ 地址』

相关推荐
无所谓จุ๊บ35 分钟前
树莓派开发相关知识十 -小试服务器
服务器·网络·树莓派
道法自然040243 分钟前
Ethernet 系列(8)-- 基础学习::ARP
网络·学习·智能路由器
EasyCVR2 小时前
萤石设备视频接入平台EasyCVR多品牌摄像机视频平台海康ehome平台(ISUP)接入EasyCVR不在线如何排查?
运维·服务器·网络·人工智能·ffmpeg·音视频
城南vision2 小时前
计算机网络——HTTP篇
网络协议·计算机网络·http
明月看潮生2 小时前
青少年编程与数学 02-003 Go语言网络编程 15课题、Go语言URL编程
开发语言·网络·青少年编程·golang·编程与数学
龙哥说跨境3 小时前
如何利用指纹浏览器爬虫绕过Cloudflare的防护?
服务器·网络·python·网络爬虫
懒大王就是我4 小时前
C语言网络编程 -- TCP/iP协议
c语言·网络·tcp/ip
Elaine2023914 小时前
06 网络编程基础
java·网络
海绵波波1075 小时前
Webserver(4.3)TCP通信实现
服务器·网络·tcp/ip
幺零九零零8 小时前
【计算机网络】TCP协议面试常考(一)
服务器·tcp/ip·计算机网络