欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 汽车 > 新车 > 【计算机网络】​TCP(传输控制协议)套接字,多线程远程执行命令编程​

【计算机网络】​TCP(传输控制协议)套接字,多线程远程执行命令编程​

2025/5/2 10:41:30 来源:https://blog.csdn.net/2303_77756141/article/details/147545328  浏览:    关键词:【计算机网络】​TCP(传输控制协议)套接字,多线程远程执行命令编程​

📚 博主的专栏

🐧 Linux   |   🖥️ C++   |   📊 数据结构  | 💡C++ 算法 | 🅒 C 语言  | 🌐 计算机网络

上篇文章:UDP套接字编程(英汉字典以及多线程聊天室编写)

下篇文章:应用层自定义协议与序列化

目录

Echo Server

1. 创建套接字(socket)创建一个通信端点(套接字描述符)

2. 绑定地址(bind)将套接字与本地IP地址和端口绑定

3. 监听连接(listen)监听客户端连接请求

4. 接受连接(accept)返回新套接字用于与客户端通信

Echo Server

 5. 客户端连接(connect)

客户端:TcpClientMain.cpp

V1 - Echo Server 多进程版本

V2 - Echo Server 多线程版本

V3 ----Echo Server  线程池版本

V3-1 - 多线程远程命令执行

改造前面的代码:

添加以及修改:

Command.hpp

TcpServerMain.cc

6. 数据传输(send/recv)

7.popen 函数详解

Command.hpp


本文摘要:

本文深入解析TCP套接字编程的核心接口(socket、bind、listen、accept、connect),逐步实现单线程Echo服务器,并通过多进程、多线程及线程池版本优化并发能力。结合代码示例,探讨了父子进程资源管理、线程分离、任务队列等关键技术。进一步扩展服务器功能,通过白名单机制实现安全的远程命令执行(如lspwd),并分析recv/sendpopen的用法。最后预告应用层协议设计,解决TCP粘包与全双工通信问题,为构建高可靠网络服务提供完整实践指南。 

Echo Server

首先我们介绍几个TCP套接字的接口

1. 创建套接字(socket)创建一个通信端点(套接字描述符)

函数原型

int socket(int domain, int type, int protocol);
  • 功能:创建一个通信端点(套接字描述符)。

  • 参数

    • domain:地址族,常用AF_INET(IPv4)或AF_INET6(IPv6)。

    • type:套接字类型,SOCK_STREAM表示TCP协议。

    • protocol:通常设为0,由系统自动选择。

  • 返回值:成功返回套接字描述符(非负整数),失败返回-1

示例

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {perror("socket creation failed");exit(EXIT_FAILURE);
}

2. 绑定地址(bind)将套接字与本地IP地址和端口绑定

函数原型

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

  • 功能:将套接字与本地IP地址和端口绑定。

  • 参数

    • sockfd:套接字描述符。

    • addr:指向sockaddr结构体的指针,需填充本地地址信息。

    • addrlen:地址结构体的长度。

  • 返回值:成功返回0,失败返回-1

地址填充示例

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);           // 端口号(需转为网络字节序)
server_addr.sin_addr.s_addr = INADDR_ANY;     // 绑定所有可用IPif (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {perror("bind failed");close(sockfd);exit(EXIT_FAILURE);
}


3. 监听连接(listen)监听客户端连接请求

函数原型

int listen(int sockfd, int backlog);

  • 功能:将套接字设为被动模式,监听客户端连接请求。

  • 参数

    • backlog:等待连接队列的最大长度。

  • 返回值:成功返回0,失败返回-1

示例

if (listen(sockfd, 5) < 0) {  // 最多允许5个客户端在队列中等待perror("listen failed");close(sockfd);exit(EXIT_FAILURE);
}


4. 接受连接(accept)返回新套接字用于与客户端通信

函数原型

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

  • 功能:接受客户端连接请求,返回新套接字用于与客户端通信。

  • 参数

    • addr:保存客户端地址信息的结构体指针。

    • addrlen:地址结构体的长度(需初始化为sizeof(struct sockaddr_in))。

  • 返回值:成功返回新套接字描述符,失败返回-1

示例

struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_sock = accept(sockfd, (struct sockaddr*)&client_addr, &addr_len);
if (client_sock < 0) {perror("accept failed");close(sockfd);exit(EXIT_FAILURE);
}

具体accept的操作是:通过sockfd来获取每个客户端和服务端的链接,每获取到一个新的(不同的客户端)链接,就会有一个新的套接字描述符返回值,将来再提供服务的就是这个返回值套接字(io套接字)sockfd协助获取新的链接(不进行收发信息、listen(监听)套接字)。这种服务会随着客户端的链接增多,内部所维护的文件描述符会变多。

 我们需要准备几个文件:许多文件可以参照上文:UDP编程


Makefile

.PHONY:all
all:tcpserver tcpclient
tcpserver:TcpServerMain.ccg++ -o $@ $^ -std=c++14
tcpclient:TcpClientMain.ccg++ -o $@ $^ -std=c++14
.PHONY:clean
clean: rm -rf tcpserver tcpclient

 根据上篇博客内容,整理好思路再结合上面所列出的接口可以写好服务端第一阶段代码:

Echo Server

TcpServer.hpp1.0

【创建好套接字、绑定sockfd 和 socket addr、监听客户端连接需求】

#pragma once
#include <iostream>
#include <cstring>
#include <functional>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>#include "Log.hpp"using namespace log_ns;enum
{SOCKET_ERROR = 1,BIND_ERROR,LISTEN_ERROR
};const static int gport = 8888;
const static int glistensockfd = -1;
const static int gblcklog = 8;
class TcpServer
{public:TcpServer(uint16_t port = gport) : _port(port), _listensockfd(glistensockfd), _isrunning(false){}void InitServer(){// 1.创建socket_listensockfd = ::socket(AF_INET, SOCK_STREAM, 0);if (_listensockfd < 0){LOG(FATAL, "socker create error\n");exit(SOCKET_ERROR);}LOG(INFO, "socket create success, sockfd: %d\n", _listensockfd);struct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = INADDR_ANY;// 2.绑定sockfd 和 socket addrif (::bind(_listensockfd, (struct sockaddr *)&local, sizeof(local)) < 0){LOG(FATAL, "bind error \n");exit(BIND_ERROR);}LOG(INFO, "bind success \n");// 3.因为tcp是面向连接的,就需要tcp未来不断的能够做到获取链接// 让套接字设置为listen状态if (::listen(_listensockfd, gblcklog)){LOG(FATAL, "listen error \n");exit(LISTEN_ERROR);}LOG(INFO, "listen success \n");}void Loop(){_isrunning = true;while (_isrunning){sleep(1);}_isrunning = false;}~TcpServer(){}private:uint16_t _port;int _listensockfd; // todobool _isrunning;
};

Main函数测试验证:

#include "TcpServer.hpp"
#include <memory>// ./tcpserver 8888
int main(int argc, char *argv[])
{if (argc != 2){std::cerr << "Usage: " << argv[0] << " local-port" << std::endl;exit(0);}uint16_t port = std::stoi(argv[1]);std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port);tsvr->InitServer();tsvr->Loop();return 0;
}

运行结果: 

通过指令:具体指令的讲解可以看:计算机网络常用的几个指令

netstat -tlp

可以发现此时进程43712的状态就是LISTEN处于监听状态 

Loop函数

在运行状态时,需要监听套接字不断地获取是否有新客户端连接请求,再去做对应的服务。提供对应的服务就需要得到客户端的IP、端口号、以及地址相关信息等:因此需要用到上篇文章所封装的:InetAddr.hpp

因为TCP是面向字节流的、因此收发信息的接口与UDP(UDP是:sendto、recvfrom)是不一样的

TCP可以直接使用文件的read和write来实现收发消息:不靠谱版本

void Loop(){_isrunning = true;while (_isrunning){struct sockaddr_in client;socklen_t len = sizeof(client);// 4.获取新链接// 从监听套接字获取新的套接字、获取客户端信息int sockfd = ::accept(_listensockfd, (struct sockaddr *)&client, &len);// 获取连接失败、继续获取if (sockfd < 0){LOG(WARNING, "accept error\n");continue;}InetAddr addr(client);// 获客成功,提供服务LOG(INFO, "get a new link, client info : %s\n", addr.AddrStr().c_str());// 不靠谱版本Service(sockfd, addr);}_isrunning = false;}void Service(int sockfd, InetAddr addr){// 长服务while (true){// 缺点:inbuffer不是动态的,每次只能读取这么多,或者说,明明只发了5个字节,但是却还是要读取1023个字节char inbuffer[1024];// n是实际读取的字节数                                   // 当做字符串ssize_t n = ::read(sockfd, inbuffer, sizeof(inbuffer) - 1); //(留一个\n的位置)if (n > 0){// 1.读取消息std::string echo_string = "[server echo]# ";echo_string += inbuffer;// 2.拼接好信息之后发回去,证明消息接收成功::write(sockfd, echo_string.c_str(), echo_string.size());}else if (n == 0) // 返回值为0,表示客户端结束(文件中读到尾){LOG(INFO, "client %s quit\n", addr.AddrStr().c_str());break;}else{LOG(ERROR, "read error: %s\n", addr.AddrStr().c_str());break;}}::close(sockfd);}

 5. 客户端连接(connect)

函数原型

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

  • 功能:客户端主动连接服务器。

  • 参数

    • addr:指向服务器地址的结构体指针。

  • 返回值:成功返回0,失败返回-1

示例

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);  // 将IP字符串转为二进制if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {perror("connect failed");close(sockfd);exit(EXIT_FAILURE);
}

客户端:TcpClientMain.cpp

#include <iostream>
#include <cstring>
#include <functional>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>#include "Log.hpp"
using namespace log_ns;int main(int argc, char *argv[])
{if (argc != 3){std::cerr << "Usage: " << argv[0] << " server-ip server-port" << std::endl;exit(0);}std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);// 1.创建socketint sockfd = ::socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0){std::cerr << "create socket error" << std::endl;exit(1);}// 2.不需要显示的bind,但是一定要有自己的IP和port,所以需要隐式的bind,OS会自动bind sockfd,用自己的IP和随机算口号// 什么时候进行自动bind,if the connection or binding succeedsstruct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(serverport);// server.sin_addr.s_addr =// 进程序列转为网络序列::inet_pton(AF_INET, serverip.c_str(), &server.sin_addr);int n = ::connect(sockfd, (struct sockaddr *)&server, sizeof(server));if(n < 0){//连接失败std::cerr << "connect socket error" <<std::endl;exit(2);}while(true){std::string message;std::cout << "Enter # ";std::getline(std::cin, message);//将消息发给服务器write(sockfd, message.c_str(), message.size());char echo_buffer[1024];//读取服务端返回的消息int n = read(sockfd, echo_buffer, sizeof(echo_buffer));if(n > 0){echo_buffer[n] = '\0';std::cout << echo_buffer <<std::endl;}else{break;}}::close(sockfd);return 0;
}

运行结果:

以上我们的代码只能实现一个客户端和服务端的链接,接下来我们引入多进程版本:

V1 - Echo Server 多进程版本

注意以前讲过,子进程会继承父进程的文件描述符表因此他们同样的向标注输出输入错误打印。

因此子进程可以关闭监听套接字,而要求父进程一定关闭其余套接字,保留监听套接字,这是为了,避免文件描述符泄漏

最后,使用子进程给客户端提供服务:

我们期待的是,父进程将自己的工作做完,就直接回到accept继续接受连接,子进程转而继续执行服务,父进程正在继续接受连接,子进程同时在进行服务对新链接进行处理 ,从而实现采用多进程方式实现并发处理链接。而实际上,子进程退出后会先进入僵尸状态,此时我们采用的是0方式(int n = waitpid(id, nullptr, 0);),也就是父进程必须阻塞直到等待到子进程。因此此时无法实现并发。

解决办法:

1.子进程在退出时是会向父进程发送信号的(SIGCHILD),如果在Linux环境里,我们对SIGCHILD信号进行忽略(signal(SIGCHLD, SIG_IGN);),父进程就再也不用等待子进程,只管创建子进程。---> 最优法
 

2.我们使用的是:

在子进程内部在创建一个进程(孙子进程),孙子进程创建好之后,直接将子进程退出,父进程等待成功,则继续循环,而孙子进程则开始服务处理链接。孙子进程最终被OS领养,被OS回收

      if(id == 0){//child//注意以前讲过,子进程会继承父进程的文件描述符表,父子进程会指向同一个文件,因此子进程需要关闭监听套接字::close(_listensockfd);//创建了孙子进程,让子进程直接退出,父进程就能等待到子进程//,此后让孙子进程去执行任务,孙子进程最后会成为孤儿进程,被OS领养,被OS回收if(fork() > 0) exit(0);//子进程来处理服务Service(sockfd, addr);exit(0);}

测试:当一个服务端去请求好链接之后,父进程就开始等待新的客户端了

V2 - Echo Server 多线程版本

直接使用原生线程:

注定还是会存在问题,主线程就会循环当中阻塞等待,操作又是串行。所以如何让新线程去处理任务,让主线程继续去接受连接,实现并发呢?

      pthread_t tid;pthread_create(&tid, nullptr, Execute, nullptr);pthread_join(tid);

解决办法:线程分离,不然主线程去等待新线程,默认线程创建出来是joinable,必须得被join。 

  static void *Execute(void *args){pthread_detach(pthread_self());}

新线程共享主线程的虚拟地址空间,有独立的栈结构,不能让主线程直接关闭fd了,也不需要。

由于发给线程的方法是返回值void*,参数void*的,因此在类内部我们使用static,保证没有隐藏的this指针传入。但是当想要执行Service服务方法的时候需要用到this指针,新获得的文件描述符,也要交给新线程,以及addr。

解决方法:将线程需要使用到的数据做一个打包封装,一起通过参数传给Execute。

定义内部类:

  class ThreadData{public:int _sockfd;TcpServer *_self;InetAddr _addr;//给InetAddr再添加一个无参构造public:ThreadData(int sockfd, TcpServer *self, const InetAddr &addr) : _sockfd(sockfd), _self(self), _addr(addr) {}};

创建td对象、将td以参数的方式传给Execute

      pthread_t tid;ThreadData* td = new ThreadData(sockfd, this, addr);pthread_create(&tid, nullptr, Execute, td);

 分离线程后,获得td对象,从td对象中拿到需要的数据,并且执行Service,执行完毕后,delete掉td,并且返回空值。

static void *Execute(void *args){pthread_detach(pthread_self());ThreadData* td = static_cast<ThreadData *>(args);td->_self->Service(td->_sockfd, td->_addr);delete td;return nullptr;}

由于主线程不需要再因为等待join新线程而阻塞,因此在新线程执行Service的时候,主线程开始新的循环去接受连接,从而实现并发。

运行结果:

V3 ----Echo Server  线程池版本

      // version 3 ---- 线程池版本 int sockfd, InetAddr addrtask_t t = std::bind(&TcpServer::Service, this, sockfd, addr);//把任务添加到任务队列当中ThreadPool<task_t>::GetInstance()->Equeue(t);

V3-1 - 多线程远程命令执行

改造前面的代码:

首先我们直接从前面的代码进行修改,删除不需要的代码:

我们使用的是多线程版本,将进程池版本删除掉

想让服务器提供服务,并且实现解耦

在TcpServer.hpp中只接受连接,创建线程,执行线程。对文件描述符的处理(1.怎么读怎么写2.怎么进行处理)交给外部。

将业务,都提到外面:注释掉Service函数。

写一个函数对象:用于处理业务

//设置函数对象
using command_service_t = std::function<void(int sockfd, InetAddr addr)>;

将函数对象设置为成员变量:

  command_service_t _service;

 构造函数初始化:

public:TcpServer(command_service_t service, uint16_t port = gport) : _port(port), _listensockfd(glistensockfd), _isrunning(false), _service(service){}

直接回调我们定义的函数对象 

  static void *Execute(void *args){pthread_detach(pthread_self());ThreadData* td = static_cast<ThreadData *>(args);//直接回调我们定义的函数对象td->_self->_service(td->_sockfd, td->_addr);//处理完了,直接关闭::close(td->_sockfd);delete td;return nullptr;}

添加以及修改:

Command.hpp

• 命令类, 用来执行命令, 并获取结果

• 这里暂停, 做一个多线程的小业务

#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <cstdio>
#include <set>
#include "Log.hpp"
#include "InetAddr.hpp"class Command
{
public:Command(){}~Command(){}// 约定跑的是字符串void Execute(const std::string &cmdstr){}// 处理命令void HandlerCommand(int sockfd, InetAddr addr){}private:
};

TcpServerMain.cc

#include "TcpServer.hpp"
#include <memory>
#include "Command.hpp"
// ./tcpserver 8888
int main(int argc, char *argv[])
{if (argc != 2){std::cerr << "Usage: " << argv[0] << " local-port" << std::endl;exit(0);}uint16_t port = std::stoi(argv[1]);Command cmdservice;std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(std::bind(&Command::HandlerCommand,&cmdservice, std::placeholders::_1,std::placeholders::_2),port);tsvr->InitServer();tsvr->Loop();return 0;
}

 认识新的套接字接口:用于替代write和read 

6. 数据传输(send/recv)

函数原型

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
功能:发送或接收数据。TCP保证数据可靠传输,无丢失或乱序。
  • 参数

    • buf:数据缓冲区指针。

    • len:数据长度。

    • flags:通常设为0

  • 返回值:成功返回实际发送/接收的字节数,失败返回-1

示例

char buffer[1024];
ssize_t bytes_received = recv(client_sock, buffer, sizeof(buffer) - 1, 0);
if (bytes_received > 0) {buffer[bytes_received] = '\0';printf("Received: %s\n", buffer);
} else if (bytes_received == 0) {printf("Connection closed by client\n");
} else {perror("recv failed");
}

认识新接口:

7.popen 函数详解

popen 是 C 语言标准库中的一个高阶函数,用于简化与外部进程的通信。它通过创建管道(pipe)并启动新进程来执行系统命令,允许父进程读取子进程的输出或向子进程输入数据

1. 基本概念

功能:执行外部命令,并通过管道实现进程间单向通信。

原型

FILE *popen(const char *command, const char *mode);
int pclose(FILE *stream);

参数

  • command:要执行的命令(如 ls -l 或 grep "text")。

  • mode:管道模式,"r"(读模式,获取子进程输出)或 "w"(写模式,向子进程输入数据)。


2. 返回值

  • 成功:返回一个 FILE* 指针,用于通过标准文件操作函数(如 fgetsfprintf)读写数据。

  • 失败:返回 NULL,并设置 errno


3. 使用场景

  • 读取命令输出:例如执行 ls 并获取文件列表。

  • 向命令输入数据:例如向 grep 传递待过滤的文本。

  • 快速实现进程间通信:无需手动管理 forkexec 和管道。

Command.hpp

#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <cstdio>
#include <set>
#include "Log.hpp"
#include "InetAddr.hpp"using namespace log_ns;
class Command
{
public:Command(){// 白名单_safe_command.insert("ls");_safe_command.insert("touch"); // touch filename_safe_command.insert("pwd");_safe_command.insert("whoami");_safe_command.insert("which"); // which pwd}~Command(){ }//漏洞: ls; rm -rf ///检查是否属于白名单里的命令bool SafeCheck(const std::string &cmdstr){for(auto &cmd :_safe_command){//进行前缀的比较if(strncmp(cmd.c_str(), cmdstr.c_str(), cmd.size()) == 0){return true;}}return false;}// 约定跑的是字符串std::string Execute(const std::string &cmdstr){if(!SafeCheck(cmdstr)){return "unsafe";}FILE *fp = popen(cmdstr.c_str(), "r");std::string result;if (fp){char line[1024];// 从fp中读取到line里面while (fgets(line, sizeof(line), fp)){result += line;}//例如touch的返回值是空的,就认为成功了:return result.empty() ? "success" : result;}return "execute error";}// 处理命令void HandlerCommand(int sockfd, InetAddr addr){// 将命令的执行当成一个 长服务(例如:在xshell当中可以隔许久再在命令行输入下一次命令)while (true){// 缺点:inbuffer不是动态的,每次只能读取这么多,或者说,明明只发了5个字节,但是却还是要读取1023个字节char commandbuf[1024];// n是实际读取的字节数                                   // 当做字符串ssize_t n = ::recv(sockfd, commandbuf, sizeof(commandbuf) - 1, 0); //(留一个\n的位置)if (n > 0){commandbuf[n] = 0;LOG(INFO, "get command from client %s, command: %s\n", addr.AddrStr().c_str(), commandbuf);// 处理命令:std::string result = Execute(commandbuf);// 2.拼接好信息之后发回去,证明消息接收成功::send(sockfd, result.c_str(), result.size(), 0);}else if (n == 0) // 返回值为0,表示客户端结束(文件中读到尾){LOG(INFO, "client %s quit\n", addr.AddrStr().c_str());break;}else{LOG(ERROR, "recv error: %s\n", addr.AddrStr().c_str());break;}}}private:// 只允许执行个别命令std::set<std::string> _safe_command;
};

运行结果: 实现了远程执行命令

实际上,我们还需要解决一个问题:为什么recv是不对的不完善的

ssize_t n = ::recv(sockfd, commandbuf, sizeof(commandbuf) - 1, 0); //(留一个\n的位置)

在下篇文章:

应用层自定义协议与序列化进行讲解:重新理解 read、 write、 recv、 send 和 tcp 为什么支持全双工

结语:

       随着这篇博客接近尾声,我衷心希望我所分享的内容能为你带来一些启发和帮助。学习和理解的过程往往充满挑战,但正是这些挑战让我们不断成长和进步。我在准备这篇文章时,也深刻体会到了学习与分享的乐趣。    

         在此,我要特别感谢每一位阅读到这里的你。是你的关注和支持,给予了我持续写作和分享的动力。我深知,无论我在某个领域有多少见解,都离不开大家的鼓励与指正。因此,如果你在阅读过程中有任何疑问、建议或是发现了文章中的不足之处,都欢迎你慷慨赐教。

        你的每一条反馈都是我前进路上的宝贵财富。同时,我也非常期待能够得到你的点赞、收藏,关注这将是对我莫大的支持和鼓励。当然,我更期待的是能够持续为你带来有价值的内容。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词