Linux Socket TCP编程(二):逐文件重构多进程、多线程与线程池服务器,深入fork、pthread_create、popen和std::bind

Linux Socket TCP编程(二):逐文件重构多进程、多线程与线程池服务器,深入fork、pthread_create、popen和std::bind


🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 | 《C++从入门到入土》 | 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能


文章目录

  • [Linux Socket TCP编程(二):逐文件重构多进程、多线程与线程池服务器,深入fork、pthread_create、popen和std::bind](#Linux Socket TCP编程(二):逐文件重构多进程、多线程与线程池服务器,深入fork、pthread_create、popen和std::bind)
  • 一、先明确整个服务器重构的目标
    • [1.1 V1为什么只能串行服务?](#1.1 V1为什么只能串行服务?)
    • [1.2 我们希望改成什么样?](#1.2 我们希望改成什么样?)
    • [1.3 为什么保留前面设计的公共文件?](#1.3 为什么保留前面设计的公共文件?)
  • 二、V2:为什么多进程能够解决串行处理问题?
    • [2.1 从fork出发思考](#2.1 从fork出发思考)
    • [2.2 为什么fork以后子进程能直接使用connfd?](#2.2 为什么fork以后子进程能直接使用connfd?)
    • [2.3 父进程和子进程各自应该关闭什么?](#2.3 父进程和子进程各自应该关闭什么?)
  • 三、V2的第一个问题:子进程退出后怎么办?
    • [3.1 能不能直接waitpid?](#3.1 能不能直接waitpid?)
    • [3.2 因此引入双重fork](#3.2 因此引入双重fork)
    • [3.3 为什么原始父进程只需要等待中间子进程?](#3.3 为什么原始父进程只需要等待中间子进程?)
    • [3.4 双重fork有哪些局限?](#3.4 双重fork有哪些局限?)
  • 四、V2需要设计哪些文件?
    • [4.1 TcpServerV2的职责](#4.1 TcpServerV2的职责)
    • [4.2 需要设计的接口](#4.2 需要设计的接口)
  • 五、TcpServerV2.hpp完整实现
  • 六、为什么V2需要特别分析close?
    • [6.1 原始父进程](#6.1 原始父进程)
    • [6.2 中间子进程](#6.2 中间子进程)
    • [6.3 孙子进程](#6.3 孙子进程)
    • [6.4 为什么不直接exit?](#6.4 为什么不直接exit?)
    • [6.5 为什么双重fork以后仍需考虑资源消耗?](#6.5 为什么双重fork以后仍需考虑资源消耗?)
  • 七、ServerMainV2.cc完整实现
    • [7.1 怎样验证V2支持多个客户端?](#7.1 怎样验证V2支持多个客户端?)
    • [7.2 怎样观察进程?](#7.2 怎样观察进程?)
  • 八、V3:为什么还要使用多线程?
    • [8.1 线程与进程的区别](#8.1 线程与进程的区别)
    • [8.2 V3总体结构](#8.2 V3总体结构)
    • [8.3 V3中最重要的资源管理区别](#8.3 V3中最重要的资源管理区别)
  • 九、V3需要怎样设计接口?
    • [9.1 业务回调接口](#9.1 业务回调接口)
    • [9.2 为什么用std::function?](#9.2 为什么用std::function?)
    • [9.3 ThreadData需要保存什么?](#9.3 ThreadData需要保存什么?)
    • [9.4 为什么要在堆上创建ThreadData?](#9.4 为什么要在堆上创建ThreadData?)
    • [9.5 需要哪些类接口?](#9.5 需要哪些类接口?)
  • 十、TcpServerV3.hpp完整实现
  • 十一、逐步分析V3中的线程管理
    • [11.1 为什么使用unique_ptr管理ThreadData?](#11.1 为什么使用unique_ptr管理ThreadData?)
    • [11.2 为什么创建失败后仍需要delete?](#11.2 为什么创建失败后仍需要delete?)
    • [11.3 为什么要pthread_detach?](#11.3 为什么要pthread_detach?)
    • [11.4 为什么工作线程可以直接使用connfd?](#11.4 为什么工作线程可以直接使用connfd?)
    • [11.5 为什么主线程不能close(connfd)?](#11.5 为什么主线程不能close(connfd)?)
    • [11.6 分离线程有什么额外注意事项?](#11.6 分离线程有什么额外注意事项?)
  • 十二、ServerMainV3.cc完整实现
    • [12.1 怎样测试多线程服务器?](#12.1 怎样测试多线程服务器?)
    • [12.2 怎样观察线程?](#12.2 怎样观察线程?)
  • 十三、V3-1:为什么要让服务器执行不同业务?
  • 十四、设计Command.hpp
    • [14.1 文件功能](#14.1 文件功能)
    • [14.2 为什么需要白名单?](#14.2 为什么需要白名单?)
    • [14.3 需要设计哪些接口?](#14.3 需要设计哪些接口?)
  • 十五、popen()与pclose()的作用
    • [15.1 popen()](#15.1 popen())
    • [15.2 pclose()](#15.2 pclose())
    • [15.3 为什么当前代码还需要处理换行?](#15.3 为什么当前代码还需要处理换行?)
  • 十六、Command.hpp完整实现
    • [16.1 为什么使用map?](#16.1 为什么使用map?)
    • [16.2 为什么必须进行完整匹配?](#16.2 为什么必须进行完整匹配?)
    • [16.3 为什么限制输出长度?](#16.3 为什么限制输出长度?)
    • [16.4 popen执行的是不是当前服务器线程中的普通函数?](#16.4 popen执行的是不是当前服务器线程中的普通函数?)
  • 十七、CommandServerMain.cc完整实现
  • 十八、V4:为什么要引入线程池?
    • [18.1 线程池应该实现哪些功能?](#18.1 线程池应该实现哪些功能?)
    • [18.2 为什么可以复用之前学过的线程池?](#18.2 为什么可以复用之前学过的线程池?)
  • 十九、设计ThreadPool.hpp
    • [19.1 类成员](#19.1 类成员)
    • [19.2 类接口](#19.2 类接口)
  • 二十、ThreadPool.hpp完整实现
    • [20.1 为什么Worker中使用条件变量?](#20.1 为什么Worker中使用条件变量?)
    • [20.2 为什么执行任务时要释放互斥锁?](#20.2 为什么执行任务时要释放互斥锁?)
    • [20.3 为什么析构时join可能等待很久?](#20.3 为什么析构时join可能等待很久?)
  • 二十一、V4的TcpServer需要怎样设计?
    • [21.1 服务器职责](#21.1 服务器职责)
    • [21.2 为什么仍然需要TcpHandler?](#21.2 为什么仍然需要TcpHandler?)
    • [21.3 为什么Task应该是无参函数?](#21.3 为什么Task应该是无参函数?)
    • [21.4 为什么close要放进Task?](#21.4 为什么close要放进Task?)
  • 二十二、TcpServerV4.hpp完整实现
    • [22.1 为什么先声明_handler,再声明_pool?](#22.1 为什么先声明_handler,再声明_pool?)
    • [22.2 为什么这与线程池中的任务生命周期有关?](#22.2 为什么这与线程池中的任务生命周期有关?)
    • [22.3 当前设计是否支持优雅停机?](#22.3 当前设计是否支持优雅停机?)
  • 二十三、ServerMainV4.cc完整实现
  • 二十四、为什么线程池不等于无限并发?
    • [24.1 为什么会发生这种情况?](#24.1 为什么会发生这种情况?)
    • [24.2 线程池解决了什么问题?](#24.2 线程池解决了什么问题?)
  • 二十五、V4-1:怎样复用线程池服务器实现英译汉业务?
    • [25.1 业务需求](#25.1 业务需求)
    • [25.2 文件设计](#25.2 文件设计)
  • 二十六、设计Dict.hpp
    • [26.1 字典文件格式](#26.1 字典文件格式)
    • [26.2 为什么使用unordered_map?](#26.2 为什么使用unordered_map?)
    • [26.3 设计接口](#26.3 设计接口)
    • [26.4 完整实现](#26.4 完整实现)
    • [26.5 为什么Translate是const成员函数?](#26.5 为什么Translate是const成员函数?)
    • [26.6 为什么使用find而不是operator\[\]?](#26.6 为什么使用find而不是operator[]?)
  • 二十七、DictServerMain.cc完整实现
    • [27.1 为什么使用shared_ptr?](#27.1 为什么使用shared_ptr?)
    • [27.2 多个线程可以同时查询unordered_map吗?](#27.2 多个线程可以同时查询unordered_map吗?)
    • [27.3 为什么不需要修改TcpServerV4?](#27.3 为什么不需要修改TcpServerV4?)
  • 二十八、统一编译:Makefile
    • [28.1 Makefile要实现什么功能?](#28.1 Makefile要实现什么功能?)
    • [28.2 完整Makefile](#28.2 完整Makefile)
    • [28.3 编译](#28.3 编译)
  • 二十九、实验一:多进程服务器
  • 三十、实验二:多线程服务器
  • 三十一、实验三:线程池服务器
  • 三十二、实验四:TCP网络字典
  • 三十三、V1到V4到底改变了什么?
    • [33.1 V1的本质](#33.1 V1的本质)
    • [33.2 V2的本质](#33.2 V2的本质)
    • [33.3 V3的本质](#33.3 V3的本质)
    • [33.4 V4的本质](#33.4 V4的本质)
  • 三十四、再谈Socket所有权:为什么它比写出accept更重要?
    • [34.1 V2多进程](#34.1 V2多进程)
    • [34.2 V3多线程](#34.2 V3多线程)
    • [34.3 V4线程池](#34.3 V4线程池)
  • [三十六、回顾整个TCP Socket学习路线](#三十六、回顾整个TCP Socket学习路线)
  • 三十七、最终总结

前言:为什么服务器能够通信,还远远不够?

在上一篇中,我们按照完整的工程设计流程,实现了V1单进程TCP Echo Server。

整个项目被划分为:

text 复制代码
NoCopy.hpp
Comm.hpp
InetAddr.hpp
TcpIO.hpp
TcpService.hpp
TcpServer.hpp
ServerMain.cc
TcpClient.cc
Makefile

其中,TCP通信由:

cpp 复制代码
TcpServer

负责。

回显业务由:

cpp 复制代码
EchoService()

负责。

而TCP字节流的文本读写由:

cpp 复制代码
tcpio::ReadLine()
tcpio::WriteAll()

负责。

最后我们通过双客户端实验发现:

cpp 复制代码
ProcessConnection(connfd, peer);

如果直接在服务器主线程中执行,就可能导致:

第一个客户端始终占用服务器的主执行流,使后续客户端不能及时获得应用层服务。

所以现在需要重新设计的并不是:

cpp 复制代码
socket()
bind()
listen()
accept()

这些基础接口。

真正需要重新设计的是:

一条客户端连接交给谁处理,以及怎样管理相应的进程、线程和Socket资源。

本文将依次完成:

text 复制代码
V2:多进程TCP服务器
         ↓
V3:多线程TCP服务器
         ↓
V3-1:固定命令执行服务
         ↓
V4:线程池TCP服务器
         ↓
V4-1:复用线程池实现TCP网络字典

每个阶段都先分析目标和文件职责,然后设计接口,再给出完整源码,最后分析关键实现。


一、先明确整个服务器重构的目标

1.1 V1为什么只能串行服务?

V1的结构是:

text 复制代码
主线程
   |
   v
accept Client-A
   |
   v
EchoService(Client-A)
   |
   v
Client-A退出
   |
   v
accept Client-B

如果 Client-A 长期保持连接,服务器就可能一直阻塞在:

cpp 复制代码
read()

因此无法继续调用:

cpp 复制代码
accept()

1.2 我们希望改成什么样?

希望:

text 复制代码
主执行流:不断接受新连接

同时:

text 复制代码
其他执行流:处理已经接受的连接

例如:

text 复制代码
                  TCP Server
                      |
                      v
                   accept()
                      |
             +--------+--------+
             |        |        |
             v        v        v
          Client-A Client-B Client-C
             |        |        |
             v        v        v
          Service  Service  Service

可以通过三种方式逐步实现:

text 复制代码
多进程

多线程

线程池

1.3 为什么保留前面设计的公共文件?

因为本次要修改的是服务器并发模型,不是TCP回显业务。

所以第一篇中的:

text 复制代码
NoCopy.hpp

Comm.hpp

InetAddr.hpp

TcpIO.hpp

TcpService.hpp

TcpClient.cc

都可以继续使用。

特别是:

cpp 复制代码
EchoService(int connfd, const InetAddr& peer);

这个接口。

它负责:

text 复制代码
读取消息

处理消息

返回回显

但不主动关闭 connfd。

因此,我们可以根据不同服务器模型,单独决定Socket的关闭时机。


二、V2:为什么多进程能够解决串行处理问题?

2.1 从fork出发思考

前面已经学习:

cpp 复制代码
pid_t fork();

可以创建子进程。

如果服务器:

cpp 复制代码
accept()

得到一个客户端连接以后,执行:

cpp 复制代码
fork()

那么:

text 复制代码
父进程

可以继续接受新连接。

而:

text 复制代码
子进程

可以负责当前客户端的通信。

这样就形成:

text 复制代码
                 TCP Server
                     |
                     v
                  accept()
                     |
                     v
                   fork()
                     |
             +-------+-------+
             |               |
             v               v
           Parent          Child
             |               |
             v               v
        继续accept      EchoService()

2.2 为什么fork以后子进程能直接使用connfd?

假设在 fork() 之前:

text 复制代码
Parent:

fd=3 → 监听Socket

fd=4 → Client-A通信Socket

执行:

cpp 复制代码
fork();

以后,子进程会继承相应文件描述符。

从概念上可以表示为:

text 复制代码
Parent文件描述符表           Child文件描述符表

fd=3 ──┐                     fd=3 ──┐
       |                            |
       +------ 监听Socket ----------+

fd=4 ──┐                     fd=4 ──┐
       |                            |
       +------ 通信Socket ----------+

需要注意:

父子进程的文件描述符表是不同的,但继承的文件描述符可以引用相同的底层内核Socket资源。

因此,子进程无需重新:

cpp 复制代码
socket()
connect()

它可以直接:

cpp 复制代码
EchoService(connfd, peer);

2.3 父进程和子进程各自应该关闭什么?

子进程不负责继续接受新连接。

所以应该:

cpp 复制代码
close(_listenfd);

父进程不再负责当前客户端的业务。

所以应该:

cpp 复制代码
close(connfd);

子进程完成服务后,也需要:

cpp 复制代码
close(connfd);

关闭自己持有的通信Socket引用。

这里与多线程模型有重要区别。

在多进程中,父子进程分别持有对应的文件描述符引用。

父进程关闭自己的 connfd,并不意味着子进程继承的相应描述符也立即被关闭。


三、V2的第一个问题:子进程退出后怎么办?

如果我们直接实现:

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

if (id == 0)
{
    EchoService(connfd, peer);

    close(connfd);

    _exit(0);
}

close(connfd);

父进程确实可以继续:

cpp 复制代码
accept()

但还有一个问题:

子进程退出以后,父进程需要负责子进程状态的回收。

否则退出的子进程可能成为僵尸进程。

3.1 能不能直接waitpid?

例如:

cpp 复制代码
waitpid(id, nullptr, 0);

可以回收直接子进程。

但是如果父进程立即阻塞等待它退出:

text 复制代码
fork Client-A
      |
      v
waitpid等待Client-A退出
      |
      v
继续accept

就又会让父进程等待客户端业务完成。

这不符合我们希望父进程持续接受连接的设计目标。

3.2 因此引入双重fork

双重fork是一种经典的进程关系调整方式。

过程:

text 复制代码
原始父进程
      |
      v
第一次fork
      |
      +---------- 原始父进程
      |                 |
      |                 v
      |          waitpid回收中间子进程
      |                 |
      |                 v
      |             继续accept
      |
      v
中间子进程
      |
      v
第二次fork
      |
      +---------- 中间子进程退出
      |
      v
孙子进程
      |
      v
EchoService
      |
      v
关闭通信Socket并退出

3.3 为什么原始父进程只需要等待中间子进程?

因为中间子进程完成第二次fork以后,就可以立即退出。

因此原始父进程的:

cpp 复制代码
waitpid()

通常不需要等待整个客户端通信过程结束。

最终处理连接的孙子进程,在中间子进程退出后,会由系统中适当的进程接管并负责后续回收。

这里需要注意:

孤儿进程不一定永远由PID为1的进程接管,也可能由具备相应subreaper属性的进程接管。

3.4 双重fork有哪些局限?

它仍然需要创建两个进程。

而进程创建、地址空间管理和调度都具有成本。

因此这种方式适合作为当前进程控制知识的综合实验,但不意味着它是所有服务器最合适的连接处理方式。


四、V2需要设计哪些文件?

第一篇的公共文件不变。

现在增加:

text 复制代码
TcpServerV2.hpp

ServerMainV2.cc

整体工程:

text 复制代码
tcp_server_versions/
├── NoCopy.hpp
├── Comm.hpp
├── InetAddr.hpp
├── TcpIO.hpp
├── TcpService.hpp
├── TcpClient.cc
├── TcpServerV2.hpp
└── ServerMainV2.cc

4.1 TcpServerV2的职责

text 复制代码
创建监听Socket

绑定本地端口

进入监听状态

循环接受客户端

通过双重fork处理连接

管理父子进程的Socket引用

4.2 需要设计的接口

cpp 复制代码
class TcpServerV2 : public NoCopy
{
public:
    explicit TcpServerV2(uint16_t port);

    bool Init();

    void Start();

    void ProcessConnection(
        int connfd,
        const sockaddr_in& peer
    );

    ~TcpServerV2();
};

为什么将:

cpp 复制代码
ProcessConnection()

独立出来?

因为它是从V1:

text 复制代码
同步Service

升级为V2:

text 复制代码
双重fork处理

的核心修改点。


五、TcpServerV2.hpp完整实现

cpp 复制代码
#pragma once

#include <cerrno>
#include <cstdint>
#include <iostream>

#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#include "NoCopy.hpp"
#include "Comm.hpp"
#include "InetAddr.hpp"
#include "TcpService.hpp"

class TcpServerV2 : public NoCopy
{
public:
    explicit TcpServerV2(uint16_t port)
        : _port(port),
          _listenfd(-1)
    {
    }

    // 创建并初始化监听Socket
    bool Init()
    {
        _listenfd = ::socket(
            AF_INET,
            SOCK_STREAM,
            0
        );

        if (_listenfd < 0)
        {
            perror("socket");
            return false;
        }

        int opt = 1;

        if (::setsockopt(
                _listenfd,
                SOL_SOCKET,
                SO_REUSEADDR,
                &opt,
                sizeof(opt)
            ) < 0)
        {
            perror("setsockopt");
            return false;
        }

        sockaddr_in local{};

        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY;

        if (::bind(
                _listenfd,
                reinterpret_cast<sockaddr*>(&local),
                sizeof(local)
            ) < 0)
        {
            perror("bind");
            return false;
        }

        if (::listen(
                _listenfd,
                kBacklog
            ) < 0)
        {
            perror("listen");
            return false;
        }

        return true;
    }

    // 使用双重fork处理当前连接
    void ProcessConnection(
        int connfd,
        const sockaddr_in& peer
    )
    {
        pid_t id = ::fork();

        if (id < 0)
        {
            perror("fork");
            ::close(connfd);
            return;
        }

        if (id == 0)
        {
            // 第一个子进程
            // 不再需要监听Socket
            ::close(_listenfd);

            pid_t child = ::fork();

            if (child < 0)
            {
                perror("second fork");

                ::close(connfd);
                ::_exit(1);
            }

            if (child > 0)
            {
                // 中间子进程立即退出
                ::close(connfd);
                ::_exit(0);
            }

            // 孙子进程负责实际通信
            InetAddr addr(peer);

            EchoService(connfd, addr);

            ::close(connfd);

            ::_exit(0);
        }

        // 原始父进程不处理当前连接
        ::close(connfd);

        // 只等待中间子进程退出
        while (true)
        {
            pid_t ret = ::waitpid(
                id,
                nullptr,
                0
            );

            if (ret == id)
            {
                break;
            }

            if (ret < 0 && errno == EINTR)
            {
                continue;
            }

            if (ret < 0)
            {
                perror("waitpid");
            }

            break;
        }
    }

    // 持续接受客户端连接
    void Start()
    {
        while (true)
        {
            sockaddr_in peer{};

            socklen_t len = sizeof(peer);

            int connfd = ::accept(
                _listenfd,
                reinterpret_cast<sockaddr*>(&peer),
                &len
            );

            if (connfd < 0)
            {
                if (errno == EINTR)
                {
                    continue;
                }

                perror("accept");
                continue;
            }

            InetAddr addr(peer);

            std::cout
                << "accept client: "
                << addr.PrintDebug()
                << std::endl;

            // 把客户端交给工作进程
            ProcessConnection(connfd, peer);
        }
    }

    ~TcpServerV2()
    {
        if (_listenfd >= 0)
        {
            ::close(_listenfd);
        }
    }

private:
    uint16_t _port;
    int _listenfd;
};

六、为什么V2需要特别分析close?

这一部分是多进程Socket编程的关键。

6.1 原始父进程

它负责:

cpp 复制代码
accept()

得到:

cpp 复制代码
connfd

然后:

cpp 复制代码
fork()

由于已经把业务交给子进程处理,所以自己应该:

cpp 复制代码
close(connfd);

但保留:

cpp 复制代码
_listenfd

继续接受新连接。

6.2 中间子进程

不负责监听。

因此:

cpp 复制代码
close(_listenfd);

完成第二次fork以后,直接退出。

退出前需要关闭自己不再使用的:

cpp 复制代码
connfd

6.3 孙子进程

负责:

cpp 复制代码
EchoService(connfd, addr);

结束以后:

cpp 复制代码
close(connfd);

然后:

cpp 复制代码
_exit(0);

6.4 为什么不直接exit?

_exit()可以直接结束进程,不执行普通C库的用户空间退出处理流程。

对于fork后的工作子进程,在不需要运行普通退出清理流程的情况下,这种方式能够避免一些继承缓冲区重复刷新的问题。

6.5 为什么双重fork以后仍需考虑资源消耗?

因为每个客户端连接都可能生成新的工作进程。

进程不会因为采用了双重fork,就没有任何资源成本。

因此多进程服务器仍然需要考虑:

text 复制代码
进程数量

文件描述符数量

系统调度开销

客户端连接数量

七、ServerMainV2.cc完整实现

这个文件只负责:

text 复制代码
参数处理

服务器对象创建

初始化

启动

完整代码:

cpp 复制代码
#include <iostream>
#include <cstdint>

#include "Comm.hpp"
#include "TcpServerV2.hpp"

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        std::cerr
            << "Usage: "
            << argv[0]
            << " local_port"
            << std::endl;

        return 1;
    }

    uint16_t port = 0;

    if (!ParsePort(argv[1], &port))
    {
        std::cerr << "invalid port" << std::endl;
        return 1;
    }

    TcpServerV2 server(port);

    if (!server.Init())
    {
        return 2;
    }

    server.Start();

    return 0;
}

编译:

bash 复制代码
g++ ServerMainV2.cc \
    -std=c++11 \
    -Wall -Wextra \
    -o tcp_server_v2

运行:

bash 复制代码
./tcp_server_v2 8888

客户端可以直接复用第一篇的:

bash 复制代码
./tcp_client 127.0.0.1 8888

7.1 怎样验证V2支持多个客户端?

先启动服务器。

然后连续启动:

text 复制代码
Client-A

Client-B

Client-C

保持三个客户端连接。

分别发送:

text 复制代码
hello A

hello B

hello C

在正常条件下,不同客户端可以由不同的工作进程分别获得回显服务。

7.2 怎样观察进程?

可以执行:

bash 复制代码
ps -eo pid,ppid,stat,cmd

观察:

text 复制代码
PID

PPID

STAT

CMD

也可以使用:

bash 复制代码
pstree -p

查看进程树。

但如果中间子进程退出得非常快,可能很难在 ps 输出中捕捉到它。

这是正常的。


八、V3:为什么还要使用多线程?

现在已经通过:

cpp 复制代码
fork()

让服务器能够并发处理多个客户端。

但多进程模型存在一定成本。

例如:

text 复制代码
创建进程

维护进程地址空间

进程调度

进程回收

如果服务器并发连接数量增加,进程管理开销可能变得明显。

因此可以使用:

cpp 复制代码
pthread_create()

创建线程处理连接。

8.1 线程与进程的区别

同一进程中的不同线程共享:

text 复制代码
进程地址空间

打开的文件描述符表

其他进程级资源

因此工作线程可以直接使用主线程通过:

cpp 复制代码
accept()

得到的:

cpp 复制代码
connfd

8.2 V3总体结构

text 复制代码
                  TCP Server进程
                       |
                       v
                   主线程accept
                       |
            +----------+----------+
            |          |          |
            v          v          v
         Thread-A   Thread-B   Thread-C
            |          |          |
            v          v          v
         Client-A   Client-B   Client-C

主线程继续:

cpp 复制代码
accept()

工作线程负责:

cpp 复制代码
EchoService()

8.3 V3中最重要的资源管理区别

多进程中:

text 复制代码
父子进程拥有各自的文件描述符表

但多线程中:

text 复制代码
线程共享同一个进程文件描述符表

因此:

主线程把connfd交给工作线程以后,不能立即在主线程中close(connfd)。

因为它可能关闭工作线程仍在使用的Socket。

当前约定:

text 复制代码
工作线程负责处理连接

工作线程负责关闭connfd

九、V3需要怎样设计接口?

为了支持后续远程命令业务,我们不把服务器限定为只能执行:

cpp 复制代码
EchoService()

而是设计一个可以替换的业务接口。

9.1 业务回调接口

cpp 复制代码
using TcpHandler = std::function<
    void(int, const InetAddr&)
>;

其中两个参数:

text 复制代码
当前通信Socket

客户端地址

例如:

cpp 复制代码
EchoService(connfd, peer);

就符合这个函数签名。

9.2 为什么用std::function?

因为后面可以换成:

cpp 复制代码
CommandService()

而不必重新编写一份多线程服务器的:

text 复制代码
socket
bind
listen
accept
pthread_create

代码。

因此:

cpp 复制代码
TcpServerV3

负责连接和线程管理。

业务回调负责具体的服务内容。

9.3 ThreadData需要保存什么?

工作线程需要:

text 复制代码
连接文件描述符
客户端地址
业务处理函数

所以:

cpp 复制代码
struct ThreadData
{
    int connfd;

    InetAddr peer;

    TcpHandler handler;
};

9.4 为什么要在堆上创建ThreadData?

因为工作线程执行时间无法根据主线程的循环顺序确定。

如果直接传入一个可能很快失效的局部变量地址,就存在生命周期问题。

所以使用:

cpp 复制代码
new ThreadData(...)

创建参数对象。

线程结束时由工作线程负责释放。

9.5 需要哪些类接口?

cpp 复制代码
class TcpServerV3 : public NoCopy
{
public:
    TcpServerV3(
        uint16_t port,
        TcpHandler handler,
        const std::string& ip = "0.0.0.0"
    );

    bool Init();

    void Start();

    void ProcessConnection(
        int connfd,
        const InetAddr& peer
    );

    ~TcpServerV3();

private:
    static void* ThreadEntry(void* args);
};

这里:

cpp 复制代码
ThreadEntry()

必须设计为:

cpp 复制代码
static

因为 pthread_create() 需要普通函数指针形式的线程入口。

普通非静态成员函数还隐含 this 参数,不能直接作为对应的线程入口函数。


十、TcpServerV3.hpp完整实现

cpp 复制代码
#pragma once

#include <cerrno>
#include <cstdint>
#include <cstring>
#include <functional>
#include <iostream>
#include <memory>
#include <string>
#include <utility>

#include <pthread.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#include "NoCopy.hpp"
#include "Comm.hpp"
#include "InetAddr.hpp"
#include "TcpService.hpp"

using TcpHandler = std::function<
    void(int, const InetAddr&)
>;

struct ThreadData
{
    int connfd;

    InetAddr peer;

    TcpHandler handler;

    ThreadData(
        int fd,
        const InetAddr& addr,
        const TcpHandler& func
    )
        : connfd(fd),
          peer(addr),
          handler(func)
    {
    }
};

class TcpServerV3 : public NoCopy
{
public:
    TcpServerV3(
        uint16_t port,
        TcpHandler handler = EchoService,
        const std::string& ip = "0.0.0.0"
    )
        : _port(port),
          _ip(ip),
          _listenfd(-1),
          _handler(std::move(handler))
    {
    }

    bool Init()
    {
        _listenfd = ::socket(
            AF_INET,
            SOCK_STREAM,
            0
        );

        if (_listenfd < 0)
        {
            perror("socket");
            return false;
        }

        int opt = 1;

        if (::setsockopt(
                _listenfd,
                SOL_SOCKET,
                SO_REUSEADDR,
                &opt,
                sizeof(opt)
            ) < 0)
        {
            perror("setsockopt");
            return false;
        }

        sockaddr_in local{};

        local.sin_family = AF_INET;
        local.sin_port = htons(_port);

        if (::inet_pton(
                AF_INET,
                _ip.c_str(),
                &local.sin_addr
            ) != 1)
        {
            std::cerr << "invalid bind IP" << std::endl;
            return false;
        }

        if (::bind(
                _listenfd,
                reinterpret_cast<sockaddr*>(&local),
                sizeof(local)
            ) < 0)
        {
            perror("bind");
            return false;
        }

        if (::listen(
                _listenfd,
                kBacklog
            ) < 0)
        {
            perror("listen");
            return false;
        }

        return true;
    }

private:
    // pthread要求的静态线程入口
    static void* ThreadEntry(void* args)
    {
        // 接管ThreadData生命周期
        std::unique_ptr<ThreadData> task(
            static_cast<ThreadData*>(args)
        );

        // 当前工作线程设为分离状态
        int ret = pthread_detach(pthread_self());

        if (ret != 0)
        {
            std::cerr
                << "pthread_detach error: "
                << std::strerror(ret)
                << std::endl;
        }

        // 执行业务处理
        task->handler(
            task->connfd,
            task->peer
        );

        // 工作线程负责关闭通信Socket
        ::close(task->connfd);

        return nullptr;
    }

public:
    void ProcessConnection(
        int connfd,
        const InetAddr& peer
    )
    {
        ThreadData* task = new ThreadData(
            connfd,
            peer,
            _handler
        );

        pthread_t tid;

        int ret = pthread_create(
            &tid,
            nullptr,
            ThreadEntry,
            task
        );

        if (ret != 0)
        {
            std::cerr
                << "pthread_create error: "
                << std::strerror(ret)
                << std::endl;

            ::close(connfd);

            delete task;
        }

        // 创建成功后由工作线程负责后续资源管理
    }

    void Start()
    {
        while (true)
        {
            sockaddr_in peer{};

            socklen_t len = sizeof(peer);

            int connfd = ::accept(
                _listenfd,
                reinterpret_cast<sockaddr*>(&peer),
                &len
            );

            if (connfd < 0)
            {
                if (errno == EINTR)
                {
                    continue;
                }

                perror("accept");
                continue;
            }

            InetAddr addr(peer);

            ProcessConnection(connfd, addr);
        }
    }

    ~TcpServerV3()
    {
        if (_listenfd >= 0)
        {
            ::close(_listenfd);
        }
    }

private:
    uint16_t _port;
    std::string _ip;

    int _listenfd;

    TcpHandler _handler;
};

十一、逐步分析V3中的线程管理

11.1 为什么使用unique_ptr管理ThreadData?

创建参数时:

cpp 复制代码
ThreadData* task = new ThreadData(...);

工作线程入口中:

cpp 复制代码
std::unique_ptr<ThreadData> task(
    static_cast<ThreadData*>(args)
);

表示工作线程接管这个对象的生命周期。

线程函数退出时:

cpp 复制代码
unique_ptr

会自动释放对象。

因此不需要手工:

cpp 复制代码
delete task;

11.2 为什么创建失败后仍需要delete?

因为如果:

cpp 复制代码
pthread_create()

失败,工作线程根本没有创建成功。

也就没有线程负责接管:

cpp 复制代码
ThreadData

所以主线程必须:

cpp 复制代码
delete task;

同时关闭:

cpp 复制代码
connfd

11.3 为什么要pthread_detach?

当前主线程希望持续:

cpp 复制代码
accept()

而不是为每个线程立即:

cpp 复制代码
pthread_join()

因此可以让工作线程分离。

分离线程退出以后,相应线程资源可以被自动回收。

但:

cpp 复制代码
pthread_detach()

不负责释放我们自己创建的 ThreadData 或关闭Socket。

11.4 为什么工作线程可以直接使用connfd?

因为同一进程中的线程共享文件描述符表。

所以主线程接受连接以后,工作线程能够直接使用对应文件描述符进行IO。

11.5 为什么主线程不能close(connfd)?

因为一旦主线程关闭了这个描述符,可能影响工作线程正在进行的网络IO。

因此明确规定:

text 复制代码
pthread_create成功
      |
      v
connfd交给工作线程
      |
      v
工作线程Service
      |
      v
工作线程close(connfd)

11.6 分离线程有什么额外注意事项?

由于工作线程已经分离,主线程不能再通过普通 pthread_join() 等待它。

因此,如果要实现服务器的正常停止,还需要额外设计:

text 复制代码
停止接受新连接

通知工作线程退出

等待所有工作任务结束

销毁业务对象

当前实验采用持续运行的服务器模型,重点先理解线程创建、分离和连接处理。


十二、ServerMainV3.cc完整实现

服务器入口仍然非常简单。

cpp 复制代码
#include <iostream>
#include <cstdint>

#include "Comm.hpp"
#include "TcpServerV3.hpp"

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        std::cerr
            << "Usage: "
            << argv[0]
            << " local_port"
            << std::endl;

        return 1;
    }

    uint16_t port = 0;

    if (!ParsePort(argv[1], &port))
    {
        std::cerr << "invalid port" << std::endl;
        return 1;
    }

    // 默认业务处理函数为EchoService
    TcpServerV3 server(port);

    if (!server.Init())
    {
        return 2;
    }

    server.Start();

    return 0;
}

编译:

bash 复制代码
g++ ServerMainV3.cc \
    -std=c++11 \
    -Wall -Wextra \
    -pthread \
    -o tcp_server_v3

运行:

bash 复制代码
./tcp_server_v3 8888

客户端继续使用第一篇的:

bash 复制代码
./tcp_client 127.0.0.1 8888

12.1 怎样测试多线程服务器?

启动三个客户端。

分别输入不同消息。

在正常条件下,即使客户端一保持连接,客户端二、客户端三仍然可以获得对应工作线程的服务。

12.2 怎样观察线程?

先查找服务器PID:

bash 复制代码
pidof tcp_server_v3

然后:

bash 复制代码
ps -T -p <服务器PID>

或者:

bash 复制代码
top -H -p <服务器PID>

查看线程。


十三、V3-1:为什么要让服务器执行不同业务?

到这里,我们已经通过:

cpp 复制代码
TcpHandler

把具体业务函数与TCP服务器分离。

当前默认使用:

cpp 复制代码
EchoService()

但这并不是唯一可以执行的业务。

例如可以实现一个简单的:

text 复制代码
固定命令查询服务

客户端发送:

text 复制代码
pwd

服务器返回当前工作目录。

客户端发送:

text 复制代码
whoami

服务器返回运行该服务的用户名称。

这可以用于学习:

cpp 复制代码
popen()
pclose()

以及网络业务回调的复用方式。


十四、设计Command.hpp

14.1 文件功能

text 复制代码
接收一条命令
      |
      v
判断是否在允许列表中
      |
      v
执行固定命令
      |
      v
获取输出
      |
      v
返回客户端

14.2 为什么需要白名单?

因为不能让未经信任的客户端输入任意控制服务器的Shell命令。

所以先定义:

cpp 复制代码
std::map<std::string, std::string>

将客户端允许使用的请求名称映射为固定命令。

例如:

text 复制代码
pwd       → pwd

whoami    → whoami

who       → who

ls        → ls

ls -l     → ls -l

如果客户端输入其他内容,就直接拒绝。

14.3 需要设计哪些接口?

cpp 复制代码
class Command
{
public:
    static bool IsSafe(
        const std::string& command
    );

    static std::string Execute(
        const std::string& command
    );
};

另外,设计业务回调:

cpp 复制代码
void CommandService(
    int connfd,
    const InetAddr& peer
);

它与:

cpp 复制代码
TcpHandler

的函数签名一致。

因此可以直接传递给:

cpp 复制代码
TcpServerV3

使用。


十五、popen()与pclose()的作用

15.1 popen()

函数原型:

cpp 复制代码
FILE* popen(
    const char* command,
    const char* type
);

例如:

cpp 复制代码
FILE* fp = popen("pwd", "r");

其中:

cpp 复制代码
"r"

表示通过返回的流读取命令的标准输出。

因此可以通过:

cpp 复制代码
fgets()

读取命令产生的内容。

15.2 pclose()

使用完成后:

cpp 复制代码
pclose(fp);

它用于关闭相应流,并等待命令进程结束,取得相应终止状态。

不应直接将其替换为普通 fclose()。

15.3 为什么当前代码还需要处理换行?

因为第一篇的TCP客户端使用:

text 复制代码
一行请求对应一行响应

的文本协议。

但:

cpp 复制代码
popen()

读取的命令输出可能包含多行。

例如:

text 复制代码
file1
file2
file3

因此本实验将命令输出中的实际换行符编码为文本:

text 复制代码
\n

使其能够作为一行响应发送给客户端。

这只是为了兼容当前简单文本协议而采用的输出格式。


十六、Command.hpp完整实现

cpp 复制代码
#pragma once

#include <cstdio>
#include <map>
#include <string>
#include <iostream>

#include "InetAddr.hpp"
#include "TcpIO.hpp"

class Command
{
public:
    static bool IsSafe(
        const std::string& command
    )
    {
        const auto& commands = AllowCommands();

        return commands.find(command)
            != commands.end();
    }

    static std::string Execute(
        const std::string& command
    )
    {
        const auto& commands = AllowCommands();

        auto iter = commands.find(command);

        if (iter == commands.end())
        {
            return "unsupported command";
        }

        // 这里只执行程序中写死的命令
        FILE* fp = ::popen(
            iter->second.c_str(),
            "r"
        );

        if (fp == nullptr)
        {
            return "execute failed";
        }

        char buffer[256];

        std::string raw;

        // 控制演示程序的输出大小
        while (raw.size() < 1500 &&
               std::fgets(buffer, sizeof(buffer), fp))
        {
            raw += buffer;
        }

        ::pclose(fp);

        if (raw.empty())
        {
            return "(no output)";
        }

        // 将多行输出编码为一行文本
        std::string result;

        for (char ch : raw)
        {
            if (ch == '\n')
            {
                result += "\\n";
            }
            else if (ch == '\r')
            {
                result += "\\r";
            }
            else
            {
                result.push_back(ch);
            }
        }

        return result;
    }

private:
    static const std::map<
        std::string,
        std::string
    >& AllowCommands()
    {
        static const std::map<
            std::string,
            std::string
        > commands = {
            {"pwd", "pwd"},
            {"whoami", "whoami"},
            {"who", "who"},
            {"ls", "ls"},
            {"ls -l", "ls -l"}
        };

        return commands;
    }
};

// 符合TcpHandler要求的业务回调
inline void CommandService(
    int connfd,
    const InetAddr& peer
)
{
    while (true)
    {
        std::string request;

        tcpio::ReadResult result =
            tcpio::ReadLine(
                connfd,
                &request
            );

        if (result != tcpio::ReadResult::Line)
        {
            break;
        }

        std::cout
            << "[" << peer.PrintDebug()
            << "] command: "
            << request
            << std::endl;

        std::string response =
            Command::Execute(request) + "\n";

        if (!tcpio::WriteAll(
                connfd,
                response
            ))
        {
            break;
        }
    }
}

16.1 为什么使用map?

因为不仅要判断:

text 复制代码
命令是否允许

还希望明确:

text 复制代码
客户端请求对应执行什么固定命令

所以使用:

cpp 复制代码
std::map<std::string, std::string>

表示映射关系。

16.2 为什么必须进行完整匹配?

例如:

text 复制代码
ls

被允许。

不代表:

text 复制代码
ls 任意参数

都应该被允许。

因此检查:

cpp 复制代码
commands.find(command)

是否能够找到完全一致的Key。

16.3 为什么限制输出长度?

因为:

cpp 复制代码
ls -l

等命令的输出可能很长。

当前只是一个简单的文本协议实验,因此限制读取大小,避免为一个命令无限积累输出。

但这种方式不表示已经实现了完整的远程命令输出流协议。

16.4 popen执行的是不是当前服务器线程中的普通函数?

不是简单的普通函数调用。

popen()会通过相应进程和管道机制执行命令,并将其输出提供给调用者读取。

因此它涉及已经学习过的:

text 复制代码
进程

管道

标准IO

知识。


十七、CommandServerMain.cc完整实现

由于我们已经把:

cpp 复制代码
TcpServerV3

设计为可以接收业务回调,所以无需重新编写一个新的多线程服务器类。

只需要使用:

cpp 复制代码
CommandService

替换默认的:

cpp 复制代码
EchoService

即可。

cpp 复制代码
#include <iostream>
#include <cstdint>

#include "Comm.hpp"
#include "TcpServerV3.hpp"
#include "Command.hpp"

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        std::cerr
            << "Usage: "
            << argv[0]
            << " local_port"
            << std::endl;

        return 1;
    }

    uint16_t port = 0;

    if (!ParsePort(argv[1], &port))
    {
        std::cerr << "invalid port" << std::endl;
        return 1;
    }

    // 仅监听本机回环地址
    TcpServerV3 server(
        port,
        CommandService,
        "127.0.0.1"
    );

    if (!server.Init())
    {
        return 2;
    }

    server.Start();

    return 0;
}

编译:

bash 复制代码
g++ CommandServerMain.cc \
    -std=c++11 \
    -Wall -Wextra \
    -pthread \
    -o command_server

运行:

bash 复制代码
./command_server 8888

客户端:

bash 复制代码
./tcp_client 127.0.0.1 8888

输入:

text 复制代码
pwd

可以收到服务器执行固定命令后的结果。

输入:

text 复制代码
whoami

可以收到运行该服务的用户名称。

输入一个不允许的命令:

text 复制代码
example

服务器返回:

text 复制代码
unsupported command

这表明:

text 复制代码
同一个TCP服务器并发框架

可以支持:

text 复制代码
不同的业务处理函数

而无需重新实现Socket监听和线程创建逻辑。


十八、V4:为什么要引入线程池?

目前V3已经能够让多个客户端分别使用不同线程进行通信。

但每接受一个客户端:

cpp 复制代码
accept()

以后,都执行:

cpp 复制代码
pthread_create()

创建新线程。

假设有:

text 复制代码
10个客户端

就可能创建:

text 复制代码
10条工作线程

如果有:

text 复制代码
1000个客户端

这种模型可能创建大量线程。

每条线程都需要:

text 复制代码
线程栈

线程管理结构

CPU调度资源

因此,我们希望:

提前创建固定数量的工作线程,并重复使用这些线程执行不同任务。

这就是:

text 复制代码
ThreadPool

线程池。

18.1 线程池应该实现哪些功能?

首先需要:

text 复制代码
工作线程集合

例如:

text 复制代码
Worker-1

Worker-2

Worker-3

Worker-4

其次需要:

text 复制代码
任务队列

保存暂时没有工作线程处理的任务。

然后需要:

text 复制代码
互斥锁

保护任务队列。

还需要:

text 复制代码
条件变量

在没有任务时让工作线程等待。

最后提供:

cpp 复制代码
Enqueue()

用于向线程池提交任务。

18.2 为什么可以复用之前学过的线程池?

因为线程池并不需要知道自己正在执行:

text 复制代码
TCP业务

还是:

text 复制代码
其他业务

它只需要能够执行一种通用任务。

例如:

cpp 复制代码
std::function<void()>

这样就能够将:

cpp 复制代码
EchoService()

包装为任务后提交。


十九、设计ThreadPool.hpp

19.1 类成员

cpp 复制代码
std::vector<std::thread> _workers;

保存工作线程。

cpp 复制代码
std::queue<std::function<void()>> _tasks;

保存待执行任务。

cpp 复制代码
std::mutex _mutex;

保护队列。

cpp 复制代码
std::condition_variable _cv;

协调工作线程的等待与唤醒。

cpp 复制代码
bool _stopping;

表示线程池是否准备结束。

19.2 类接口

cpp 复制代码
explicit ThreadPool(std::size_t count);

创建指定数量工作线程。

cpp 复制代码
bool Enqueue(std::function<void()> task);

提交任务。

cpp 复制代码
void Worker();

工作线程执行函数。

cpp 复制代码
~ThreadPool();

停止接收新任务并回收工作线程。


二十、ThreadPool.hpp完整实现

cpp 复制代码
#pragma once

#include <condition_variable>
#include <cstddef>
#include <functional>
#include <mutex>
#include <queue>
#include <thread>
#include <utility>
#include <vector>

#include "NoCopy.hpp"

class ThreadPool : public NoCopy
{
public:
    explicit ThreadPool(std::size_t count)
        : _stopping(false)
    {
        if (count == 0)
        {
            count = 1;
        }

        // 创建工作线程
        for (std::size_t i = 0; i < count; ++i)
        {
            _workers.emplace_back(
                [this]()
                {
                    Worker();
                }
            );
        }
    }

    // 向线程池提交任务
    bool Enqueue(std::function<void()> task)
    {
        {
            std::lock_guard<std::mutex> guard(_mutex);

            if (_stopping)
            {
                return false;
            }

            _tasks.push(std::move(task));
        }

        _cv.notify_one();

        return true;
    }

private:
    // 工作线程主循环
    void Worker()
    {
        while (true)
        {
            std::function<void()> task;

            {
                std::unique_lock<std::mutex> lock(_mutex);

                _cv.wait(
                    lock,
                    [this]()
                    {
                        return _stopping ||
                               !_tasks.empty();
                    }
                );

                if (_stopping && _tasks.empty())
                {
                    return;
                }

                task = std::move(_tasks.front());

                _tasks.pop();
            }

            // 在互斥锁外执行实际任务
            task();
        }
    }

public:
    ~ThreadPool()
    {
        {
            std::lock_guard<std::mutex> guard(_mutex);

            _stopping = true;
        }

        _cv.notify_all();

        for (auto& worker : _workers)
        {
            if (worker.joinable())
            {
                worker.join();
            }
        }
    }

private:
    std::vector<std::thread> _workers;

    std::queue<std::function<void()>> _tasks;

    std::mutex _mutex;

    std::condition_variable _cv;

    bool _stopping;
};

20.1 为什么Worker中使用条件变量?

因为当任务队列为空时,不希望工作线程不断执行:

cpp 复制代码
while (true)
{
    // 检查队列
}

这种循环可能浪费CPU资源。

通过:

cpp 复制代码
_cv.wait()

可以让没有任务的工作线程进入等待状态。

有任务时,通过:

cpp 复制代码
notify_one()

唤醒其中一个工作线程。

20.2 为什么执行任务时要释放互斥锁?

因为:

cpp 复制代码
task();

可能执行较长时间。

如果持有任务队列锁执行任务,那么其他线程就无法及时:

cpp 复制代码
Enqueue()

或取出其他任务。

因此:

text 复制代码
加锁
  ↓
取出任务
  ↓
解锁
  ↓
执行任务

才是这里希望实现的流程。

20.3 为什么析构时join可能等待很久?

因为工作线程可能仍然在执行:

cpp 复制代码
EchoService()

而 EchoService() 可能长期阻塞在TCP读取操作中。

因此当前线程池的析构会等待这些任务结束。

这与我们之前学习的线程池资源回收原理一致。


二十一、V4的TcpServer需要怎样设计?

目前我们已经拥有:

cpp 复制代码
ThreadPool

接下来需要改造服务器。

21.1 服务器职责

text 复制代码
创建监听Socket

循环接受客户端

为当前连接构造Task

提交到线程池

工作线程执行对应业务

工作任务关闭通信Socket

21.2 为什么仍然需要TcpHandler?

因为我们希望:

cpp 复制代码
TcpServerV4

不仅能够执行:

cpp 复制代码
EchoService()

还能够执行其他业务。

所以继续使用:

cpp 复制代码
std::function<
    void(int, const InetAddr&)
>

作为业务处理接口。

21.3 为什么Task应该是无参函数?

因为线程池的任务类型是:

cpp 复制代码
std::function<void()>

所以我们需要提前保存:

text 复制代码
connfd

客户端地址

业务处理函数

可以使用Lambda:

cpp 复制代码
[this, connfd, peer]()
{
    _handler(connfd, peer);

    close(connfd);
}

它不需要额外的调用参数。

21.4 为什么close要放进Task?

因为工作任务负责使用当前通信Socket。

所以任务完成以后,应当负责:

cpp 复制代码
close(connfd);

主线程在成功提交任务后,不应该再提前关闭这个Socket。


二十二、TcpServerV4.hpp完整实现

cpp 复制代码
#pragma once

#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <iostream>
#include <string>
#include <utility>

#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#include "NoCopy.hpp"
#include "Comm.hpp"
#include "InetAddr.hpp"
#include "TcpService.hpp"
#include "ThreadPool.hpp"

using PoolHandler = std::function<
    void(int, const InetAddr&)
>;

class TcpServerV4 : public NoCopy
{
public:
    TcpServerV4(
        uint16_t port,
        std::size_t thread_count,
        PoolHandler handler = EchoService,
        const std::string& ip = "0.0.0.0"
    )
        : _port(port),
          _ip(ip),
          _listenfd(-1),
          _handler(std::move(handler)),
          _pool(thread_count)
    {
    }

    bool Init()
    {
        _listenfd = ::socket(
            AF_INET,
            SOCK_STREAM,
            0
        );

        if (_listenfd < 0)
        {
            perror("socket");
            return false;
        }

        int opt = 1;

        if (::setsockopt(
                _listenfd,
                SOL_SOCKET,
                SO_REUSEADDR,
                &opt,
                sizeof(opt)
            ) < 0)
        {
            perror("setsockopt");
            return false;
        }

        sockaddr_in local{};

        local.sin_family = AF_INET;
        local.sin_port = htons(_port);

        if (::inet_pton(
                AF_INET,
                _ip.c_str(),
                &local.sin_addr
            ) != 1)
        {
            std::cerr << "invalid bind IP" << std::endl;
            return false;
        }

        if (::bind(
                _listenfd,
                reinterpret_cast<sockaddr*>(&local),
                sizeof(local)
            ) < 0)
        {
            perror("bind");
            return false;
        }

        if (::listen(
                _listenfd,
                kBacklog
            ) < 0)
        {
            perror("listen");
            return false;
        }

        return true;
    }

    // 将当前连接交给线程池
    void ProcessConnection(
        int connfd,
        const InetAddr& peer
    )
    {
        std::function<void()> task =
            [this, connfd, peer]()
            {
                _handler(connfd, peer);

                // 工作任务负责关闭Socket
                ::close(connfd);
            };

        if (!_pool.Enqueue(std::move(task)))
        {
            // 提交失败,仍由主线程负责清理
            ::close(connfd);
        }
    }

    void Start()
    {
        while (true)
        {
            sockaddr_in peer{};

            socklen_t len = sizeof(peer);

            int connfd = ::accept(
                _listenfd,
                reinterpret_cast<sockaddr*>(&peer),
                &len
            );

            if (connfd < 0)
            {
                if (errno == EINTR)
                {
                    continue;
                }

                perror("accept");
                continue;
            }

            InetAddr addr(peer);

            ProcessConnection(connfd, addr);
        }
    }

    ~TcpServerV4()
    {
        if (_listenfd >= 0)
        {
            ::close(_listenfd);
        }

        // 后续由ThreadPool析构函数等待工作线程结束
    }

private:
    uint16_t _port;
    std::string _ip;

    int _listenfd;

    PoolHandler _handler;

    ThreadPool _pool;
};

22.1 为什么先声明_handler,再声明_pool?

观察:

cpp 复制代码
PoolHandler _handler;

ThreadPool _pool;

C++成员变量在对象析构时,按照声明顺序的逆序销毁。

因此:

text 复制代码
先销毁_pool

再销毁_handler

而线程池析构需要等待工作任务结束。

这样可以保证在线程池等待任务退出时,业务回调对象仍然存在。

22.2 为什么这与线程池中的任务生命周期有关?

因为Task捕获了:

cpp 复制代码
this

并且访问:

cpp 复制代码
_handler

所以如果业务处理函数先被销毁,线程池任务却还在执行,就可能访问失效对象。

因此需要明确对象的生命周期关系。

22.3 当前设计是否支持优雅停机?

只实现了最基础的资源析构。

完整的优雅停机还需要:

text 复制代码
通知accept循环退出

关闭或唤醒等待中的IO

停止接收新任务

等待现有任务退出

回收全部资源

当前文章重点仍然是线程池如何处理TCP连接,不提前扩展复杂的服务器停机机制。


二十三、ServerMainV4.cc完整实现

cpp 复制代码
#include <iostream>
#include <cstdint>

#include "Comm.hpp"
#include "TcpServerV4.hpp"

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        std::cerr
            << "Usage: "
            << argv[0]
            << " local_port"
            << std::endl;

        return 1;
    }

    uint16_t port = 0;

    if (!ParsePort(argv[1], &port))
    {
        std::cerr << "invalid port" << std::endl;
        return 1;
    }

    // 使用4条工作线程
    TcpServerV4 server(
        port,
        4
    );

    if (!server.Init())
    {
        return 2;
    }

    server.Start();

    return 0;
}

编译:

bash 复制代码
g++ ServerMainV4.cc \
    -std=c++11 \
    -Wall -Wextra \
    -pthread \
    -o tcp_server_v4

运行:

bash 复制代码
./tcp_server_v4 8888

客户端仍然使用:

bash 复制代码
./tcp_client 127.0.0.1 8888

二十四、为什么线程池不等于无限并发?

这是理解V4最重要的一个问题。

假设线程池中只有:

text 复制代码
4条工作线程

现在先后有四个客户端连接:

text 复制代码
Client-A
Client-B
Client-C
Client-D

每个工作线程都进入:

cpp 复制代码
EchoService()

等待对应客户端继续发送数据。

那么:

text 复制代码
Worker-1 → Client-A

Worker-2 → Client-B

Worker-3 → Client-C

Worker-4 → Client-D

如果四个客户端都保持连接但暂时不发送新消息,工作线程可能全部阻塞在:

cpp 复制代码
read()

此时第五个客户端连接:

text 复制代码
Client-E

主线程依然能够:

cpp 复制代码
accept()

并将任务加入线程池队列。

但因为所有工作线程都已经被占用:

text 复制代码
Client-E的Task需要等待

直到有工作线程完成当前任务。

24.1 为什么会发生这种情况?

因为当前任务不是:

text 复制代码
处理一条请求后立刻返回

而是:

text 复制代码
负责整条TCP连接直到客户端退出

所以每个任务可能运行很长时间。

24.2 线程池解决了什么问题?

线程池能够:

text 复制代码
复用已创建线程

减少频繁创建和销毁线程

控制工作线程数量

但是它不能自动解决:

text 复制代码
长期阻塞的IO任务占用工作线程

的问题。

因此:

线程池是否适合某种服务器业务,需要根据任务时长和阻塞特点进行判断。


二十五、V4-1:怎样复用线程池服务器实现英译汉业务?

现在已经完成:

cpp 复制代码
TcpServerV4

它通过:

cpp 复制代码
PoolHandler

支持替换业务函数。

因此,我们不需要再重新实现:

text 复制代码
socket

bind

listen

accept

ThreadPool

只需要编写:

cpp 复制代码
Dict

字典业务,再提供一个符合:

cpp 复制代码
PoolHandler

的处理函数即可。

25.1 业务需求

客户端输入:

text 复制代码
apple

服务器返回:

text 复制代码
苹果

客户端输入:

text 复制代码
banana

服务器返回:

text 复制代码
香蕉

客户端输入不认识的单词:

text 复制代码
unknownword

服务器返回:

text 复制代码
Unknown

25.2 文件设计

增加:

text 复制代码
Dict.hpp

DictServerMain.cc

dict.txt

Dict.hpp负责:

text 复制代码
加载本地字典

保存单词和翻译

提供Translate接口

DictServerMain.cc负责:

text 复制代码
创建字典对象

定义TCP业务回调

启动线程池服务器

二十六、设计Dict.hpp

26.1 字典文件格式

dict.txt:

text 复制代码
apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
pen: 笔
teacher: 老师
student: 学生
hello: 你好
goodbye: 再见

每一行:

text 复制代码
英文: 中文

例如:

text 复制代码
apple: 苹果

26.2 为什么使用unordered_map?

因为需要通过英文单词查找中文翻译。

可以使用:

cpp 复制代码
std::unordered_map<
    std::string,
    std::string
>

保存:

text 复制代码
Key:英文

Value:中文

26.3 设计接口

构造函数:

cpp 复制代码
explicit Dict(
    const std::string& path
);

负责加载文件。

查询函数:

cpp 复制代码
std::string Translate(
    const std::string& word
) const;

负责返回翻译结果。

26.4 完整实现

Dict.hpp:

cpp 复制代码
#pragma once

#include <fstream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>

class Dict
{
public:
    explicit Dict(const std::string& path)
    {
        std::ifstream in(path);

        if (!in.is_open())
        {
            throw std::runtime_error(
                "open dictionary failed: " + path
            );
        }

        std::string line;

        const std::string sep = ": ";

        while (std::getline(in, line))
        {
            std::size_t pos = line.find(sep);

            if (pos == std::string::npos)
            {
                continue;
            }

            std::string word =
                line.substr(0, pos);

            std::string meaning =
                line.substr(pos + sep.size());

            _words[word] = meaning;
        }
    }

    std::string Translate(
        const std::string& word
    ) const
    {
        auto iter = _words.find(word);

        if (iter == _words.end())
        {
            return "Unknown";
        }

        return iter->second;
    }

private:
    std::unordered_map<
        std::string,
        std::string
    > _words;
};

26.5 为什么Translate是const成员函数?

因为翻译查询只需要读取已经加载好的字典。

不应该因为查询一个不存在的单词,就向字典中插入新内容。

所以:

cpp 复制代码
Translate() const

用于表达:

text 复制代码
这个查询不会修改字典对象的逻辑状态

26.6 为什么使用find而不是operator\[\]?

因为:

cpp 复制代码
_words[word]

在Key不存在时,可能向字典中插入默认值。

而:

cpp 复制代码
_words.find(word)

只执行查找。

对于当前只读查询业务,后者更加合适。


二十七、DictServerMain.cc完整实现

现在使用:

cpp 复制代码
TcpServerV4

承载字典业务。

cpp 复制代码
#include <cstdint>
#include <iostream>
#include <memory>
#include <string>

#include "Comm.hpp"
#include "Dict.hpp"
#include "TcpIO.hpp"
#include "TcpServerV4.hpp"

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        std::cerr
            << "Usage: "
            << argv[0]
            << " local_port"
            << std::endl;

        return 1;
    }

    uint16_t port = 0;

    if (!ParsePort(argv[1], &port))
    {
        std::cerr << "invalid port" << std::endl;
        return 1;
    }

    std::shared_ptr<Dict> dict;

    try
    {
        dict = std::make_shared<Dict>(
            "./dict.txt"
        );
    }
    catch (const std::exception& e)
    {
        std::cerr
            << e.what()
            << std::endl;

        return 2;
    }

    // 定义字典业务回调
    PoolHandler handler =
        [dict](
            int connfd,
            const InetAddr& peer
        )
        {
            while (true)
            {
                std::string request;

                tcpio::ReadResult result =
                    tcpio::ReadLine(
                        connfd,
                        &request
                    );

                if (result != tcpio::ReadResult::Line)
                {
                    break;
                }

                std::string response =
                    dict->Translate(request) + "\n";

                std::cout
                    << "[" << peer.PrintDebug()
                    << "] " << request
                    << std::endl;

                if (!tcpio::WriteAll(
                        connfd,
                        response
                    ))
                {
                    break;
                }
            }
        };

    // 使用4条工作线程处理字典连接
    TcpServerV4 server(
        port,
        4,
        handler
    );

    if (!server.Init())
    {
        return 3;
    }

    server.Start();

    return 0;
}

27.1 为什么使用shared_ptr?

这里:

cpp 复制代码
std::shared_ptr<Dict>

表示业务对象可以安全地由多个持有该智能指针的回调共享生命周期。

Lambda:

cpp 复制代码
[dict]

按值捕获智能指针。

这样即使一个回调被复制到线程池内部,它也可以继续持有字典对象。

27.2 多个线程可以同时查询unordered_map吗?

在当前设计中:

cpp 复制代码
Dict::Translate() const

只进行查询。

并且字典在创建服务器之前就已经加载完成。

没有其他线程修改:

cpp 复制代码
_words

因此多个线程可以并发执行只读访问。

但如果以后要支持动态修改字典,就需要重新考虑适当的同步机制。

27.3 为什么不需要修改TcpServerV4?

因为:

cpp 复制代码
PoolHandler

已经把业务处理作为外部可调用对象传入。

所以从:

text 复制代码
Echo

切换为:

text 复制代码
Dict

只需要更换:

cpp 复制代码
handler

而不需要修改TCP监听和线程池调度代码。


二十八、统一编译:Makefile

现在项目已经包含多个服务器版本:

text 复制代码
tcp_server_versions/
├── NoCopy.hpp
├── Comm.hpp
├── InetAddr.hpp
├── TcpIO.hpp
├── TcpService.hpp
├── TcpClient.cc
├── TcpServerV2.hpp
├── ServerMainV2.cc
├── TcpServerV3.hpp
├── ServerMainV3.cc
├── Command.hpp
├── CommandServerMain.cc
├── ThreadPool.hpp
├── TcpServerV4.hpp
├── ServerMainV4.cc
├── Dict.hpp
├── DictServerMain.cc
├── dict.txt
└── Makefile

其中:

text 复制代码
NoCopy.hpp
Comm.hpp
InetAddr.hpp
TcpIO.hpp
TcpService.hpp
TcpClient.cc

直接复用第一篇的完整实现。

其他文件已经在本篇逐个给出。

28.1 Makefile要实现什么功能?

一条命令:

bash 复制代码
make

编译全部版本。

需要生成:

text 复制代码
tcp_client

tcp_server_v2

tcp_server_v3

command_server

tcp_server_v4

dict_server

28.2 完整Makefile

makefile 复制代码
CXX := g++

CXXFLAGS := -std=c++11 -Wall -Wextra -pthread

.PHONY: all clean

all: tcp_client tcp_server_v2 tcp_server_v3 command_server tcp_server_v4 dict_server

tcp_client: TcpClient.cc
	$(CXX) $(CXXFLAGS) -o $@ $<

tcp_server_v2: ServerMainV2.cc
	$(CXX) $(CXXFLAGS) -o $@ $<

tcp_server_v3: ServerMainV3.cc
	$(CXX) $(CXXFLAGS) -o $@ $<

command_server: CommandServerMain.cc
	$(CXX) $(CXXFLAGS) -o $@ $<

tcp_server_v4: ServerMainV4.cc
	$(CXX) $(CXXFLAGS) -o $@ $<

dict_server: DictServerMain.cc
	$(CXX) $(CXXFLAGS) -o $@ $<

clean:
	rm -f tcp_client tcp_server_v2 tcp_server_v3 command_server tcp_server_v4 dict_server

需要注意:

Makefile中的编译命令前面使用Tab。

28.3 编译

bash 复制代码
make

如果只需要编译某个版本:

bash 复制代码
make tcp_server_v3

或者:

bash 复制代码
make tcp_server_v4

二十九、实验一:多进程服务器

启动:

bash 复制代码
./tcp_server_v2 8888

然后在三个终端分别启动客户端:

bash 复制代码
./tcp_client 127.0.0.1 8888

保持它们都不退出。

分别发送:

text 复制代码
hello A
hello B
hello C

观察:

text 复制代码
三个客户端能否分别及时获得Echo响应?

还可以使用:

bash 复制代码
ps -eo pid,ppid,stat,cmd

查看进程。

重点分析:

text 复制代码
父进程为何能够继续accept?

最终工作进程为何可以使用connfd?

子进程为何必须考虑回收问题?

三十、实验二:多线程服务器

先关闭V2服务器。

然后:

bash 复制代码
./tcp_server_v3 8888

同样启动三个客户端。

观察并发通信。

然后使用:

bash 复制代码
pidof tcp_server_v3

获得服务器进程PID。

执行:

bash 复制代码
ps -T -p <服务器PID>

观察线程。

重点分析:

text 复制代码
为什么工作线程属于同一个进程?

为什么它们能够使用同一进程的文件描述符?

为什么connfd应当由工作线程关闭?

三十一、实验三:线程池服务器

关闭V3服务器。

启动:

bash 复制代码
./tcp_server_v4 8888

当前线程池有:

text 复制代码
4条工作线程

启动四个客户端,使它们保持连接。

然后再启动第五个客户端。

观察:

text 复制代码
第五个客户端是否能够立刻收到应用层回显?

如果四个工作线程都被长期连接占用,第五个连接虽然可能已经由主线程接受并提交任务,但其业务任务仍需要等待可用的工作线程。

因此可以观察到:

线程池能够控制工作线程数量,但不意味着所有连接都能立即得到服务。


三十二、实验四:TCP网络字典

准备:

text 复制代码
dict.txt

然后启动:

bash 复制代码
./dict_server 8888

客户端:

bash 复制代码
./tcp_client 127.0.0.1 8888

输入:

text 复制代码
apple

服务器返回:

text 复制代码
苹果

输入:

text 复制代码
banana

返回:

text 复制代码
香蕉

输入:

text 复制代码
unknownword

返回:

text 复制代码
Unknown

这个实验的重点不是再次学习:

cpp 复制代码
socket()

而是验证:

同一个线程池TCP服务器可以通过不同业务回调,提供不同应用服务。


三十三、V1到V4到底改变了什么?

现在统一比较四种服务器。

版本 主线程职责 连接处理者 主要资源管理问题
V1 单进程 接受并直接处理连接 主线程 长连接导致串行处理
V2 多进程 接受连接 工作进程 fork继承、Socket关闭、子进程回收
V3 多线程 接受连接 每连接一条工作线程 线程参数、文件描述符共享、线程退出
V4 线程池 接受连接并提交任务 已有工作线程 任务队列、连接所有权、工作线程占用

33.1 V1的本质

text 复制代码
一个执行流负责接受和处理连接

33.2 V2的本质

text 复制代码
父进程接受连接

子进程处理连接

33.3 V3的本质

text 复制代码
主线程接受连接

新建线程处理连接

33.4 V4的本质

text 复制代码
主线程接受连接

固定工作线程从队列中取任务处理连接

这些版本并不是简单地把某个API换成另外一个API。

而是逐步改变:

客户端连接的执行模型。


三十四、再谈Socket所有权:为什么它比写出accept更重要?

实际编写TCP服务器时,很容易只注意:

cpp 复制代码
accept()

是否成功。

但真正容易产生错误的往往是:

text 复制代码
Socket交给了谁?

是否存在两个地方同时关闭?

是否有人忘记关闭?

处理任务尚未完成,Socket是否已失效?

34.1 V2多进程

父进程:

cpp 复制代码
close(connfd);

子进程:

cpp 复制代码
EchoService(connfd, peer);

close(connfd);

因为父子进程持有各自继承的文件描述符表条目。

34.2 V3多线程

主线程:

cpp 复制代码
pthread_create(...)

成功以后,不再负责关闭该连接。

工作线程:

cpp 复制代码
EchoService(connfd, peer);

close(connfd);

34.3 V4线程池

提交成功:

text 复制代码
Task获得connfd的管理责任

工作任务完成:

cpp 复制代码
close(connfd);

提交失败:

text 复制代码
仍由提交者关闭connfd

因此可以总结:

不同并发模型下,文件描述符的资源所有权必须重新梳理,而不能机械照搬上一个版本的close位置。



三十六、回顾整个TCP Socket学习路线

到现在为止,我们已经完成:

text 复制代码
TCP基础接口
    |
    v
socket
bind
listen
accept
connect
    |
    v
V1单进程Echo Server
    |
    v
发现串行处理问题
    |
    v
V2多进程服务器
    |
    v
fork、双重fork、waitpid
    |
    v
V3多线程服务器
    |
    v
pthread_create、pthread_detach
    |
    v
V3-1命令业务
    |
    v
Command、popen、pclose
    |
    v
V4线程池服务器
    |
    v
ThreadPool、Task、Handler
    |
    v
V4-1网络字典
    |
    v
业务回调复用

从编程能力角度,这条路线不仅涉及网络通信,还涉及:

text 复制代码
Linux文件描述符

进程创建与回收

线程创建与生命周期

互斥锁与条件变量

线程池

C++函数对象

智能指针

类与模块设计

因此,TCP服务器可以看作这些Linux系统编程知识的一次综合应用。


三十七、最终总结

本文按照:

text 复制代码
文件需要实现什么功能
        ↓
需要怎样设计接口
        ↓
完整实现文件
        ↓
分析关键代码
        ↓
编译运行与测试

的结构,完成了TCP服务器的多个版本。

V2使用:

cpp 复制代码
fork()

实现多进程服务,并通过双重fork调整工作进程关系。

重点掌握:

text 复制代码
父子进程文件描述符继承

不再需要的Socket应该由谁关闭

子进程退出后怎样回收

V3使用:

cpp 复制代码
pthread_create()

实现多线程服务。

重点掌握:

text 复制代码
ThreadData参数生命周期

pthread_detach线程分离

线程共享文件描述符

工作线程负责关闭通信Socket

V3-1通过:

cpp 复制代码
Command

将具体业务与TCP服务器框架分离。

认识:

cpp 复制代码
popen()
pclose()

以及固定命令业务的实现方式。

V4使用:

cpp 复制代码
ThreadPool

复用工作线程,通过:

cpp 复制代码
std::function<void()>

包装任务。

最后V4-1复用线程池服务器实现英译汉业务,说明:

业务处理函数可以替换,而底层TCP连接管理代码不需要反复重写。

因此,本阶段最重要的结论是:

TCP服务器设计的核心不仅是让客户端成功连接,更是要明确连接由谁处理、资源由谁管理,以及不同连接怎样并发执行。


相关推荐
晚风叙码1 小时前
Linux 进程概念(下):程序地址空间与虚拟内存
linux·服务器·算法
无足鸟ICT1 小时前
【RHCA+】影响Linux系统性能的因素
linux·运维·服务器
论文复现现场1 小时前
DeepSeek V4.1 Flash 和 V4 Pro 有什么区别?API 价格、性能与迁移建议
java·服务器·人工智能·python·大模型api·deepseek·deepseek v4.1
沫璃染墨1 小时前
从零入门计算机网络系列(十):HTTP协议进阶——从资源路径解析到状态码与请求头详解
linux·运维·服务器·网络·c++·http
小则又沐风a2 小时前
深入了解五种IO模型与非阻塞IO
linux
Sayai2 小时前
Ubuntu 开发环境搭建避坑(一):网卡 EEE 致 60% 周期性丢包,Realtek RTL8125 网络黑洞排查与修复
linux·运维·网络·ubuntu·性能优化
墨染天姬2 小时前
【linux 网络1】数据怎么进网卡到内核?
linux·运维·网络
wq8973872 小时前
[AI问答] VFIO操作的相关函数学习
学习
迪康coolmu2 小时前
金融客户数据合规落地:基于迪康端点安全一体化管理系统的四道防线实践
运维·服务器·数据库·经验分享·安全·网络安全·金融