Linux网络编程:TCP服务器的多进程与多线程实现

前言

上一篇实现了一个简单的 TCP Echo Server,不过测试的时候发现一个问题:服务器只能正常处理一个客户端。

原因也比较简单,服务器 accept 获取连接后,会直接进入 Service 函数处理当前客户端。由于 Service 内部存在循环,在当前客户端退出之前,服务器不会继续执行 accept。

那么有什么办法可以解决这个问题?

一个比较直接的思路就是:客户端连接成功后,不让服务器主流程一直处理这个客户端,而是把具体的通信工作交给其他执行单元。

这篇主要记录两种实现方式:多进程和多线程。

一、通过多进程处理多个客户端

先看多进程版本。

基本思路是:

  • 父进程负责接收客户端连接。
  • 每当有客户端连接,就创建一个子进程。
  • 子进程负责和客户端通信。
  • 父进程继续等待下一个客户端。

这样就不会因为某一个客户端一直占用 Service,而导致服务器无法接收新的连接。

1. 使用fork创建子进程

Linux 中可以通过 fork 创建子进程:

cpp 复制代码
pid_t id = fork();

if(id < 0)
{
    // 创建失败
}
else if(id == 0)
{
    // 子进程
}
else
{
    // 父进程
}

fork 的返回值需要注意:

  • 小于 0:创建进程失败。
  • 等于 0:当前执行的是子进程。
  • 大于 0:当前执行的是父进程,返回值是子进程的 PID。

在 TCP 服务器中,可以在 accept 成功后调用 fork。

2. 多进程版本的连接处理

核心代码如下:

cpp 复制代码
void ProcessConnection(
    int sockfd,
    struct sockaddr_in& peer)
{
    pid_t id = fork();

    if(id < 0)
    {
        close(sockfd);
        return;
    }
    else if(id == 0)
    {
        // child
        close(_listensock);

        if(fork() > 0)
            exit(0);

        InetAddr addr(peer);

        Service(sockfd);

        close(sockfd);
        exit(0);
    }
    else
    {
        close(sockfd);

        waitpid(id, nullptr, 0);
    }
}

这里的代码除了第一次 fork,还进行了第二次 fork。

为什么要这样做?

第一次 fork 后,父进程继续负责服务器主流程,子进程负责当前客户端。

子进程关闭监听套接字后再次 fork,原来的子进程退出,真正处理客户端通信的是第二次创建出来的进程,也就是孙子进程。

父进程通过 waitpid 回收第一次创建的子进程,避免产生僵尸进程。

而孙子进程继续执行 Service,处理客户端的请求。

这里需要注意文件描述符的关闭:

  • 父进程关闭 accept 返回的通信套接字。
  • 子进程关闭监听套接字。
  • 孙子进程负责处理通信,并在结束后关闭通信套接字。

这样父进程就能继续执行 accept,等待新的客户端。

3. 为什么需要InetAddr类?

多进程版本中,还引入了一个 InetAddr 类。

它主要用来封装客户端的网络地址信息。

cpp 复制代码
class InetAddr
{
public:
    InetAddr(struct sockaddr_in& addr)
        : _addr(addr)
    {
        _port = ntohs(_addr.sin_port);
        _ip = inet_ntoa(_addr.sin_addr);
    }

private:
    std::string _ip;
    uint16_t _port;
    struct sockaddr_in _addr;
};

这样就不需要每次都直接操作 sockaddr_in 结构体。

还可以提供一些接口:

cpp 复制代码
Ip();
Port();
PrintDebug();
GetAddr();

例如将客户端地址打印出来:

text 复制代码
127.0.0.1:4444

这样在服务器处理多个客户端时,就比较方便区分不同的连接。

二、通过多线程处理多个客户端

多进程可以解决问题,不过这里还可以换一种方式:使用多线程。

基本思路和多进程类似:

  • 主线程负责 accept。
  • 每次获取一个客户端连接,就创建一个线程。
  • 新线程负责处理当前客户端。
  • 主线程继续接收其他客户端。

这样同样可以实现多个客户端同时连接。

1. 使用ThreadData保存线程数据

线程启动时,需要知道自己应该处理哪个客户端。

所以这里定义了一个 ThreadData 类:

cpp 复制代码
class ThreadData
{
public:
    ThreadData(
        int sockfd,
        struct sockaddr_in addr)
        : _sockfd(sockfd), _addr(addr)
    {}

public:
    int _sockfd;
    InetAddr _addr;
};

这里保存了两个信息:

  • _sockfd:当前客户端对应的通信套接字。
  • _addr:当前客户端的网络地址。

这样每个线程都能拿到自己需要的数据。

2. 将Service交给线程执行

原来的 Service 函数主要负责读取客户端数据并返回 Echo 消息。

多线程版本中,将它改成静态成员函数:

cpp 复制代码
static void Service(ThreadData& td)
{
    char buffer[1024];

    while(true)
    {
        ssize_t n = read(
            td._sockfd,
            buffer,
            sizeof(buffer) - 1
        );

        if(n > 0)
        {
            buffer[n] = '\0';

            string echo_string = "server echo# ";
            echo_string += buffer;

            write(
                td._sockfd,
                echo_string.c_str(),
                echo_string.size()
            );
        }
        else if(n == 0)
        {
            break;
        }
        else
        {
            break;
        }
    }
}

这里使用静态函数,主要是为了方便通过 pthread 的线程入口函数调用。

3. 编写线程入口函数

pthread_create 要求线程入口函数具有规定的函数形式,因此需要再封装一个函数:

cpp 复制代码
static void* threadExcute(void* args)
{
    pthread_detach(pthread_self());

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

    TcpServer::Service(*td);

    close(td->_sockfd);

    delete td;

    return nullptr;
}

这里有几个地方需要注意。

第一,pthread_detach。

线程默认是可连接的,使用 detach 后,线程运行结束时,系统会自动回收对应的线程资源,不需要再通过 pthread_join 等待它。

第二,ThreadData 的生命周期。

这里通过 new 创建 ThreadData:

cpp 复制代码
ThreadData* td = new ThreadData(sockfd, peer);

因为线程是独立执行的,如果直接把一个局部对象的地址传进去,就可能在线程使用之前,对象已经销毁。

所以这里动态申请空间,并在线程处理结束后通过 delete 释放。

第三,关闭文件描述符。

线程处理完成后,需要关闭对应的通信套接字。

4. 创建线程处理客户端连接

有了线程入口函数之后,就可以在 ProcessConnection 中创建线程:

cpp 复制代码
void ProcessConnection(
    int sockfd,
    struct sockaddr_in& peer)
{
    pthread_t tid;

    ThreadData* td =
        new ThreadData(sockfd, peer);

    pthread_create(
        &tid,
        nullptr,
        threadExcute,
        (void*)td
    );
}

主线程只需要调用 ProcessConnection,就能将当前客户端交给新线程处理。

而主线程本身不需要等待这个线程执行完毕,可以继续 accept 下一个客户端。

三、多进程和多线程的整体流程

把两种方式放在一起看,思路其实很相似。

多进程版本:

text 复制代码
主进程
  |
  | accept
  |
  +---- 客户端1 -> 子进程处理
  |
  +---- 客户端2 -> 子进程处理
  |
  +---- 客户端3 -> 子进程处理

多线程版本:

text 复制代码
主线程
  |
  | accept
  |
  +---- 客户端1 -> 线程1处理
  |
  +---- 客户端2 -> 线程2处理
  |
  +---- 客户端3 -> 线程3处理

两种方式都是将连接接收和业务处理分开。

不过,这里的多线程版本仍然是一个客户端创建一个线程。后面还可以继续使用线程池改进这种设计。

四、总结

这篇主要解决了 TCP Echo Server 只能处理一个客户端的问题。

多进程版本使用 fork 创建子进程,通过父子进程分工,让父进程继续接收新的连接。

多线程版本则使用 pthread_create 创建线程,将每个客户端的通信任务交给独立线程处理。

其中几个知识点需要重点理解:

  1. fork():创建子进程。
  2. waitpid():等待并回收子进程。
  3. pthread_create():创建线程。
  4. pthread_detach():分离线程。
  5. ThreadData:保存每个客户端对应的通信信息。
  6. InetAddr:封装客户端的 IP 和端口信息。

从代码结构上看,这次改动并不算特别大,主要变化就是把原本由服务器主流程执行的 Service 交给了其他进程或线程。

下一篇继续在多线程版本的基础上增加业务功能,并尝试使用线程池处理客户端请求。

相关推荐
阡陌数智23 分钟前
Claude API新版本重大变更与安全升级指南
java·linux·安全
奶茶树36 分钟前
【Linux】环境开发工具(vim,gcc,g++,Makefile)
linux·运维·vim
一晌小贪欢1 小时前
Python基础:第 09 篇 · while 循环:重复直到不满足
开发语言·c++·python·数据可视化·面向对象·python办公
xiaoye-duck1 小时前
《C++ 项目:手写大模型接入 SDK》LLMManager架构设计与实现
开发语言·网络·c++·语言模型
say_fall2 小时前
【AI大模型】MCP 模型上下文协议:客户端-服务器架构、JSON-RPC 消息与能力协商、工具发现与调用链路、与函数调用的分工
服务器·人工智能·架构
蒸蒸yyyyzwd2 小时前
后端相关学习笔记。
笔记·学习
一条大祥脚2 小时前
CuTe 与 CuTe DSL:从 C++ 模板元编程到 Python DSL 的 GPU 编程演进
开发语言·c++·python·cutlass·cute·cute dsl
·present·2 小时前
CSAPP(lecture 13_02 Linking)
c语言·c++
andxe2 小时前
工业级 PoE 交换机选型指南:破解边缘部署四大核心难题
运维·网络·安全·光通信·交换机