计算机网络Socket入门:TCP

Linux TCP Socket 编程入门:从 echo server 到多进程、多线程与线程池

摘要:本文系统整理 TCP Socket 编程的核心流程,围绕 socketbindlistenacceptconnectreadwrite 等接口展开,从最简单的 echo server 写起,逐步分析单连接阻塞问题,并扩展到多进程、多线程、线程池以及远程命令执行场景。

前言

UDP 编程中,服务端通常是 socket -> bind -> recvfrom -> sendto。TCP 的流程要多一些,因为 TCP 是面向连接的协议,通信前需要先建立连接。

写 TCP 程序时,最容易混淆的地方有几个:

服务端为什么要 listen

accept 返回的新文件描述符和监听 socket 有什么区别?

客户端为什么通常不需要显式 bind

为什么最简单的 TCP 服务器只能服务一个客户端?

多进程、多线程、线程池版本分别解决什么问题?

这篇文章按从基础到工程演进的顺序,把 TCP Socket 编程完整梳理一遍。

一、TCP Socket 编程的整体流程

TCP 服务端的核心流程如下:
#mermaid-svg-5FieowCi90ATRi9J{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5FieowCi90ATRi9J .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5FieowCi90ATRi9J .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5FieowCi90ATRi9J .error-icon{fill:#552222;}#mermaid-svg-5FieowCi90ATRi9J .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5FieowCi90ATRi9J .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5FieowCi90ATRi9J .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5FieowCi90ATRi9J .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5FieowCi90ATRi9J .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5FieowCi90ATRi9J .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5FieowCi90ATRi9J .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5FieowCi90ATRi9J .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5FieowCi90ATRi9J .marker.cross{stroke:#333333;}#mermaid-svg-5FieowCi90ATRi9J svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5FieowCi90ATRi9J p{margin:0;}#mermaid-svg-5FieowCi90ATRi9J .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5FieowCi90ATRi9J .cluster-label text{fill:#333;}#mermaid-svg-5FieowCi90ATRi9J .cluster-label span{color:#333;}#mermaid-svg-5FieowCi90ATRi9J .cluster-label span p{background-color:transparent;}#mermaid-svg-5FieowCi90ATRi9J .label text,#mermaid-svg-5FieowCi90ATRi9J span{fill:#333;color:#333;}#mermaid-svg-5FieowCi90ATRi9J .node rect,#mermaid-svg-5FieowCi90ATRi9J .node circle,#mermaid-svg-5FieowCi90ATRi9J .node ellipse,#mermaid-svg-5FieowCi90ATRi9J .node polygon,#mermaid-svg-5FieowCi90ATRi9J .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5FieowCi90ATRi9J .rough-node .label text,#mermaid-svg-5FieowCi90ATRi9J .node .label text,#mermaid-svg-5FieowCi90ATRi9J .image-shape .label,#mermaid-svg-5FieowCi90ATRi9J .icon-shape .label{text-anchor:middle;}#mermaid-svg-5FieowCi90ATRi9J .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5FieowCi90ATRi9J .rough-node .label,#mermaid-svg-5FieowCi90ATRi9J .node .label,#mermaid-svg-5FieowCi90ATRi9J .image-shape .label,#mermaid-svg-5FieowCi90ATRi9J .icon-shape .label{text-align:center;}#mermaid-svg-5FieowCi90ATRi9J .node.clickable{cursor:pointer;}#mermaid-svg-5FieowCi90ATRi9J .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5FieowCi90ATRi9J .arrowheadPath{fill:#333333;}#mermaid-svg-5FieowCi90ATRi9J .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5FieowCi90ATRi9J .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5FieowCi90ATRi9J .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5FieowCi90ATRi9J .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5FieowCi90ATRi9J .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5FieowCi90ATRi9J .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5FieowCi90ATRi9J .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5FieowCi90ATRi9J .cluster text{fill:#333;}#mermaid-svg-5FieowCi90ATRi9J .cluster span{color:#333;}#mermaid-svg-5FieowCi90ATRi9J div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5FieowCi90ATRi9J .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5FieowCi90ATRi9J rect.text{fill:none;stroke-width:0;}#mermaid-svg-5FieowCi90ATRi9J .icon-shape,#mermaid-svg-5FieowCi90ATRi9J .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5FieowCi90ATRi9J .icon-shape p,#mermaid-svg-5FieowCi90ATRi9J .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5FieowCi90ATRi9J .icon-shape .label rect,#mermaid-svg-5FieowCi90ATRi9J .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5FieowCi90ATRi9J .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5FieowCi90ATRi9J .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5FieowCi90ATRi9J :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} socket 创建监听套接字
bind 绑定本地 IP 和端口
listen 进入监听状态
accept 获取新连接
read/write 处理客户端数据

TCP 客户端的核心流程如下:
#mermaid-svg-lGGiFItWwbujISZV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lGGiFItWwbujISZV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lGGiFItWwbujISZV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lGGiFItWwbujISZV .error-icon{fill:#552222;}#mermaid-svg-lGGiFItWwbujISZV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lGGiFItWwbujISZV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lGGiFItWwbujISZV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lGGiFItWwbujISZV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lGGiFItWwbujISZV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lGGiFItWwbujISZV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lGGiFItWwbujISZV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lGGiFItWwbujISZV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lGGiFItWwbujISZV .marker.cross{stroke:#333333;}#mermaid-svg-lGGiFItWwbujISZV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lGGiFItWwbujISZV p{margin:0;}#mermaid-svg-lGGiFItWwbujISZV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lGGiFItWwbujISZV .cluster-label text{fill:#333;}#mermaid-svg-lGGiFItWwbujISZV .cluster-label span{color:#333;}#mermaid-svg-lGGiFItWwbujISZV .cluster-label span p{background-color:transparent;}#mermaid-svg-lGGiFItWwbujISZV .label text,#mermaid-svg-lGGiFItWwbujISZV span{fill:#333;color:#333;}#mermaid-svg-lGGiFItWwbujISZV .node rect,#mermaid-svg-lGGiFItWwbujISZV .node circle,#mermaid-svg-lGGiFItWwbujISZV .node ellipse,#mermaid-svg-lGGiFItWwbujISZV .node polygon,#mermaid-svg-lGGiFItWwbujISZV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lGGiFItWwbujISZV .rough-node .label text,#mermaid-svg-lGGiFItWwbujISZV .node .label text,#mermaid-svg-lGGiFItWwbujISZV .image-shape .label,#mermaid-svg-lGGiFItWwbujISZV .icon-shape .label{text-anchor:middle;}#mermaid-svg-lGGiFItWwbujISZV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lGGiFItWwbujISZV .rough-node .label,#mermaid-svg-lGGiFItWwbujISZV .node .label,#mermaid-svg-lGGiFItWwbujISZV .image-shape .label,#mermaid-svg-lGGiFItWwbujISZV .icon-shape .label{text-align:center;}#mermaid-svg-lGGiFItWwbujISZV .node.clickable{cursor:pointer;}#mermaid-svg-lGGiFItWwbujISZV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lGGiFItWwbujISZV .arrowheadPath{fill:#333333;}#mermaid-svg-lGGiFItWwbujISZV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lGGiFItWwbujISZV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lGGiFItWwbujISZV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lGGiFItWwbujISZV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lGGiFItWwbujISZV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lGGiFItWwbujISZV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lGGiFItWwbujISZV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lGGiFItWwbujISZV .cluster text{fill:#333;}#mermaid-svg-lGGiFItWwbujISZV .cluster span{color:#333;}#mermaid-svg-lGGiFItWwbujISZV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lGGiFItWwbujISZV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lGGiFItWwbujISZV rect.text{fill:none;stroke-width:0;}#mermaid-svg-lGGiFItWwbujISZV .icon-shape,#mermaid-svg-lGGiFItWwbujISZV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lGGiFItWwbujISZV .icon-shape p,#mermaid-svg-lGGiFItWwbujISZV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lGGiFItWwbujISZV .icon-shape .label rect,#mermaid-svg-lGGiFItWwbujISZV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lGGiFItWwbujISZV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lGGiFItWwbujISZV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lGGiFItWwbujISZV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} socket 创建套接字
填充服务器地址
connect 发起连接
read/write 收发数据

和 UDP 相比,TCP 多了 listenacceptconnect 这些动作:

对比项 TCP UDP
是否有连接 有连接 无连接
服务端是否监听 需要 listen 不需要
服务端是否接收连接 需要 accept 不需要
客户端是否连接 需要 connect 通常直接 sendto
常用读写接口 read/writerecv/send recvfrom/sendto
数据模型 面向字节流 面向数据报

二、核心 API 逐个理解

1. socket

socket 用于创建网络通信端口,成功后返回一个文件描述符。

cpp 复制代码
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);

参数含义:

参数 含义
AF_INET 使用 IPv4
SOCK_STREAM 使用 TCP 字节流
0 使用默认协议

Linux 中 socket 也是文件描述符,因此后续可以像操作文件一样使用 readwrite 收发数据。

2. bind

服务端通常要监听固定地址和端口,方便客户端连接,所以需要 bind

cpp 复制代码
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = htonl(INADDR_ANY);

bind(listen_sock, (struct sockaddr*)&local, sizeof(local));

INADDR_ANY 表示监听本机所有可用网卡地址。服务器有多个网卡时,这种写法更通用。

3. listen

listen 把 socket 设置为监听状态:

cpp 复制代码
listen(listen_sock, backlog);

backlog 表示连接等待队列的长度。它不是"最大服务客户端数量",而是与处于连接等待状态的请求队列有关。

4. accept

accept 从监听 socket 上获取一个已经建立好的连接:

cpp 复制代码
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int service_sock = accept(listen_sock, (struct sockaddr*)&peer, &len);

这里非常关键:accept 返回的是新的 socket 文件描述符

文件描述符 作用
listen_sock 只负责监听和接受新连接
service_sock 负责和某个客户端进行通信

很多初学者容易把这两个描述符混在一起。监听 socket 不应该拿去读写业务数据;连接 socket 才是和客户端通信的对象。

5. connect

客户端调用 connect 连接服务器:

cpp 复制代码
connect(sockfd, (struct sockaddr*)&server, sizeof(server));

connect 的参数形式和 bind 很像,但含义不同:

接口 地址含义
bind 绑定自己的地址
connect 连接对方的地址

客户端也需要本地 IP 和端口,不过通常不需要显式 bind。发起连接时,操作系统会自动选择合适的本地地址和临时端口。

三、最小 TCP echo server

下面是一个最小版本的 TCP echo server,用来理解主流程。

cpp 复制代码
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <cstring>
#include <iostream>
#include <string>

int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::cerr << "Usage: " << argv[0] << " port\n";
        return 1;
    }

    uint16_t port = std::stoi(argv[1]);

    int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_sock < 0) {
        perror("socket");
        return 2;
    }

    int opt = 1;
    setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in local;
    memset(&local, 0, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(port);
    local.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0) {
        perror("bind");
        close(listen_sock);
        return 3;
    }

    if (listen(listen_sock, 6) < 0) {
        perror("listen");
        close(listen_sock);
        return 4;
    }

    while (true) {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
        if (sock < 0) {
            perror("accept");
            continue;
        }

        char buffer[1024];
        while (true) {
            ssize_t n = read(sock, buffer, sizeof(buffer) - 1);
            if (n > 0) {
                buffer[n] = '\0';
                std::string response = "server echo# ";
                response += buffer;
                write(sock, response.c_str(), response.size());
            } else if (n == 0) {
                std::cout << "client quit" << std::endl;
                break;
            } else {
                perror("read");
                break;
            }
        }

        close(sock);
    }

    close(listen_sock);
    return 0;
}

编译运行:

bash 复制代码
g++ tcp_echo_server.cc -std=c++11 -o tcp_echo_server
./tcp_echo_server 8888

这段代码能跑,但它有一个明显问题:服务器 accept 一个客户端后,会一直在内部循环里 read 这个客户端。只要这个客户端不退出,服务器就回不到外层循环,也就无法继续 accept 新客户端。

这就是单进程同步模型的局限。

四、最小 TCP echo client

客户端代码的主流程是:创建 socket,填充服务器地址,调用 connect,然后读写。

cpp 复制代码
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <cstring>
#include <iostream>
#include <string>

int main(int argc, char* argv[]) {
    if (argc != 3) {
        std::cerr << "Usage: " << argv[0] << " server_ip server_port\n";
        return 1;
    }

    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);

    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) {
        perror("socket");
        return 2;
    }

    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);
    inet_pton(AF_INET, server_ip.c_str(), &server.sin_addr);

    if (connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0) {
        perror("connect");
        close(sock);
        return 3;
    }

    while (true) {
        std::string line;
        std::cout << "Please Enter# ";
        std::getline(std::cin, line);

        if (!std::cin || line == "quit") {
            break;
        }

        write(sock, line.c_str(), line.size());

        char buffer[1024];
        ssize_t n = read(sock, buffer, sizeof(buffer) - 1);
        if (n > 0) {
            buffer[n] = '\0';
            std::cout << buffer << std::endl;
        } else {
            break;
        }
    }

    close(sock);
    return 0;
}

编译运行:

bash 复制代码
g++ tcp_echo_client.cc -std=c++11 -o tcp_echo_client
./tcp_echo_client 127.0.0.1 8888

客户端通常不显式 bind。如果给客户端固定端口,同一台机器启动多个客户端时很容易端口冲突。让操作系统自动分配临时端口更自然。

五、read 返回值必须认真处理

TCP 通信中,read 的返回值很关键:

返回值 含义
> 0 读到数据
== 0 对端关闭连接
< 0 读取出错

典型写法:

cpp 复制代码
ssize_t n = read(sock, buffer, sizeof(buffer) - 1);
if (n > 0) {
    buffer[n] = '\0';
    // 处理数据
} else if (n == 0) {
    // 客户端退出
} else {
    // 读取出错
}

这里需要特别注意:read == 0 不是"暂时没数据",而是读到了文件结束,对 TCP socket 来说通常表示对端关闭了连接。

六、为什么 V1 只能处理一个客户端

最简单的服务器结构大致是:

cpp 复制代码
while (true) {
    int sock = accept(listen_sock, ...);
    Service(sock);
    close(sock);
}

问题出在 Service(sock)。如果 Service 内部一直循环读写当前客户端,外层循环就无法继续调用 accept

也就是说:

text 复制代码
主线程既负责 accept 新连接,又负责处理已连接客户端

这两个职责混在一起,就会导致一个客户端长期占用整个服务流程。

解决思路很自然:让主执行流只负责接收连接,把具体服务交给其他执行流处理。

常见方案有三种:

方案 思路
多进程 每个连接交给子进程
多线程 每个连接交给线程
线程池 连接任务投递到线程池

七、多进程版本:fork 处理连接

多进程版本的思路是:主进程负责 accept,每来一个连接就 fork 一个子进程处理。

核心逻辑:

cpp 复制代码
void ProcessConnection(int sock, int listen_sock) {
    pid_t id = fork();
    if (id < 0) {
        close(sock);
        return;
    }

    if (id == 0) {
        close(listen_sock);
        Service(sock);
        close(sock);
        exit(0);
    }

    close(sock);
}

父子进程里要关闭不同的文件描述符:

执行流 应该关闭
子进程 关闭监听 socket,只处理当前连接
父进程 关闭已连接 socket,继续监听新连接

否则文件描述符引用关系会变得混乱,连接释放也可能不及时。

多进程模型的优点是隔离性好,一个子进程出问题不容易影响主进程。缺点是进程创建成本相对更高,进程间共享状态也更麻烦。

八、多线程版本:pthread 处理连接

多线程版本和多进程版本思想类似,只是把"每个连接交给子进程"换成"每个连接交给线程"。

为了让线程函数拿到连接信息,可以定义一个 ThreadData

cpp 复制代码
struct ThreadData {
    int sock;
    sockaddr_in peer;
};

线程入口函数:

cpp 复制代码
void* ThreadEntry(void* arg) {
    pthread_detach(pthread_self());

    ThreadData* data = static_cast<ThreadData*>(arg);
    Service(data->sock);
    close(data->sock);
    delete data;

    return nullptr;
}

创建线程:

cpp 复制代码
void ProcessConnection(int sock, sockaddr_in peer) {
    pthread_t tid;
    ThreadData* data = new ThreadData{sock, peer};
    pthread_create(&tid, nullptr, ThreadEntry, data);
}

这里有几个细节:

pthread_detach(pthread_self()) 表示线程结束后自动释放资源,不需要主线程 join

ThreadData 放在堆上,是为了避免函数返回后栈对象失效。

线程服务结束后要关闭连接 socket,并释放 ThreadData

多线程模型比多进程轻量,但要更注意共享资源的线程安全问题。

九、远程命令执行:业务逻辑可以替换

echo server 的业务只是"收到什么返回什么"。继续扩展,可以做一个远程命令执行服务。

基本思路:

  1. 客户端发送命令字符串;
  2. 服务端检查命令是否在安全白名单中;
  3. 服务端用 popen 执行命令;
  4. 把命令输出结果返回客户端。

白名单示例:

cpp 复制代码
std::set<std::string> safe_commands = {
    "ls",
    "pwd",
    "who",
    "whoami"
};

执行前先检查:

cpp 复制代码
bool IsSafe(const std::string& command) {
    return safe_commands.find(command) != safe_commands.end();
}

执行命令:

cpp 复制代码
std::string Execute(const std::string& command) {
    if (!IsSafe(command)) {
        return "unsafe";
    }

    FILE* fp = popen(command.c_str(), "r");
    if (fp == nullptr) {
        return "";
    }

    char buffer[1024];
    std::string result;
    while (fgets(buffer, sizeof(buffer), fp) != nullptr) {
        result += buffer;
    }

    pclose(fp);
    return result.empty() ? "done" : result;
}

这里一定要强调安全边界:远程执行命令是高风险功能,真实项目不能直接把客户端输入交给 shell。哪怕做白名单,也要仔细限制命令参数、执行权限、运行目录和资源占用。

十、线程池版本:避免无限创建线程

每个连接创建一个线程虽然简单,但如果连接很多,就会频繁创建和销毁线程,开销不小。更工程化的做法是使用线程池。

主线程仍然负责 accept

cpp 复制代码
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);

拿到连接后,把处理逻辑封装成任务:

cpp 复制代码
using task_t = std::function<void()>;

task_t task = std::bind(&TcpServer::Service, this, sock, addr);
ThreadPool<task_t>::GetInstance()->Push(task);

线程池中的工作线程负责执行任务:

text 复制代码
accept 新连接
封装连接处理任务
投递到线程池
工作线程执行 Service
连接结束后关闭 socket

线程池模型的好处:

优点 说明
降低线程创建销毁开销 线程提前创建,循环处理任务
控制并发规模 避免连接暴涨时无限创建线程
方便扩展业务 任务可以是 echo、命令执行、HTTP 处理等

不过也要注意:如果 Service 长时间阻塞,一个任务会长期占用线程池里的一个工作线程。后续做高并发服务器时,还会继续引入 IO 多路复用等模型。

十一、SO_REUSEADDR 和端口复用

服务端代码里常见这段:

cpp 复制代码
int opt = 1;
setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

它的常见用途是避免服务端刚退出后马上重启时,端口仍处于某些状态导致 bind 失败。

有些代码还会使用:

cpp 复制代码
SO_REUSEPORT

这和多进程/多线程服务器的端口复用有关。初学阶段先记住一点:服务端调试时,SO_REUSEADDR 很实用,可以减少"端口占用"带来的干扰。

十二、常见问题与易错点

accept 返回的新 socket 才是业务通信 socket,监听 socket 继续负责接收新连接。

客户端不是不能 bind,而是通常没有必要显式绑定固定端口。

服务器也不是绝对不能省略 bind,但如果让系统随机分配监听端口,客户端就不知道该连哪里。

listen 只让 socket 进入监听状态,它不负责真正和客户端通信。

read 返回 0 通常表示对端关闭连接,不要当成普通空数据处理。

多进程模型里,父进程和子进程要分别关闭自己不需要的文件描述符。

多线程模型里,传给线程的数据不能使用即将失效的栈对象。

线程分离后不需要 join,但线程内部要负责释放堆上申请的资源。

远程命令执行一定要做严格限制,不能把用户输入直接交给 popen

TCP 是字节流协议,真实业务中不能假设一次 read 就刚好读到一条完整消息。后续需要通过协议设计解决粘包、半包等问题。

总结

TCP Socket 编程的主线可以概括为:

text 复制代码
服务端:socket -> bind -> listen -> accept -> read/write
客户端:socket -> connect -> read/write

最小 echo server 能帮助我们理解 API,但它只能处理一个连接。要支持多个客户端,就要把"接收连接"和"处理连接"拆开:可以用多进程,可以用多线程,也可以把连接处理任务交给线程池。

写 TCP 程序时,真正重要的不只是把接口调通,而是理解每个文件描述符的职责、每个执行流的职责,以及连接关闭、资源释放、并发处理这些工程细节。