
🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》 🐶学习方向:C++方向学习爱好者 ⭐人生格言:得知坦然 ,失之淡然

🏠博主简介 
文章目录
- 前言
- 一、线程为什么能直接使用通信 fd
-
- 1.1 线程共享进程的大部分资源
- 1.2 工作线程不能随意关闭监听 fd
- 二、线程入口为什么通常是静态函数
-
- 2.1 原生线程函数签名
- 2.2 把连接信息放进 `ThreadData`
- 2.3 分离线程还是回收线程
- 三、把网络收发与业务处理分开
-
- 3.1 回调函数
- 3.2 当前示例的边界
- 四、完整的多线程 TCP 服务器
- 五、受限远程命令业务
-
- 5.1 为什么必须使用白名单
- 5.2 `popen` 如何取得命令输出
- 5.3 `Command.hpp`
- 5.4 `TcpServer.cc`
- 5.5 Makefile
- 六、这一版还不能当作正式远程管理工具
- 七、多线程模型的优缺点
- 总结
前言
多进程服务器能同时服务多个客户端,但每条连接都创建进程,地址空间和调度成本较高。TCP 服务器也可以为每条连接创建线程:主线程负责 accept,工作线程负责当前通信 fd。
线程版本看起来只是把 fork 换成 pthread_create,实际却有一个重要区别:同一进程中的线程共享文件描述符表。
这意味着工作线程可以直接使用主线程 accept 得到的 fd,但也意味着某个线程执行 close 会影响整个进程。本文先把线程模型写清楚,再在它上面加入一个受限制的远程命令业务。
一、线程为什么能直接使用通信 fd
1.1 线程共享进程的大部分资源
创建线程不会复制一份新的进程地址空间。线程拥有独立的栈、寄存器上下文和调度信息,但会共享进程中的代码、全局数据、堆和文件描述符表。
主线程执行:
int sockfd = accept(listen_sock, ...);
之后创建工作线程,只要把 sockfd 作为参数传进去,工作线程就能直接调用 read 和 write。
这和多进程不同。多进程通过 fork 继承描述符表副本,父子进程分别关闭自己的描述符;线程访问的是同一张描述符表,没有“每个线程自己的一份 fd”。
1.2 工作线程不能随意关闭监听 fd
在多进程版本中,子进程关闭 listen_sock,不会让父进程失去监听能力,因为父进程仍有自己的描述符表项。
在线程版本中,如果工作线程调用:
close(listen_sock);
这个监听 fd 会从整个进程的描述符表中关闭,主线程下一次 accept 就无法继续工作。
所以线程只处理交给它的通信 fd,不要关闭进程仍在使用的监听 fd。
线程共享文件描述符表。一个线程关闭 fd,其他线程也不能再使用它;多进程中的“各关各的引用”不能原样套到多线程模型。
二、线程入口为什么通常是静态函数
2.1 原生线程函数签名
pthread_create 要求线程入口是普通函数指针:
void* Routine(void* args);
普通非静态成员函数自带隐藏的 this 参数,类型不符合要求。因此可以把入口声明成 static,再通过参数把对象地址传进去。
2.2 把连接信息放进 ThreadData
线程至少需要:
- 当前客户端通信 fd;
- 客户端地址;
- 服务器对象指针,用来调用成员函数。
struct ThreadData {
TcpServer* server;
int sockfd;
InetAddr peer;
ThreadData(TcpServer* s,
int fd,
const InetAddr& addr)
: server(s), sockfd(fd), peer(addr) {}
};
不能把 accept 循环中的局部变量地址直接传给线程。主线程很快进入下一轮循环,局部变量可能被覆盖,工作线程最终读到另一位客户端的数据。
因此每条连接创建独立 ThreadData,线程结束后再释放。
2.3 分离线程还是回收线程
若主线程每创建一个工作线程就立即 pthread_join,它仍要等当前连接结束,服务器会重新变成串行。
一连接一线程的简单版本可以在线程入口中分离自己:
pthread_detach(pthread_self());
分离线程退出后,系统自动回收线程资源,主线程不再 join。
但分离不代表“可以不管资源”。通信 fd、堆上的 ThreadData 以及业务对象生命周期仍然要由代码正确处理。
三、把网络收发与业务处理分开
3.1 回调函数
TCP 服务器负责:
socket -> bind -> listen -> accept -> read/write
收到字符串以后究竟是回显、翻译还是执行命令,不应该写死在网络框架中。可以定义回调:
using Handler = std::function<std::string(
const std::string&,
const InetAddr&)>;
服务器读取请求后调用:
std::string response = handler_(request, peer);
网络层只负责把响应写回去。
3.2 当前示例的边界
目前仍然把一次 read 得到的内容当作一条完整命令,这只是为了先观察 TCP 编程流程。
TCP 是字节流:
- 一条命令可能被分成多次读取;
- 多条命令也可能在一次读取中同时到达;
- 命令执行结果可能超过单次缓冲区大小。
正式业务必须设计应用层协议,例如长度字段、分隔符或固定头部。下一章节“应用层自定义协议与序列化”会专门解决这个问题。
TCP 可靠传输的是字节序列,不负责替应用层保存消息边界。示例能回显,不等于协议设计已经完成。
四、完整的多线程 TCP 服务器
继续复用第一篇的 Common.hpp 和 InetAddr.hpp。下面给出多线程版本的 TcpServer.hpp。
#pragma once
#include "Common.hpp"
#include "InetAddr.hpp"
#include <pthread.h>
#include <functional>
#include <iostream>
#include <memory>
#include <utility>
using Handler = std::function<std::string(
const std::string&,
const InetAddr&)>;
class TcpServer : public NoCopy {
private:
struct ThreadData {
TcpServer* server;
int sockfd;
InetAddr peer;
ThreadData(TcpServer* s,
int fd,
const InetAddr& addr)
: server(s), sockfd(fd), peer(addr) {}
};
public:
TcpServer(uint16_t port, Handler handler)
: port_(port), handler_(std::move(handler)) {}
bool Init() {
listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock_ < 0) {
std::cerr << "socket error: " << strerror(errno) << std::endl;
return false;
}
int enable = 1;
setsockopt(listen_sock_,
SOL_SOCKET,
SO_REUSEADDR,
&enable,
sizeof(enable));
InetAddr local(port_);
if (bind(listen_sock_, local.AddrPtr(), local.Length()) < 0) {
std::cerr << "bind error: " << strerror(errno) << std::endl;
return false;
}
if (listen(listen_sock_, 8) < 0) {
std::cerr << "listen error: " << strerror(errno) << std::endl;
return false;
}
return true;
}
void Run() {
for (;;) {
sockaddr_in peer_addr{};
socklen_t len = sizeof(peer_addr);
int sockfd = accept(
listen_sock_,
reinterpret_cast<sockaddr*>(&peer_addr),
&len);
if (sockfd < 0) {
if (errno == EINTR) {
continue;
}
std::cerr << "accept error: "
<< strerror(errno) << std::endl;
continue;
}
InetAddr peer(peer_addr);
auto* data = new ThreadData(this, sockfd, peer);
pthread_t tid;
int ret = pthread_create(&tid, nullptr, Routine, data);
if (ret != 0) {
std::cerr << "pthread_create error: "
<< strerror(ret) << std::endl;
close(sockfd);
delete data;
}
}
}
~TcpServer() override {
if (listen_sock_ >= 0) {
close(listen_sock_);
}
}
private:
static void* Routine(void* args) {
pthread_detach(pthread_self());
std::unique_ptr<ThreadData> data(
static_cast<ThreadData*>(args));
data->server->Service(data->sockfd, data->peer);
close(data->sockfd);
return nullptr;
}
static bool WriteAll(int sockfd, const std::string& message) {
std::size_t offset = 0;
while (offset < message.size()) {
ssize_t n = write(sockfd,
message.data() + offset,
message.size() – offset);
if (n > 0) {
offset += static_cast<std::size_t>(n);
} else if (n < 0 && errno == EINTR) {
continue;
} else {
return false;
}
}
return true;
}
void Service(int sockfd, const InetAddr& peer) {
char buffer[1024];
for (;;) {
ssize_t n = read(sockfd, buffer, sizeof(buffer) – 1);
if (n > 0) {
std::string request(buffer,
static_cast<std::size_t>(n));
std::string response = handler_(request, peer);
if (!WriteAll(sockfd, response)) {
break;
}
} else if (n == 0) {
std::cout << peer.StringAddr() << " quit" << std::endl;
break;
} else if (errno != EINTR) {
std::cerr << peer.StringAddr()
<< " read error: "
<< strerror(errno) << std::endl;
break;
}
}
}
private:
uint16_t port_;
int listen_sock_ = –1;
Handler handler_;
};
这里使用 unique_ptr<ThreadData> 接管线程参数。无论 Service 从哪条路径返回,ThreadData 都会自动释放,不再依赖手写 delete。
五、受限远程命令业务
5.1 为什么必须使用白名单
客户端传来的字符串不能直接交给 Shell。若服务器无条件执行:
popen(request.c_str(), "r");
远端用户就能运行任意命令,读取文件、创建进程甚至删除数据。
黑名单很难覆盖所有组合形式。例如只禁止 rm,用户仍可能使用命令连接符、脚本解释器或其他工具绕过。教学示例采用严格白名单:只有完整字符串与允许项完全一致时才执行。
5.2 popen 如何取得命令输出
popen(command, "r") 内部会创建管道并启动 Shell 执行命令。命令的标准输出写入管道,父进程得到 FILE*,可以通过 fgets 读取。

使用完成后要调用 pclose,它不仅关闭流,还会等待子进程并返回终止状态。
5.3 Command.hpp
#pragma once
#include "InetAddr.hpp"
#include <cstdio>
#include <set>
#include <string>
class Command {
public:
Command()
: whitelist_({
"ls",
"ls -l",
"pwd",
"who",
"whoami"
}) {}
std::string Execute(const std::string& command,
const InetAddr& peer) const {
if (whitelist_.find(command) == whitelist_.end()) {
return "command rejected\\n";
}
FILE* fp = popen(command.c_str(), "r");
if (fp == nullptr) {
return "popen failed\\n";
}
std::string result;
char buffer[1024];
while (fgets(buffer, sizeof(buffer), fp) != nullptr) {
result += buffer;
}
int status = pclose(fp);
if (status == –1) {
return "pclose failed\\n";
}
if (result.empty()) {
result = "command completed without output\\n";
}
return peer.StringAddr() + " execute result:\\n" + result;
}
private:
std::set<std::string> whitelist_;
};
这里没有把 touch 放进白名单,因为文章只需要演示读取型命令。允许写操作会改变服务器状态,风险和排查成本都更高。
5.4 TcpServer.cc
#include "Command.hpp"
#include "TcpServer.hpp"
#include <functional>
#include <iostream>
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return USAGE_ERR;
}
uint16_t port = static_cast<uint16_t>(std::stoi(argv[1]));
Command command;
TcpServer server(
port,
[&command](const std::string& request,
const InetAddr& peer) {
return command.Execute(request, peer);
});
if (!server.Init()) {
return SOCKET_ERR;
}
server.Run();
return OK;
}
5.5 Makefile
.PHONY: all clean
all: tcpclient tcpserver
tcpclient: TcpClient.cc
g++ -o $@ $^ -std=c++17
tcpserver: TcpServer.cc
g++ -o $@ $^ -std=c++17 -pthread
clean:
rm -f tcpclient tcpserver
六、这一版还不能当作正式远程管理工具
即使有白名单,这个示例仍然缺少很多正式系统必须具备的能力:
- 身份认证和权限控制;
- 传输加密;
- 完整的应用层报文协议;
- 命令执行超时;
- 输出大小限制;
- 审计日志和并发数量限制。
因此它只用于学习 TCP、多线程和回调分层,不能直接暴露到公网。
白名单只是最低限度的输入限制,不等于远程执行系统已经安全。没有认证、加密和协议边界的服务不应部署到不可信网络。
七、多线程模型的优缺点
优点:
- 同一进程内共享数据方便;
- 创建与切换成本通常低于进程;
- 每条长连接有独立执行流,写法直观。
缺点:
- 线程共享地址空间,一个线程越界可能破坏整个进程;
- 共享数据需要加锁;
- 连接数量很大时,一连接一线程仍会消耗大量栈和调度资源;
- 分离线程不便集中管理和优雅退出。
下一篇继续引入线程池。不过线程池不适合无条件处理所有长连接,需要先区分长服务和短服务。
总结
本文完成了多线程 TCP 服务器和受限远程命令业务:
下一篇把通信任务交给线程池,并讨论为什么线程池更适合短服务,而不是让少量工作线程长期占在连接上。
往期博客分享 【Linux网络】UDP 聊天室怎么实现?从在线用户管理、消息广播到双线程收发与线程池 【Linux网络】TCP 服务器怎么写?从 listen、accept 到 read/write,手写完整 Echo Server 【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
网硕互联帮助中心





评论前必须登录!
注册