Socket 编程TCP:多线程远程命令执行

1. TCP 协议特点

1.1 TCP 核心特性

  • 面向连接:需要三次握手建立连接
  • 可靠传输:通过序号、确认、重传等机制保证
  • 流量控制:通过滑动窗口控制发送速率
  • 拥塞控制:防止网络过载
  • 全双工通信:双方可同时发送和接收数据

1.2 TCP vs UDP

特性 TCP UDP
连接性 面向连接 无连接
可靠性 可靠 不可靠
传输效率 较低 较高
数据边界 字节流 数据报
适用场景 文件传输、Web、邮件 视频流、DNS、广播

2. TCP Socket 编程模型

2.1 服务器端流程

cpp 复制代码
socket() → bind() → listen() → accept() → recv()/send() → close()

为什么在TCP通信中服务器处理完客户端请求后要关闭对应的sockfd

在TCP通信中,服务器处理完客户端请求后关闭对应的sockfd,主要是基于以下几个重要原因:

1. 资源管理
  • 文件描述符限制:每个sockfd占用一个文件描述符,系统资源有限
  • 内存占用:每个连接都需要维护内核缓冲区、TCP控制块等数据结构
  • 防止泄露:如果不关闭,会造成资源泄漏,最终导致服务器无法接受新连接
2.并发与安全考虑
  • 防止占用过多资源:长时间不关闭会耗尽服务器资源
  • 避免僵尸连接:已失效但未关闭的连接占用资源
  • 安全隔离:及时关闭减少被攻击的窗口期
3.不关闭的风险
  • 文件描述符耗尽:达到系统上限后无法创建新连接
  • 内存泄漏:内核资源持续占用
  • 端口耗尽:大量TIME_WAIT状态的连接
  • 客户端困惑:客户端不知道何时响应结束

2.2 客户端流程

cpp 复制代码
socket() → connect() → send()/recv() → close()

3. 核心函数详解

socket和bind函数前面UDP部分已经讲解,此处不再赘述

注意:因为TCP是面向字节流的,而文件也是面向字节流的,所以TCP可以像读写文件一样用read/write 在网络上收发数据

listen

listen()用于将套接字设置为监听模式,等待客户端连接请求。

cpp 复制代码
#include <sys/socket.h>

int listen(int sockfd, int backlog);

listen()声明sockfd 处于监听状态, 并且最多允许有backlog 个客户端处于连接等待状态, 如果接收到更多的连接请求就忽略, 这里设置不会太大(一般是5)

参数详解

  1. sockfd
  • 通过socket()创建的套接字描述符
  • 必须是 SOCK_STREAMSOCK_SEQPACKET 类型(TCP套接字)
  • 必须先调用bind()绑定地址和端口
  1. backlog

定义内核中等待连接队列的最大长度,控制同时等待处理的连接数。

返回值

  • 成功:返回 0
  • 失败:返回 -1,并设置 errno

accept

accept() 用于 TCP 服务器接受客户端的连接请求。

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

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

• 三次握手完成后, 服务器调用accept()接受连接

• 如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来

参数说明

  1. sockfd:监听套接字描述符(通过 socket()创建,bind()绑定,listen() 监听)
  2. addr:指向 struct sockaddr的指针,用于接收客户端地址信息,addr 是一个传出参数,accept()返回时传出客户端的地址和端口号,如果给addr 参数传NULL,表示不关心客户端的地址
  3. addrlen:addrlen 参数是一个传入传出参数, 传入的是调用者提供的, 缓冲区addr 的长度以避免缓冲区溢出问题, 传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)

返回值

  • 成功:返回一个新的套接字描述符,用于与客户端通信
  • 失败:返回 -1,并设置 errno

理解accept返回值:饭店拉客例子

让我用一个饭店(餐厅)比喻来解释accept() 的返回值:

比喻场景:

想象一个餐厅

  • 餐厅大门 = 监听套接字(server_fd)
  • 服务员A = 在门口专门迎接客人(调用 accept())
  • 餐桌 = 客户端套接字(client_fd)
  • 客人 = 客户端连接

具体过程

1. 餐厅准备阶段(服务器启动)
cpp 复制代码
// 餐厅开业,装好大门
int server_fd = socket();  // 安装餐厅大门

// 在大门上挂上门牌号(地址和端口)
bind(server_fd, ...);      // 挂上"XX路888号"门牌

// 打开大门,开始营业
listen(server_fd, 10);     // 打开大门,允许最多10个客人排队等待
2. 迎接客人阶段(调用 accept())

服务员A站在门口,等待客人:

cpp 复制代码
int client_fd = accept(server_fd, ...);

情况A:有客人来了(连接到达)

cpp 复制代码
客人(客户端) → 来到餐厅门口 → 服务员A迎接

服务员A:
1. 记录客人信息(获取客户端地址)
2. **为客人分配一张餐桌**(创建新的套接字)
3. **把餐桌号交给另一个服务员B**(返回 client_fd)
4. **自己回到门口继续迎接**(服务器继续 accept)

结果:
- server_fd (餐厅大门) → 保持不变,继续接受新客人
- client_fd (餐桌号) → 给服务员B,专门服务这个客人

关键点

  • accept() 返回值 client_fd = 餐桌号
  • 服务员B用这个餐桌号为这个客人点菜、上菜(读写数据)
  • 服务员A继续在门口迎接其他客人

情况B:没有客人(阻塞模式)

cpp 复制代码
服务员A站在门口,一直等待...
没有客人时,他就一直等(阻塞)

情况C:餐厅打烊(关闭服务器)

cpp 复制代码
老板说:关门!
服务员A不再等待(accept 失败)

多客人场景(并发处理)

cpp 复制代码
while (1) {
    // 服务员A在门口迎接(阻塞等待)
    int table_number = accept(restaurant_door, ...);
    
    if (table_number > 0) {
        // 来了一个客人,分配餐桌table_number
        
        // 方案1:一个服务员服务一桌(多进程/多线程)
        if (fork() == 0) {
            // 新服务员专门服务这桌客人
            serve_customer(table_number);
            close(table_number);  // 客人走了,收拾餐桌
            exit(0);
        }
        
        // 方案2:一个服务员跑多桌(IO多路复用)
        // 记录这个餐桌,稍后统一服务
        add_to_waiting_list(table_number);
    }
}

返回值对比表

饭店比喻 网络编程 说明
餐厅大门 server_fd 永远不变,专门迎接新客人
餐桌号 client_fd 每个客人一张桌子,用完回收
服务员A accept()调用 专门在门口迎接,不服务客人
服务员B 工作进程/线程 用餐桌号服务特定客人
客人信息表 struct sockaddr 记录客人联系方式(IP、端口)

重要特性理解

1. 为什么需要两个套接字?
cpp 复制代码
// 错误理解:用大门直接和客人交流 ❌
read(server_fd, buffer, ...);  // 错误!大门不能用来点菜

// 正确做法:用餐桌和客人交流 ✅
read(client_fd, buffer, ...);  // 正确!在餐桌上点菜
2. 大门(server_fd)的作用
  • 只负责迎接新客人(accept())
  • 不参与具体服务(不读写数据)
  • 一个餐厅只有一个大门 ,但可以有很多餐桌
3. 餐桌(client_fd)的生命周期
cpp 复制代码
// 客人来了,安排餐桌
int table = accept(door, ...);  // 分配餐桌

// 在餐桌上服务
write(table, "菜单", ...);      // 给客人菜单
read(table, order, ...);        // 接收点单

// 客人走了,清理餐桌
close(table);                   // 收拾桌子,等待下个客人

总结要点

  1. server_fd 像餐厅大门:永远不变,专门接受新连接
  2. accept() 返回值像餐桌号:每个客人一个,用完回收
  3. 大门不能直接服务客人:必须通过餐桌(client_fd)通信

connect

connect()用于建立套接字连接。

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

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

• 客户端需要调用connect()连接服务器;

• connect 和bind 的参数形式一致, 区别在于bind 的参数是自己的地址, 而connect 的参数是对方的地址

参数说明

参数 说明
sockfd 套接字描述符,由 socket()创建
addr 指向目标服务器地址结构的指针
addrlen 地址结构的大小

返回值

  • 成功: 返回 0
  • 失败: 返回 -1,并设置 errno

4.实践

read

cpp 复制代码
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

read()用于从文件描述符读取数据。

参数说明

参数 说明
fd 文件描述符(file descriptor)
buf 存放读取数据的缓冲区指针
count 请求读取的字节数

返回值

返回值 含义
>0 实际读取的字节数
0 到达文件末尾(EOF)
-1 出错,错误码在 errno中

重要特性

1. 阻塞与非阻塞模式
  • 默认情况下,read() 是阻塞
  • 对于普通文件,通常读取请求的数据量
  • 对于管道、套接字等,可能读取少于请求的数据量
  • 可以设置 O_NONBLOCK 标志使 read() 非阻塞
2. 特殊文件描述符的行为
文件描述符 行为特点
普通文件 通常读取请求的字节数,除非到达文件末尾
终端设备 通常按行读取(直到换行符)
管道/套接字 读取可用数据,可能少于请求字节数
目录 错误,errno=EISDIR

注意事项

  1. 返回值可能小于请求字节数:即使没有错误,read 也可能返回少于count 的字节
  2. 信号中断:如果 read() 被信号中断,会返回 -1 并设置 errno=EINTR
  3. 文件位置偏移:对于可定位的文件,read() 会从当前文件偏移开始读取,并更新偏移
  4. 原子性:普通文件的读取是原子的(对于管道和套接字则不一定)

与标准I/O的区别

区别 read() fread()
特性 系统调用 标准库函数
缓冲 无缓冲 带缓冲
性能 系统调用开销大 减少系统调用次数
控制粒度 底层控制 高级接口
可移植性 POSIX标准 C标准

read()返回值在TCP 特有的行为

情况 read()返回值 含义
正常情况 >0 实际读取的字节数
连接正常关闭 0 对端调用了 close()(收到 FIN)
出错 -1 设置 errno指示具体错误
非阻塞+无数据 -1 errno=EAGAIN 或EWOULDBLOCK

核心问题:消息边界

TCP 是字节流协议,没有消息边界:

  • 发送方调用多次 write() 发送的数据,接收方可能一次read() 就全部收到
  • 发送方一次 write() 发送的数据,接收方可能需要多次read() 才能收完

write

write()用于写入数据到文件描述符

cpp 复制代码
#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

参数说明

参数 说明
fd 文件描述符(文件、管道、套接字等)
buf 要写入的数据缓冲区
count 要写入的字节数

返回值

  • 成功:返回实际写入的字节数(可能小于 count)
  • 失败:返回 -1,并设置 errno

重要特性

1. 可能部分写入

write() 不保证一次性写入所有数据,实际写入的字节可能少于请求的字节。

2. 原子性

对于普通文件,小于 PIPE_BIF(通常 4096 字节)的写入是原子的:

  • 多个进程同时写入不会交错
  • 但对于网络套接字,没有这样的保证
3. 文件偏移

写入从当前文件偏移开始,完成后偏移量增加实际写入的字节数。

write在TCP中的重要特性

1. 阻塞行为
  • 默认情况下,套接字是阻塞的
  • 如果发送缓冲区已满,write()会阻塞直到有空间
  • 可以使用fcntl()设置为非阻塞模式
2. TCP保证
  • TCP保证数据的可靠传输
  • 数据会按发送顺序到达
  • 自动处理重传、流量控制等
3. 缓冲区管理
  • 内核有发送缓冲区
  • write()成功只表示数据已复制到内核缓冲区
  • 不保证对端已收到

popen

popen()是 Linux C 标准库中的一个函数,用于创建管道并执行 shell 命令

cpp 复制代码
#include <stdio.h>

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

参数说明

  • command: 要执行的 shell 命令字符串
  • type :
    • "r": 读取子进程的输出(父进程读取,子进程写入)
    • "w": 向子进程输入数据(父进程写入,子进程读取)

返回值

  • 成功:返回文件流指针
  • 失败:返回 NULL

fgets

fgets是 C 语言标准库中用于从文件流读取字符串的函数

cpp 复制代码
char *fgets(char *str, int n, FILE *stream);

参数说明

  • str:指向字符数组的指针,用于存储读取的数据
  • n:要读取的最大字符数(包括终止空字符)
  • strem:文件流指针(如 stdin、文件指针等)

返回值

  • 成功:返回 str 指针
  • 失败或到达文件末尾:返回 NULL

功能特点

  1. 安全读取:相比 gets,fgets 更安全,会限制读取长度
  2. 保留换行符:如果读取到换行符,会将其存入字符串
  3. 自动添加终止符:读取结束后会自动添加 '\0'
  4. 遇到以下情况停止
    • 读取了 n-1 个字符
    • 遇到换行符
    • 到达文件末尾

代码

cpp 复制代码
.PHONY:all
all: command_client command_server

command_client:CommandClient.cpp
	g++ -o $@ $^ -std=c++17
command_server:CommandServer.cpp
	g++ -o $@ $^ -std=c++17 -lpthread

.PHONY:clean
clean:
	rm -f command_client command_server
cpp 复制代码
#pragma once
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cstring>
#include <string>
#include "Logger.hpp"

using namespace std;

#define Conv(addr) ((struct sockaddr *)&addr)

class InetAddr
{
private:
    void Net2Host()
    {
        _port = ntohs(_addr.sin_port);
        _ip = inet_ntoa(_addr.sin_addr);
        //LOG(LogLevel::DEBUG) << _ip << _port;
    }
    void Host2Net()
    {
        memset(&_addr, 0, sizeof(_addr));
        _addr.sin_family = AF_INET;
        _addr.sin_port = htons(_port);
        _addr.sin_addr.s_addr = inet_addr(_ip.c_str());
    }

public:
    //默认ip为INADDR_ANY(0.0.0.0)
    InetAddr(uint16_t port,const string ip="0.0.0.0")
        : _ip(ip),
          _port(port)
    {
        Host2Net();
    }

    InetAddr(struct sockaddr_in &addr)
    {
        _addr = addr;
        Net2Host();
    }

    struct sockaddr *Addr()
    {
        return Conv(_addr);
    }

    string IP()
    {
        return _ip;
    }

    uint16_t Port()
    {
        return _port;
    }

    bool operator==(const InetAddr &addr)
    {
        return _ip == addr._ip && _port == addr._port;
    }

    socklen_t Length()
    {
        return sizeof(_addr);
    }

    string ToString()
    {
        return _ip + "-" + to_string(_port);
    }

    ~InetAddr()
    {
    }

private:
    // 网络风格地址
    struct sockaddr_in _addr;

    // 主机风格地址
    string _ip;
    uint16_t _port;
};
cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
using namespace std;
class Command
{
private:
    bool IsSafe(const std::string &cmd)
    {
        for(auto &c : _command_white_list)
        {
            if(cmd == c)
            {
                return true;
            }
        }

        return false;
    }
public:
    Command()
    {
        //简单实现,只给一些常用的命令白名单
        _command_white_list.push_back("ls -a -l");
        _command_white_list.push_back("ls -a");
        _command_white_list.push_back("ls -l");
        _command_white_list.push_back("cat test.txt");
        _command_white_list.push_back("touch touch.txt");
        _command_white_list.push_back("whoami");
        _command_white_list.push_back("who");
        _command_white_list.push_back("pwd");
    }
    string Exec(const string &cmd)
    {
        if(!IsSafe(cmd))
        {
            return "该命令不被允许\n";
        }
        string result;
        FILE *fp = popen(cmd.c_str(), "r");
        if(fp == NULL)
        {
            result = cmd + " exec error";
        }
        else
        {
            char buffer[1024];
            while(fgets(buffer, sizeof(buffer), fp) != nullptr)
            {
                result += buffer;
            }
            pclose(fp);
        }

        return result;
    }
    ~Command(){}
private:
    vector<string> _command_white_list;
};
cpp 复制代码
#pragma once
#include"InetAddr.hpp"
#include<functional>
#include"MyErrno.hpp"
#include<pthread.h>
using namespace std;

static const int gfd = -1;//文件描述符初始化为-1
static const int gbacklog = 5;//默认backlog设置为5
//static const int gport = 8080;//默认端口号是8080

using callback_t=function<string(const string&)>;

//服务器只负责IO问题
class CommandServer
{
    private:
    void HandlerIO(int client_fd,InetAddr client_addr)
    {
        char buffer[1024];
        while(true)
        {
            buffer[0]=0;//缓冲区清空
            //保护缓冲区并设置缓冲区最后一个字符为'\0'
            ssize_t n=read(client_fd,buffer,sizeof(buffer)-1);
            if(n>0)
            {
                buffer[n]=0;
                LOG(LogLevel::INFO)<<client_addr.ToString()<<" say:"<<buffer;
                 // 约定:你给我发过来的是命令字符串!ls -a -l touch XX
                string result=_cb(buffer);
                //将处理后的结果写回client_fd
                write(client_fd,result.c_str(),sizeof(result));
            }
            else if(n==0)
            {
                //连接正常关闭
                LOG(LogLevel::INFO)<<client_addr.ToString()<<"quit,me too,close client_fd"<<client_fd;
                break;
            }
            else
            {
                LOG(LogLevel::WARNING) << "read client "
                                       << client_addr.ToString() << " error, client_fd : " << client_fd;
                break;
            }
        }
         close(client_fd);// 一定要关闭
    }
    public:
    CommandServer(callback_t cb,uint16_t port,int server_fd = gfd)
    :_cb(cb)
    ,_port(port)
    ,_server_fd(server_fd)
    {

    }
    void Init()
    {
        //1.创建套接字
        _server_fd=socket(AF_INET,SOCK_STREAM,0);
        if(_server_fd<0)
        {
            LOG(LogLevel::FATAL)<<"creat sockfd error";
            exit(SOCKET_CREATE_ERR);
        }
        LOG(LogLevel::INFO)<<"creat sockfd success,server_fd:"<<_server_fd;
        //2.bind
        InetAddr server(_port);
        if(bind(_server_fd,server.Addr(),server.Length())!=0)
        {
            LOG(LogLevel::FATAL)<<"bind sockfd error";
            exit(SOCKET_BIND_ERR);
        }
        LOG(LogLevel::INFO)<<"bind sockfd success";
        //3.将套接字设置为监听模式,等待客户端连接请求
        // 一个tcp server,listen()之后,服务器已经算是运行了
        if(listen(_server_fd,gbacklog)!=0)
        {
            LOG(LogLevel::FATAL)<<"listen socket error";
            exit(SOCKET_LISTEN_ERR);
        }
        LOG(LogLevel::INFO)<<"listen socket success";
    }

    class ThreadData
    {
        public:
        ThreadData(int clientfd,CommandServer *self,InetAddr& client_addr)
        :_sockfd(clientfd),
        _self(self),
        _addr(client_addr)
        {

        }
        ~ThreadData()
        {}

        //私有的 在类外不能访问
        int _sockfd;
        CommandServer *_self;
        InetAddr _addr;
    };
    //成员函数有this指针,所以该执行函数应设置为静态
    //静态成员函数无法访问正常成员函数,所以要封装ThreadData
    static void* Routine(void *args)
    {
        ThreadData *td=(ThreadData*)args;
        pthread_detach(pthread_self());
        //HandlerIO
        td->_self->HandlerIO(td->_sockfd,td->_addr);
        delete td;
        return nullptr;
    }

    void Start()
    {
        while(true)
        {
            struct sockaddr_in peer;
            socklen_t len=sizeof(peer);
            int client_fd=accept(_server_fd,(struct sockaddr*)&peer,&len);
            if(client_fd<0)
            {
                LOG(LogLevel::FATAL)<<"accept client error";
                continue;
            }
            InetAddr client(peer);
            LOG(LogLevel::INFO)<<"accept success,client_fd:"<<client_fd<<" client addr:"<<client.ToString();
            pthread_t tid;
            ThreadData *td=new ThreadData(client_fd,this,client);
            pthread_create(&tid,nullptr,Routine,(void*)td);
        }
    }

    ~CommandServer()
    {

    }
    private:
    int _server_fd;//专门用于监听
    uint16_t _port;
    callback_t _cb;
};
cpp 复制代码
#include"CommandServer.hpp"
#include"Command.hpp"
void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " localport" << std::endl;
}

int main(int argc,char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(0);
    }
    uint16_t serverport = std::stoi(argv[1]);
    
    EnableConsoleLogStrategy();
    //定义一个对象,不传参的时候,是不需要括号的
    //否则编译器解析 codobj 就不是 对象,而是一个函数名
    //Command cmdobj();error
    Command cmdobj;
    unique_ptr<CommandServer> tsvr = make_unique<CommandServer>(
        [&cmdobj](const string &cmd) -> string
        {
            return cmdobj.Exec(cmd);
        },
        serverport);

    tsvr->Init();
    tsvr->Start();

    return 0;
}
cpp 复制代码
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "Command.hpp"
#include "InetAddr.hpp"
#include "MyErrno.hpp"
using namespace std;

void Usage(std::string proc)
{
    cerr << "Usage: " << proc << " serverip serverport" << endl;
}

// ./tcp_client serverip serverport
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(0);
    }

    string serverip = argv[1];
    uint16_t serverport = stoi(argv[2]);

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        cerr << "create client sockfd error" << endl;
        exit(SOCKET_CREATE_ERR);
    }

    // 向目标服务器发起连接请求 !

    InetAddr server(serverport, serverip);
    if (connect(sockfd, server.Addr(), server.Length()) != 0)
    {
        perror("connect");
        cerr << "connect server error" << endl;
        exit(SOCKET_CONNECT_ERR);
    }

    cout << "connect " << server.ToString() << " success" << endl;

    while (true)
    {
        cout << "my input: ";
        string line;
        getline(cin, line);

        ssize_t n = write(sockfd, line.c_str(), line.size());
        if (n >= 0)
        {
            char buffer[1024];
            ssize_t m = read(sockfd, buffer, sizeof(buffer) - 1);
            if (m > 0)
            {
                buffer[m] = 0;
                cout << buffer;
            }
        }
    }

    return 0;
}
相关推荐
汽车通信软件大头兵3 小时前
信息安全--安全XCP方案
网络·安全·汽车·uds
J ..3 小时前
C++11 新特性:智能指针的使用与解析
c++
列逍3 小时前
Linux 动静态库深度解析:原理、制作与实战
linux·运维·服务器·动态库·静态库
云和数据.ChenGuang3 小时前
欧拉(openEuler)和CentOS
linux·运维·centos
老猿讲编程3 小时前
【车载信息安全系列2】车载控制器中基于HSE的多密钥安全存储和使用
网络·安全
qq_589568103 小时前
centos打开文件之后怎么退出 ,使用linux命令
linux·运维·centos
linuxxx1103 小时前
Cannot find a valid baseurl for repo: centos-sclo-rh/x86_64
linux·运维·centos
HIT_Weston3 小时前
69、【Ubuntu】【Hugo】搭建私人博客(三)
linux·运维·ubuntu
春日见4 小时前
眼在手上外参标定保姆级教学---离线手眼标定(vscode + opencv)
linux·运维·开发语言·人工智能·数码相机·计算机视觉·matlab