云计算百科
云计算领域专业知识百科平台

【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork

封面

🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、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 读取。

popen通过管道取得命令输出

使用完成后要调用 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 服务器和受限远程命令业务:

  • 线程共享文件描述符表,可以直接使用 accept 返回的 fd;
  • 工作线程不能关闭主线程仍在使用的监听 fd;
  • 每条连接的参数必须拥有独立生命周期;
  • 分离线程退出后由系统回收,但通信 fd 和堆对象仍要正确释放;
  • 网络框架通过回调与业务解耦;
  • 远程命令必须严格限制,示例只允许只读白名单命令。
  • 下一篇把通信任务交给线程池,并讨论为什么线程池更适合短服务,而不是让少量工作线程长期占在连接上。

    往期博客分享 【Linux网络】UDP 聊天室怎么实现?从在线用户管理、消息广播到双线程收发与线程池 【Linux网络】TCP 服务器怎么写?从 listen、accept 到 read/write,手写完整 Echo Server 【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!