简易TCP网络程序

目录

[1. TCP 和 UDP 的基本区别](#1. TCP 和 UDP 的基本区别)

[2. TCP 中的 listen、accept 和 connect](#2. TCP 中的 listen、accept 和 connect)

[3. UDP 中的区别:没有 listen、accept 和 connect](#3. UDP 中的区别:没有 listen、accept 和 connect)

[4. 总结对比:](#4. 总结对比:)

2.字符串回响

2.1.核心功能

[2.2 代码展示](#2.2 代码展示)

[1. server.hpp 服务器头文件](#1. server.hpp 服务器头文件)

[2. server.cpp 服务器源文件](#2. server.cpp 服务器源文件)

[3. client.hpp 客户端头文件](#3. client.hpp 客户端头文件)

[4. client.cpp 客户端源文件](#4. client.cpp 客户端源文件)

[5. Makefile 文件](#5. Makefile 文件)

[6. 服务器端初始化代码](#6. 服务器端初始化代码)

[7. 服务器端业务逻辑代码](#7. 服务器端业务逻辑代码)

8.客户端代码实现

[3. 多进程版服务器实现](#3. 多进程版服务器实现)

[3.1 多进程版服务器的核心功能](#3.1 多进程版服务器的核心功能)

[1. 创建子进程处理连接](#1. 创建子进程处理连接)

[2. 服务器的工作流程](#2. 服务器的工作流程)

[3.2 创建子进程](#3.2 创建子进程)

[3.3 设置非阻塞](#3.3 设置非阻塞)

[4. 多线程版服务器](#4. 多线程版服务器)

[4.1 核心功能](#4.1 核心功能)

[4.2 使用原生线程库](#4.2 使用原生线程库)

5.守护进程

5.1.会话、进程组、进程

5.2.守护进程化


前言:

当我们使用 TCP 和 UDP 协议进行网络编程时,尽管都使用套接字(socket)进行通信,但它们之间存在一些重要的区别。特别是关于如何建立连接、如何处理客户端请求以及如何进行数据传输,TCP和UDP有着根本性的不同。以下是详细的对比,重点讨论 listen、accept 和 connect 等函数在 TCP 和 UDP 中的差异。

1. TCP 和 UDP 的基本区别

  • TCP(传输控制协议) 是面向连接的协议。在通信之前,客户端和服务器需要建立连接,确保可靠传输。

  • UDP(用户数据报协议) 是无连接的协议,不需要建立连接,也不保证数据传输的可靠性。每个数据包是独立的,不需要在发送前确认目标是否可达。

2. TCP 中的 listen、accept 和 connect
  • listen :

    在 TCP 中,listen 函数是用来将服务器端的套接字设置为"监听状态",它等待客户端的连接请求。这个函数需要在创建套接字并绑定端口后调用。listen 通常传入一个参数(backlog),它表示服务器端能够排队的连接请求的数量。如果队列已满,新的连接请求会被拒绝。

    cpp 复制代码
    int listen(int sockfd, int backlog);
    • sockfd 是服务器端用于监听的套接字。

    • backlog 是连接请求的队列长度。

  • accept :
    accept 是 TCP 中用于接受客户端连接请求的函数。当客户端请求连接时,accept 函数会阻塞,直到有客户端请求到来,且成功建立连接。accept 返回一个新的套接字,这个新的套接字用于和客户端进行通信。

    cpp 复制代码
    int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
    • sockfd 是服务器端监听套接字。

    • addr 是指向客户端地址结构的指针(可以用来获取客户端的IP和端口)。

    • addrlen 是地址结构的大小。

  • connect :
    connect 是 TCP 中客户端用来请求与服务器建立连接的函数 。客户端使用 connect 函数连接到服务器的 IP 地址和端口,建立连接后,客户端就可以与服务器进行通信。此函数是阻塞的,直到连接成功或超时。

    cpp 复制代码
    int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
    • sockfd 是客户端的套接字。

    • addr 是服务器的地址信息(IP 和端口)。

    • addrlen 是地址结构的大小。

3. UDP 中的区别:没有 listen、accept 和 connect
  • listen 和 accept 在 UDP 中没有意义 :

    UDP 是无连接的,不需要等待连接或接受连接请求。每个数据包(数据报)都是独立的,发送方和接收方不需要在发送数据之前进行握手或连接确认。因此,UDP 协议中没有 listen 和 accept 函数。客户端可以直接使用 sendto 或 recvfrom 来发送和接收数据。

  • connect 在 UDP 中的作用 :

    虽然 UDP 是无连接的协议,但是在实际应用中,connect 也可以在 UDP 中使用,但它的作用与 TCP 不同。通过 connect,UDP 套接字可以绑定一个目标地址,这样就不需要每次发送数据时都指定目标地址了。connect 后,发送和接收数据时,默认的目标地址就是连接时指定的地址。

    cpp 复制代码
    int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
    • sockfd 是客户端的套接字。

    • addr 是服务器的地址信息。

    • addrlen 是地址结构的大小。

    但值得注意的是,UDP 套接字与 TCP 套接字不同,使用 connect 后,依然是无连接的,即发送和接收数据不需要三次握手和连接管理。

4. 总结对比:
函数 TCP UDP
listen 服务器使用,设置为监听状态,准备接收连接请求。 没有类似的功能,UDP 无连接。
accept 服务器使用,接收连接请求并返回一个新的套接字用于通信。 没有类似的功能,UDP 无连接。
connect 客户端使用,主动与服务器建立连接。 可用来指定目标地址,之后的通信会自动使用这个地址,但不需要建立连接。

2.字符串回响

2.1.核心功能

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

2.2 代码展示
1. server.hpp 服务器头文件
cpp 复制代码
#pragma once

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

namespace server_namespace
{
    const uint16_t DEFAULT_PORT = 8888; // 默认端口号

    class TCPServer
    {
    public:
        TCPServer(const uint16_t port = DEFAULT_PORT)
            : port_(port)
        {}

        ~TCPServer() {}

        void initializeServer();
        void runServer();

    private:
        int serverSocket_; // 套接字
        uint16_t port_;    // 端口号
    };
}
2. server.cpp 服务器源文件
cpp 复制代码
#include <memory>
#include "server.hpp"

using namespace std;
using namespace server_namespace;

int main()
{
    unique_ptr<TCPServer> serverInstance(new TCPServer());
    serverInstance->initializeServer();
    serverInstance->runServer();
    return 0;
}
3. 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 "error_codes.hpp"

namespace client_namespace
{
    class TCPClient
    {
    public:
        TCPClient(const std::string& serverIP, const uint16_t port)
            : serverIP_(serverIP), serverPort_(port)
        {}

        ~TCPClient() {}

        void initializeClient();
        void startClient();

    private:
        int clientSocket_; // 套接字
        std::string serverIP_; // 服务器IP地址
        uint16_t serverPort_; // 服务器端口号
    };
}
4. client.cpp 客户端源文件
cpp 复制代码
#include <memory>
#include "client.hpp"

using namespace std;
using namespace client_namespace;

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

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        showUsage(argv[0]);
        return USAGE_ERROR;
    }

    string ip(argv[1]);
    uint16_t port = stoi(argv[2]);

    unique_ptr<TCPClient> clientInstance(new TCPClient(ip, port));
    clientInstance->initializeClient();
    clientInstance->startClient();

    return 0;
}
5. Makefile 文件
cpp 复制代码
.PHONY: all
all: server client

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

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

.PHONY: clean
clean:
	rm -rf server client
6. 服务器端初始化代码
cpp 复制代码
void TCPServer::initializeServer()
{
    // 创建套接字
    serverSocket_ = socket(AF_INET, SOCK_STREAM, 0);
    if (serverSocket_ == -1)
    {
        std::cerr << "Socket creation failed: " << strerror(errno) << std::endl;
        exit(SOCKET_ERROR);
    }

    std::cout << "Socket created successfully: " << serverSocket_ << std::endl;

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

    if (bind(serverSocket_, (const sockaddr*)&serverAddr, sizeof(serverAddr)) < 0)
    {
        std::cerr << "Binding IP and Port failed: " << strerror(errno) << std::endl;
        exit(BIND_ERROR);
    }

    // 监听连接
    if (listen(serverSocket_, 32) < 0)
    {
        std::cerr << "Listen failed: " << strerror(errno) << std::endl;
        exit(LISTEN_ERROR);
    }

    std::cout << "Server is now listening on port " << port_ << std::endl;
}
7. 服务器端业务逻辑代码
cpp 复制代码
void TCPServer::runServer()
{
    while (true)
    {
        // 接受客户端连接请求
        struct sockaddr_in clientAddr;
        socklen_t clientLen = sizeof(clientAddr);
        int clientSocket = accept(serverSocket_, (struct sockaddr*)&clientAddr, &clientLen);

        if (clientSocket < 0)
        {
            std::cerr << "Accept failed: " << strerror(errno) << std::endl;
            continue;
        }

        std::string clientIP = inet_ntoa(clientAddr.sin_addr);
        uint16_t clientPort = ntohs(clientAddr.sin_port);
        std::cout << "Accepted connection from " << clientIP << ":" << clientPort << std::endl;

        // 处理客户端请求
        handleClientRequest(clientSocket, clientIP, clientPort);
    }
}

void TCPServer::handleClientRequest(int clientSocket, const std::string& clientIP, uint16_t clientPort)
{
    char buffer[1024];
    std::string clientInfo = clientIP + ":" + std::to_string(clientPort);

    while (true)
    {
        ssize_t bytesRead = read(clientSocket, buffer, sizeof(buffer) - 1);
        if (bytesRead > 0)
        {
            buffer[bytesRead] = '\0';
            std::cout << "Received from " << clientInfo << ": " << buffer << std::endl;
            write(clientSocket, buffer, bytesRead);  // 回显
        }
        else if (bytesRead == 0)
        {
            std::cout << "Client " << clientInfo << " disconnected" << std::endl;
            close(clientSocket);
            break;
        }
        else
        {
            std::cerr << "Read failed: " << strerror(errno) << std::endl;
            close(clientSocket);
            break;
        }
    }
}
8.客户端代码实现
cpp 复制代码
void TCPClient::initializeClient()
{
    clientSocket_ = socket(AF_INET, SOCK_STREAM, 0);
    if (clientSocket_ == -1)
    {
        std::cerr << "Socket creation failed: " << strerror(errno) << std::endl;
        exit(SOCKET_ERROR);
    }

    std::cout << "Client socket created successfully: " << clientSocket_ << std::endl;
}

void TCPClient::startClient()
{
    struct sockaddr_in serverAddr;
    memset(&serverAddr, 0, sizeof(serverAddr)); // 清零
    serverAddr.sin_family = AF_INET;
    inet_aton(serverIP_.c_str(), &serverAddr.sin_addr);
    serverAddr.sin_port = htons(serverPort_);

    if (connect(clientSocket_, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) < 0)
    {
        std::cerr << "Connection failed: " << strerror(errno) << std::endl;
        exit(CONNECT_ERROR);
    }

    std::cout << "Connected to server " << serverIP_ << ":" << serverPort_ << std::endl;
    
    // 通信部分
    char buffer[1024];
    while (true)
    {
        std::string msg;
        std::cout << "Enter message to send: ";
        std::getline(std::cin, msg);
        
        write(clientSocket_, msg.c_str(), msg.size());
        
        ssize_t bytesRead = read(clientSocket_, buffer, sizeof(buffer) - 1);
        if (bytesRead > 0)
        {
            buffer[bytesRead] = '\0';
            std::cout << "Received from server: " << buffer << std::endl;
        }
        else
        {
            std::cerr << "Connection closed or error in reading data!" << std::endl;
            close(clientSocket_);
            break;
        }
    }
}

3. 多进程版服务器实现

在之前的字符串回响程序中,如果只有一个客户端与服务器通信,程序是可以正常工作的。然而,如果有多个客户端发起连接请求,服务器就无法应对,因为服务器是单进程的,它只能处理一个客户端的请求,必须等待当前请求完成后才能处理下一个。这是由于服务器的处理是串行的。

为了处理多个客户端的连接请求,服务器需要能够同时处理多个连接。我们可以通过使用 多进程 或 多线程 来实现这一目标。在这里,我们采用 多进程 方案。具体来说,每当服务器成功处理一个连接请求后,它就会使用 fork() 创建一个子进程,负责与客户端的通信,而父进程继续监听其他客户端的连接请求。

3.1 多进程版服务器的核心功能
1. 创建子进程处理连接
  • 使用 fork() 创建子进程。

  • 父进程负责接受连接请求。

  • 子进程负责处理每个连接的业务逻辑。

2. 服务器的工作流程
  • 监听端口并接受连接请求。

  • 每当有新的客户端连接,父进程会通过 fork() 创建一个新的子进程处理该连接。

  • 子进程完成通信后退出,而父进程继续接收新的连接请求。

3.2 创建子进程

我们使用 fork() 函数来创建子进程。fork() 的返回值可以帮助我们区分父进程和子进程:

  • fork() 返回值为 0:表示当前是子进程,子进程将执行处理客户端请求的逻辑。

  • fork() 返回值大于 0:表示当前是父进程,父进程将继续处理其他客户端的连接请求。

  • fork() 返回值小于 0:表示子进程创建失败。

示例代码:创建子进程处理请求

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!";
        }
    }
}
3.3 设置非阻塞

在多进程模式下,父进程需要等待每个子进程的退出,这样会导致父进程阻塞在 waitpid() 函数上。为了避免这种情况,我们可以通过不同的方式设置父进程为非阻塞模式。

方式一:通过 WNOHANG 设置非阻塞等待

通过 waitpid() 的第三个参数 WNOHANG 来设置父进程非阻塞。

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

但是这种方式虽然能避免阻塞,但仍然存在资源泄漏的问题,因为父进程可能一直处于阻塞状态。

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

SIGCHLD 是子进程结束时向父进程发送的信号。我们可以通过在父进程中忽略 SIGCHLD 信号,让操作系统自动回收子进程,这样就不需要父进程等待子进程退出了。

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

// 启动服务器
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); // 父进程不再需要资源(必须关闭)
    }
}

此方法是推荐的,因为它简单且不会导致僵尸进程。

总结与优化

  1. 父进程与子进程的职责分离:

    • 父进程负责监听并接受连接请求。

    • 每当父进程接收到一个客户端的连接请求,它将创建一个子进程来处理与该客户端的通信。

    • 父进程不需要等待子进程退出,而是继续接收新的连接请求。

  2. 避免资源泄漏:

    • 子进程处理完客户端的请求后,应该尽快退出,避免资源泄漏。

    • 父进程应及时关闭不再使用的资源,避免文件描述符泄漏。

  3. 非阻塞等待机制:

    • 使用**SIGCHLD**信号忽略子进程的退出,可以避免父进程被阻塞,同时确保操作系统能够回收子进程的资源。

4. 多线程版服务器

4.1 核心功能

通过多线程,服务器能够同时处理多个客户端的请求。每当服务器与客户端成功建立连接时,服务器会创建一个线程,专门处理该客户端的业务逻辑。多线程方式相比多进程方式更高效,因为线程间共享内存资源,开销较小。

4.2 使用原生线程库

原生线程库提供了直接使用线程的方式,通过 pthread 库,我们可以创建、管理线程,并对其进行同步。

创建线程数据结构

为了在线程中执行业务处理函数,我们需要将连接的套接字、客户端IP和端口号等信息传递给线程。由于线程的回调函数只能接受一个 void* 类型的参数,我们可以创建一个 ThreadData类来保存这些信息。

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 对象的指针
};

线程回调函数

线程的回调函数需要是静态函数,因为它不可以访问非静态成员。我们可以使用 static void* Routine(void* args) 作为线程回调函数

cpp 复制代码
// 线程回调函数
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;  // 释放资源
}

服务器类的修改

在 StartServer() 中,我们通过**pthread_create**创建线程,每个线程处理一个连接请求。

cpp 复制代码
void StartServer() {
    while (!quit_) {
        // 1. 处理连接请求
        struct sockaddr_in client;
        socklen_t len = sizeof(client);

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

        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;

        // 创建线程数据并启动线程
        ThreadData* td = new ThreadData(sock, clientip, clientport, this);
        pthread_t p;
        pthread_create(&p, nullptr, Routine, td);  // 创建线程
    }
}

资源管理

通过 pthread_detach(),线程结束后会自动清理资源,避免造成内存泄漏。我们不需要显式地等待线程结束。

Makefile 修改

由于我们使用了**pthread** 库,编译时需要链接该库,添加**-lpthread**参数:

cpp 复制代码
.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
复制代码
完整代码示例:
cpp 复制代码
// server.hpp 服务器头文件
#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() {
            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;

            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));  // 清零
            local.sin_family = AF_INET;
            local.sin_addr.s_addr = INADDR_ANY;
            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);
            }

            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_) {
                struct sockaddr_in client;
                socklen_t len = sizeof(client);

                int sock = accept(listensock_, (struct sockaddr *)&client, &len);
                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;

                // 创建线程数据并启动线程
                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_;     // 回调函数
    };
}

多线程服务器的总结

  • 多线程处理:每个客户端连接由一个线程处理,线程间共享资源,可以提高服务器的并发能力。

  • 线程回调函数 :通过传递 ThreadData 对象给线程,在线程中执行实际的业务处理。

  • 线程分离 :使用 pthread_detach() 来确保线程结束时资源能够自动清理,避免造成资源泄漏。

通过以上多线程实现,我们的服务器能够高效地处理多个客户端的并发连接,并且能够充分利用 CPU 资源。


5.守护进程

5.1.会话、进程组、进程

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

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

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

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

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

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

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

cpp 复制代码
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 属于另一个 进程组,PGID 为 4308;再仔细观察可以发现 每一组的进程组 PGID 都与当前组中第一个被创建的进程 PID 一致,这个进程被称为 组长进程

会话 >= 进程组 >= 进程

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

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

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

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

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

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

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

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

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

cpp 复制代码
jobs

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

cpp 复制代码
fg 1

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

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

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

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

cpp 复制代码
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、忽略常见的异常信号:SIGPIPE、SIGCHLD

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);
}

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

关于 inet_ntoa 函数的返回值(该函数的作用是将四字节的 IP 地址转化为点分十进制的 IP 地址)

inet_ntoa 返回值为 char*,转化后的 IP 地址存储在静态区,二次调用会覆盖上一次的结果,多线程场景中不是线程安全的

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

相关推荐
夜听莺儿鸣3 小时前
502-003_Linux 中断与异常(一):从Linux角度理解中断与异常
linux·中断与异常·linux中断
杨云龙UP4 小时前
TDengine 3.4.2.8 Community 三节点三副本生产集群部署实战(DNode/MNode/taosAdapter/Explorer)
大数据·linux·运维·数据库·tdengine·时序库
梦帮科技4 小时前
领域认知知识库图谱注入:从双式记账图网络到高质量问答对自动化合成流水线
运维·网络·数据库·人工智能·矩阵·架构·自动化
向成科技5 小时前
XC3576H工控主板|深度适配Ubuntu 26.04 LTS,释放边缘AI与工业开发新潜能
linux·人工智能·ubuntu·机器人·硬件·主板·边缘ai
Fcy6485 小时前
Linux下 进程间关系与守护进程
linux·运维·服务器·守护进程
菜鸡养成中...6 小时前
卫星通信协议笔记1-协议栈
网络
码农小韩6 小时前
Linux驱动理论(二)——Linux字符设备驱动
linux·嵌入式软件开发·linux操作系统·linux应用开发·linux驱动理论
well06126 小时前
Linux粘滞位与Makefile机制深度解析
linux·运维·服务器
再写一行代码就下班6 小时前
linux sh脚本在windows修改导致无法使用解决方式
java·linux·centos
hzxpaipai6 小时前
杭州企业官网怎么建设?从策划到上线的5步建站方法
运维·服务器·前端·网络