Linux TCP Socket 编程入门:从 echo server 到多进程、多线程与线程池
摘要:本文系统整理 TCP Socket 编程的核心流程,围绕
socket、bind、listen、accept、connect、read、write等接口展开,从最简单的 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 多了 listen、accept、connect 这些动作:
| 对比项 | TCP | UDP |
|---|---|---|
| 是否有连接 | 有连接 | 无连接 |
| 服务端是否监听 | 需要 listen |
不需要 |
| 服务端是否接收连接 | 需要 accept |
不需要 |
| 客户端是否连接 | 需要 connect |
通常直接 sendto |
| 常用读写接口 | read/write、recv/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 也是文件描述符,因此后续可以像操作文件一样使用 read、write 收发数据。
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 的业务只是"收到什么返回什么"。继续扩展,可以做一个远程命令执行服务。
基本思路:
- 客户端发送命令字符串;
- 服务端检查命令是否在安全白名单中;
- 服务端用
popen执行命令; - 把命令输出结果返回客户端。
白名单示例:
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 程序时,真正重要的不只是把接口调通,而是理解每个文件描述符的职责、每个执行流的职责,以及连接关闭、资源释放、并发处理这些工程细节。