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