🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位:接着 UDP 的学习路线,理解 TCP 为什么多出建连步骤,以及服务器为什么需要两类 Socket。
💡 学习目标 :串起
socket / bind / listen / accept / connect;区分监听与通信描述符;正确处理字节流、部分收发与 EOF;搭建完整的 C++ 回显程序。📌 实现范围:Linux、IPv4、C++17、阻塞式 TCP。本篇先采用串行服务,把通信与资源管理讲透,下篇再扩展并发。
上一节的 UDP 程序通过 sendto 指定目标,通过 recvfrom 获得来源。换成 TCP 后,除了把 SOCK_DGRAM 改成 SOCK_STREAM,服务器还要建立监听入口,接收连接,再沿着连接收发数据。
文章目录
- [一、从数据报到连接:TCP 改变了什么](#一、从数据报到连接:TCP 改变了什么)
- 二、把客户端与服务器的调用链串起来
- [三、socket、bind 与地址初始化](#三、socket、bind 与地址初始化)
- [四、listen 的作用与 backlog 的边界](#四、listen 的作用与 backlog 的边界)
- [五、accept 为什么返回另一个描述符](#五、accept 为什么返回另一个描述符)
- [六、connect 与客户端的临时端口](#六、connect 与客户端的临时端口)
- 七、字节流为什么不能按一次收发理解
- 八、返回值、部分发送与半关闭
- [九、整理一份可复用的 TCP 基础代码](#九、整理一份可复用的 TCP 基础代码)
- 十、完整的回显服务器与客户端
- 十一、编译运行与分步实验
- 十二、第二个客户端为什么迟迟没有响应
- 总结
一、从数据报到连接:TCP 改变了什么
我们先回忆 UDP 的通信方式:服务器绑定一个端口,收到某个客户端的数据报,再把响应发回本次来源。客户端发送下一条消息时,仍然可以携带目标地址。应用的收发单位是数据报。
TCP 提供的是可靠、有序的双向字节流 。应用通过连接交换字节,而不是通过每次 recv 得到一条独立消息。

1.1 "可靠"解决了哪一层的问题
设想客户端把 hello 交给 TCP,网络中发生了丢包。TCP 可以通过确认与重传等机制,尝试把字节按序交给对端。应用通常不需要自己重复编写这一层的可靠传输逻辑。
但这不等于 send 成功后,服务器业务就一定执行完成。服务器可能尚未读取,也可能读完后还没有处理,甚至在处理期间退出。传输层的交付与业务层的成功,是两个不同的结果。
回显程序通过收到原样响应来观察服务端确实完成了一次回显。如果将来改成付款、写文件或修改配置,就需要在应用协议里明确成功响应、失败响应和重复请求的处理方式。
1.2 面向连接并不意味着独占服务器端口
服务器监听 127.0.0.1:8888,客户端 A 和 B 都可以连接这个入口。建立起来的连接可以通过协议及本地、远端地址与端口等信息区分。
例如同一台机器上的两个客户端可能分别得到:
text
A:127.0.0.1:45001 → 127.0.0.1:8888
B:127.0.0.1:45002 → 127.0.0.1:8888
服务器端口都是 8888,但连接的远端端口不同。一个服务入口可以接收多条连接,具体连接由各自的通信 Socket 承载。
1.3 全双工与程序的执行顺序
全双工说明连接的两个方向都能传输数据。客户端可以发送,服务端也可以发送;协议本身不要求严格交替。
本文交互程序采用"发送一条请求,等待一条响应"的顺序,是应用自己的安排。这种安排便于观察回显,但不能据此推出 TCP 只支持一问一答。如果改写成聊天程序,还需要考虑独立收发、输出同步与退出协作。
二、把客户端与服务器的调用链串起来
在读参数之前,先看全局流程。否则很容易把几个名字相似的接口混在一起。

| 角色 | 调用顺序 | 最终用哪个描述符收发 |
|---|---|---|
| 服务器 | socket → bind → listen → accept → recv/send |
accept 返回的连接描述符 |
| 客户端 | socket → connect → send/recv |
原来创建并成功连接的描述符 |
2.1 服务器为什么多了 listen 与 accept
服务器必须让内核知道:"这个 Socket 用来等待别人发起连接。"这就是 listen 所建立的被动监听角色。
然后应用调用 accept,获得一条已经可以交给应用处理的连接。监听 Socket 负责接收连接,连接 Socket 负责交换应用数据。
这和 UDP 的接收循环不同。UDP 示例只需要在绑定的 Socket 上不断读取数据报,没有为每个客户端调用 accept 创建通信描述符。
2.2 握手由谁完成
普通 TCP 的连接建立由操作系统的 TCP 协议栈完成。应用调用 connect 发起连接,服务端内核维护连接状态,应用再通过 accept 取出待接收的连接。
不能把它理解成"服务器第一次执行 accept 才开始握手",也不能认为"客户端 connect 成功说明服务端已经在业务函数里读数据"。这两个误解会直接影响后面排查第二个客户端的实验。
2.3 哪些操作会阻塞
在本篇的阻塞式程序中,accept 可以等待连接,connect 可以等待建连结果,recv 可以等待数据;send 也可能因发送缓冲空间不足而等待。
阻塞并不等于错误。它表示当前操作的条件尚未满足。真正的错误要根据返回值和 errno 判断。后续学习非阻塞 IO 时,才会进一步把"暂时不能完成"与"永久失败"分开处理。
三、socket、bind 与地址初始化
TCP 的创建方式是:
cpp
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
// 失败,检查 errno。
}
AF_INET 选择 IPv4 地址族,SOCK_STREAM 选择流式 Socket,0 让系统使用该组合的默认协议,在这里就是 TCP。
3.1 fd 是资源入口,不是端口号
socket 成功得到一个非负整数,它是当前进程中的文件描述符。它不等于监听端口,也不等于连接编号的全局唯一值。
同样,0 是合法文件描述符。判断成功与否应该使用 fd < 0,不能写成 fd <= 0。如果进程提前关闭标准输入,新的 Socket 就可能占用 0。
3.2 bind 绑定的是本地地址
一个典型的 IPv4 服务端地址如下:
cpp
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(8888);
local.sin_addr.s_addr = htonl(INADDR_ANY);
int rc = bind(fd, reinterpret_cast<sockaddr*>(&local), sizeof(local));
这里 local 描述自己要使用的地址。bind 并不是告诉服务器把消息发到哪里,它是在确定本地入口。
使用 {} 初始化可以把整个结构体清零,再逐一填写有效字段。端口用 htons 转成网络字节序;sin_family 按地址族常量填写,不需要转换。
3.3 0.0.0.0 与 127.0.0.1 怎么选
INADDR_ANY 对 IPv4 来说对应通配地址,适合接收发往本机不同 IPv4 地址的连接。127.0.0.1 则把服务限定在回环接口,适合本机实验。
本文完整代码默认绑定 127.0.0.1,便于与客户端在同一 Linux 环境内验证。如果要从其他主机访问,可以把监听地址改成 0.0.0.0 或目标网卡地址,然后让客户端填写服务器实际可达的 IP。
不要把客户端的目标地址写成 0.0.0.0,也不要把服务器的私网 IP 自动当成公网可达地址。外部访问还涉及路由、监听接口、防火墙和云安全组。
3.4 setsockopt 不能把两个选项按位或
课程代码中出现了:
cpp
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
这里值得单独修正:第三个参数 optname 是一个选项名 ,并不是选项位集合。需要两个选项时,应分别调用两次,并各自检查结果。本文只设置 SO_REUSEADDR,不引入多个监听者共享端口的机制。
cpp
int enabled = 1;
if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR,
&enabled, sizeof(enabled)) < 0) {
// 报告设置失败。
}
SO_REUSEADDR 能帮助处理常见的地址复用场景,但不会让两个普通监听程序随意占据同一个完全相同的入口。端口正在被另一个服务占用时,仍然需要定位占用者。
四、listen 的作用与 backlog 的边界
bind 成功后,Socket 还没有进入监听角色。接下来需要:
cpp
if (listen(fd, 16) < 0) {
// 监听失败。
}
listen 返回 0 表示成功,返回 -1 表示失败。它不创建应用线程,也不会自动执行我们的回显函数。

4.1 backlog 不是最大在线人数
在现代 Linux 上,TCP 的 backlog 主要描述已经建立、等待应用 accept 的连接队列长度 ,并受系统限制约束。未完成握手的请求队列属于另一套相关机制。Linux listen 手册
因此不能把 listen(fd, 16) 理解为"整个服务最多支持 16 个用户"。已经被 accept 取走、正在处理的连接,并不等同于这个等待队列中的条目。
4.2 调大 backlog 为什么修不好串行阻塞
如果主线程在客户端 A 上一直 recv,它就没有执行下一次 accept。即使等待队列更大,也只是让更多连接暂时等待,不能使主线程同时处理 B 的业务。
这个区别可以概括为:**队列负责暂存,执行模型决定谁来服务。**把 backlog 改大与引入多线程,解决的是不同问题。
4.3 队列满时不能承诺一种固定现象
课程为了便于入门,常把队列溢出概括成"后续请求无法正常进入"。实际观察还可能受到内核配置、重传、超时与客户端时机影响,不能简单认定每个多余请求都会立刻得到同一种错误。
本篇不展开全连接队列的抓包细节,后面的 TCP 专题和加餐会继续讨论。这里先记住它与应用线程池队列也不是同一个队列。
五、accept 为什么返回另一个描述符
这是从 UDP 转到 TCP 时最值得停下来想清楚的地方。
cpp
sockaddr_in peer{};
socklen_t length = sizeof(peer);
int connection = accept(listener,
reinterpret_cast<sockaddr*>(&peer), &length);
listener 仍然负责监听。成功返回的 connection 是新的通信入口,用来访问本次接收的连接;原来的监听 Socket 保持监听角色。Linux accept 手册

5.1 用饭店接客的例子理解
可以把监听 Socket 看成饭店门口的接待处。接待处持续等待顾客,但不负责给每一桌上菜。
accept 相当于把一位已经到达的顾客交给具体服务入口;连接 Socket 则对应这位顾客的后续交互。顾客离开后,关闭本次连接,不需要把整个接待处一起关闭。
这个比喻只用于区分职责。真实系统中的连接状态、队列和内核对象,比"门口与餐桌"的对应关系更具体;不要用比喻反过来推导 Socket API 的细节。
5.2 地址长度为什么要先赋值
length 是传入传出参数:调用前说明 peer 缓冲区的容量,调用后表示实际返回的地址长度。
不要声明一个没有初始化的 socklen_t 就把地址传进去,也不要把 sizeof(peer) 直接取地址。循环里应在每次调用前重新准备长度。
如果根本不关心客户端地址,可以使用 accept(listener, nullptr, nullptr)。本文保留地址结构,是为了明确接口的输入与输出。
5.3 两种描述符的生命周期
| 描述符 | 谁创建 | 何时关闭 | 关闭后影响 |
|---|---|---|---|
| 监听描述符 | socket,随后 bind/listen |
服务整体结束时 | 此入口不再继续接收新连接 |
| 连接描述符 | accept |
本次连接处理结束时 | 释放这一条连接的本地引用 |
| 客户端描述符 | socket,随后 connect |
客户端结束时 | 结束客户端对连接的使用 |
**关闭监听入口不会自动替你关闭所有已接收的连接。**反过来,关闭某个客户端的连接,也不应该把监听入口一起关掉。
以后加入 fork 后,还会出现父子进程各自持有描述符副本的问题。那时 close 释放的是当前进程中的引用,需要把所有副本的责任都考虑进去。
六、connect 与客户端的临时端口
客户端创建 Socket 后,把服务端地址交给 connect:
cpp
sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(8888);
int rc = inet_pton(AF_INET, "127.0.0.1", &server.sin_addr);
if (rc != 1) {
// 地址文本无效,不能继续使用未正确转换的地址。
}
int result = connect(fd, reinterpret_cast<sockaddr*>(&server), sizeof(server));
6.1 bind 和 connect 的地址含义不同
虽然两者都接受 Socket、地址指针和地址长度,但 bind 指定本地地址,connect 指定远端地址。不能因为参数形式相似,就把服务器地址传给客户端 bind。
TCP 客户端通常不显式固定本地端口,内核会在连接过程中根据目标、路由与可用端口选择本地端点。端口属于临时端口范围,不是任意整数意义上的"随机编号"。
6.2 客户端也能主动 bind
如果业务需要选择本地网卡、源 IP 或指定源端口,客户端可以在 connect 前调用 bind。只是本例没有这个需求。
同样,在 Linux 中监听 Socket 可以自动获得本地端口,但普通服务通常显式绑定稳定端口,否则客户端还需要额外机制发现入口。
6.3 connect 不会返回通信 fd
connect 成功返回 0,失败返回 -1。它不会像 accept 一样返回一个新的通信描述符。客户端后续继续使用原来的 fd。
若连接失败,本文直接报告错误并让 RAII 关闭 Socket。需要重连时,更清晰的做法是建立一个新的 Socket 再发起连接;重连策略、退避与业务状态恢复属于另外的加餐内容。
七、字节流为什么不能按一次收发理解
现在连接建立了,但如果直接照搬 UDP 的"一次发送对应一次接收",程序仍然会出现问题。
假设客户端执行:
text
send("hello")
send("world")
接收端可能分别得到 hello 和 world,也可能得到 helloworld,或者先得到 hel,再得到 lowor,最后得到 ld。这里讨论的是应用读取到的字节分组,不是在规定底层实际分成几个 TCP 报文。

7.1 TCP 保留顺序,不保留应用消息边界
正确接收后的连续字节仍然按发送顺序排列。但一次 send 的范围不是接收端一次 recv 的范围。读取长度还受当前到达数据、缓冲大小和读取时机影响。
所谓"粘包、拆包",在应用编程层面通常就是:程序没有正确识别自己的消息边界,却试图把一次读取当成一条完整消息。
这个问题不是靠 sleep、调整缓冲区大小或要求发送者"慢一点"就能解决的。偶尔实验成功,只能说明那一次恰好满足了程序的假设。
7.2 纯字节回显与请求/响应回显
纯字节回显可以读多少、原样发回多少,它不一定需要理解完整消息。但我们的交互客户端希望知道"这一条请求的响应已经收全了",需要一条明确的边界约定。
课程 V1 在读取之后拼接 server echo# ,用于观察效果。如果把每次读取都当成一条请求,前缀可能随着读取分片重复出现。因此,整理后的示例采用"识别一帧,再原样回显这一帧"的方式。
7.3 本文只补一个最小边界
后面的课程会专门讨论自定义协议与序列化。这里先采用一个足够小的约定,让回显和下篇的多行命令响应都有确定结束位置:
text
4 字节长度头(uint32_t,网络字节序) + 正文
长度只计算正文的字节数,不包含长度头。正文上限为 65536 字节;空正文对应值为 0 的长度头。UTF-8 按字节计数,不按汉字个数计数。

这里没有设计请求 ID、业务类型、校验字段或复杂序列化,重点只是打破"一次读取就是一条消息"的假设。长度头是本文追加的工程补充,并不是 TCP 自动提供的功能,也不是 PDF V1 已经实现的内容。
7.4 怎么把一帧读完整
接收端先循环读满 4 字节,再通过 ntohl 得到正文长度。长度通过上限检查后,再循环读满正文。
如果两帧合在一次网络到达中,第一次读取最多只取当前帧需要的字节,下一帧留给后续调用。如果一帧分成多次到达,读取循环会不断累计已有字节。
这个方法处理的是边界识别,不能阻止客户端发送长度头后一直不发正文。要限制等待时间,还需要超时或事件驱动机制,本文不会把边界检查夸大成完整的抗滥用设计。
八、返回值、部分发送与半关闭
读取和发送都应该根据实际返回值推进,而不是根据"希望读写多少"推进。
8.1 recv/read 的三种结果
对已经建立的 TCP 连接,使用非零容量读取时:
| 返回值 | 应怎样解释 | 本文处理 |
|---|---|---|
n > 0 |
实际读到 n 字节 | 累加 n,继续读取所需内容 |
n == 0 |
当前接收方向到达 EOF | 在帧边界结束,或报告帧截断 |
n < 0 |
发生错误 | EINTR 重试,其余报告错误 |
EINTR 表示系统调用被信号中断,不能直接解释为客户端已经断开。非阻塞场景中的 EAGAIN/EWOULDBLOCK 需要不同处理;本文没有启用非阻塞模式。
还要区分"TCP 读取 EOF"和"应用空消息":本例的空消息仍然带有完整 4 字节长度头。没有正文不代表连接关闭。
8.2 send/write 可能只发送一部分
如果想发送 10000 字节,返回值是 4096,就应把偏移推进 4096,继续发送剩余字节。不能无视返回值,也不能重新从第一个字节发送,否则可能重复数据。
cpp
std::size_t done = 0;
while (done < length) {
ssize_t n = send(fd, bytes + done, length - done, MSG_NOSIGNAL);
// n > 0:done += n;EINTR:重试;其他结果:处理错误。
}
send 正数返回值说明本次有相应字节被本地 Socket 接受,不等于对端业务已经处理。Linux send 手册
8.3 为什么使用 MSG_NOSIGNAL
向已经无法正常写入的连接继续发送,可能产生 EPIPE 和 SIGPIPE。默认信号行为可能使整个程序结束。
Linux 的 MSG_NOSIGNAL 可以让本次发送抑制 SIGPIPE,但错误仍然需要根据返回值处理。本文把它放进 send_all,使一条坏连接不会通过默认信号行为直接结束整个服务器。Linux send 手册
8.4 EOF 不代表两个方向都已经关闭
TCP 是双向流。客户端可以 shutdown(fd, SHUT_WR),表示自己不会再发送,但仍然继续接收服务端数据。
服务器在读完已到达的数据后可能观察到 EOF,这只表明对端发送方向结束。服务器是否继续发送最后的响应,取决于应用协议与当前处理状态。

shutdown 用来改变连接方向的使用状态,close 则释放描述符。调用 shutdown 后,最后仍然需要关闭描述符。完整示例中,服务器先回显已读取的完整帧,再读取下一帧;客户端在最后一帧后关闭发送方向,仍可接收最后的回显。
九、整理一份可复用的 TCP 基础代码
PDF 代码依赖课程已有的 Log.hpp、nocopy.hpp 等文件。为了让本篇文件夹能够独立构建,下面集中提供一份 tcp.hpp,统一处理地址、资源所有权与收发边界。
源文件同时保存在 <code/tcp.hpp>。完整程序只需复制本文的三个文件,不依赖前面的课程工程。
9.1 描述符为什么禁用复制
如果两个对象直接复制同一个整数 fd,析构时都会调用 close。第一次关闭后,这个整数可能已经被系统重新分配,第二次关闭就可能影响另一个资源。
因此 Fd 禁用复制,允许移动。移动表示把资源责任从旧对象交给新对象,旧对象变成无效状态 -1。这里没有引入 dup,也没有让一个普通对象自动承担共享引用计数的责任。
9.2 为什么端口先校验再转换
直接把 stoi 的结果赋给 uint16_t,可能使超范围整数发生截断。本文先检查字符是否都是十进制数字,再检查范围 1~65535,最后才转换类型。
9.3 帧的异常边界
在下一帧长度头开始前遇到 EOF,可以视为正常结束。长度头读了一部分才遇到 EOF,或者正文未读满就结束,说明收到的是截断帧。
长度头宣称正文超过上限时,在分配正文内存之前就拒绝连接。这个顺序避免"先按对端声明分配巨大内存,再去发现不合理"。
9.4 完整的 tcp.hpp
cpp
#pragma once
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdint>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
namespace tcp {
constexpr std::size_t max_frame = 65536;
inline void fail(const char* operation) {
int error = errno;
throw std::runtime_error(std::string(operation) + ": " + std::strerror(error));
}
class Fd {
int value_ = -1;
public:
explicit Fd(int value = -1) noexcept : value_(value) {}
~Fd() { reset(); }
Fd(const Fd&) = delete;
Fd& operator=(const Fd&) = delete;
Fd(Fd&& other) noexcept : value_(other.release()) {}
Fd& operator=(Fd&& other) noexcept {
if (this != &other) reset(other.release());
return *this;
}
int get() const noexcept { return value_; }
int release() noexcept { return std::exchange(value_, -1); }
void reset(int value = -1) noexcept {
if (value_ >= 0) ::close(value_);
value_ = value;
}
};
inline std::uint16_t port(const std::string& text) {
if (text.empty()) throw std::invalid_argument("empty port");
unsigned value = 0;
for (unsigned char c : text) {
if (c < '0' || c > '9') throw std::invalid_argument("port must be decimal");
value = value * 10 + (c - '0');
if (value > 65535) throw std::invalid_argument("port out of range");
}
if (value == 0) throw std::invalid_argument("port must be 1..65535");
return static_cast<std::uint16_t>(value);
}
inline sockaddr_in address(const std::string& ip, std::uint16_t p) {
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(p);
if (::inet_pton(AF_INET, ip.c_str(), &addr.sin_addr) != 1)
throw std::invalid_argument("invalid IPv4 address");
return addr;
}
inline Fd socket() {
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) fail("socket");
return Fd(fd);
}
inline Fd listen(std::uint16_t p, const std::string& ip = "127.0.0.1") {
Fd fd = socket();
int enabled = 1;
if (::setsockopt(fd.get(), SOL_SOCKET, SO_REUSEADDR,
&enabled, sizeof(enabled)) < 0) fail("setsockopt");
auto addr = address(ip, p);
if (::bind(fd.get(), reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0)
fail("bind");
if (::listen(fd.get(), 16) < 0) fail("listen");
return fd;
}
inline Fd accept(int listener, sockaddr_in& peer) {
for (;;) {
socklen_t length = sizeof(peer);
int fd = ::accept(listener, reinterpret_cast<sockaddr*>(&peer), &length);
if (fd >= 0) return Fd(fd);
if (errno == EINTR) continue;
fail("accept");
}
}
inline Fd connect(const std::string& ip, std::uint16_t p) {
auto addr = address(ip, p);
Fd fd = socket();
if (::connect(fd.get(), reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0)
fail("connect");
return fd;
}
inline void send_all(int fd, const void* data, std::size_t length) {
auto bytes = static_cast<const char*>(data);
std::size_t done = 0;
while (done < length) {
ssize_t n = ::send(fd, bytes + done, length - done, MSG_NOSIGNAL);
if (n > 0) done += static_cast<std::size_t>(n);
else if (n < 0 && errno == EINTR) continue;
else if (n < 0) fail("send");
else throw std::runtime_error("send made no progress");
}
}
// false: 在本次读取开始前遇到 EOF;中途 EOF 表示截断。
inline bool recv_exact(int fd, void* data, std::size_t length) {
auto bytes = static_cast<char*>(data);
std::size_t done = 0;
while (done < length) {
ssize_t n = ::recv(fd, bytes + done, length - done, 0);
if (n > 0) done += static_cast<std::size_t>(n);
else if (n < 0 && errno == EINTR) continue;
else if (n < 0) fail("recv");
else if (done == 0) return false;
else throw std::runtime_error("truncated frame");
}
return true;
}
inline void send_frame(int fd, const std::string& payload) {
if (payload.size() > max_frame) throw std::length_error("frame too large");
std::uint32_t size = htonl(static_cast<std::uint32_t>(payload.size()));
send_all(fd, &size, sizeof(size));
send_all(fd, payload.data(), payload.size());
}
inline bool recv_frame(int fd, std::string& payload) {
std::uint32_t size = 0;
if (!recv_exact(fd, &size, sizeof(size))) return false;
std::size_t length = ntohl(size);
if (length > max_frame) throw std::length_error("frame too large");
payload.resize(length);
if (length && !recv_exact(fd, payload.data(), length))
throw std::runtime_error("missing frame body");
return true;
}
inline void echo(int fd) noexcept {
try {
std::string request;
while (recv_frame(fd, request)) send_frame(fd, request);
} catch (const std::exception& e) {
std::cerr << "connection: " << e.what() << '\n';
}
}
} // namespace tcp
代码中 tcp::echo 只负责处理连接,连接描述符由外部的 Fd 对象拥有。这使我们下篇可以保持服务函数不变,只改变"谁来执行它"。
两个 send_all 分别发送长度头与正文,接收端仍然按长度解释。协议没有要求它们在网络上形成两个报文,也没有要求接收端正好执行两次底层读取。
十、完整的回显服务器与客户端
10.1 串行服务器 echo_server.cpp
cpp
#include "tcp.hpp"
int main(int argc, char** argv) {
try {
if (argc != 2) throw std::invalid_argument("usage: echo_server PORT");
auto p = tcp::port(argv[1]);
auto listener = tcp::listen(p);
std::cout << "listening 127.0.0.1:" << p << std::endl;
for (;;) {
sockaddr_in peer{};
auto connection = tcp::accept(listener.get(), peer);
tcp::echo(connection.get());
} // connection 析构后,才进入下一次 accept。
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
return 1;
}
}
这段主循环保留了课程 V1 的执行顺序:获得一条连接后一直提供服务,直到该客户端结束,再去接收下一条连接。
connection 在循环本次迭代内创建,服务结束后析构。listener 在整个 main 的正常资源作用域中持有,创建失败、绑定失败或发生异常时也能回收。
10.2 交互客户端 tcp_client.cpp
cpp
#include "tcp.hpp"
int main(int argc, char** argv) {
try {
if (argc != 3) throw std::invalid_argument("usage: tcp_client IP PORT");
auto fd = tcp::connect(argv[1], tcp::port(argv[2]));
std::string request, response;
while (std::cout << "request> " && std::getline(std::cin, request)) {
if (request == "/quit") break;
tcp::send_frame(fd.get(), request);
if (!tcp::recv_frame(fd.get(), response)) {
std::cout << "server EOF\n";
break;
}
std::cout << "response [" << response.size() << "]: ";
std::cout.write(response.data(), static_cast<std::streamsize>(response.size()));
std::cout << '\n';
}
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
return 1;
}
}
客户端输入一行后发送一帧。空行是合法空帧;/quit 是客户端本地退出指令,不发送给服务端。终端 EOF 也会结束输入循环。
std::cout.write 根据响应长度输出,而不是假设正文是以 \0 结尾的 C 字符串。这与收发层支持任意正文中的零字节保持一致。不过交互界面按行读取,真正的二进制用例更适合通过测试脚本验证。
10.3 程序退出与服务边界
本文服务器使用无限接收循环,终端中用 Ctrl+C 结束实验。它没有实现完整的信号驱动平滑停机。进程结束时内核会释放资源,但这和"等待所有业务完成后退出"的产品需求不同。
客户端也没有设置连接或接收超时。连接可达但对端不回应时,程序可能等待。后续课程学习非阻塞 IO 和事件循环后,会进一步处理等待与时间控制。
十一、编译运行与分步实验
11.1 构建两个程序
进入本篇的 code 文件夹:
bash
make
# 或分别执行:
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 echo_server.cpp -o echo_server
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 tcp_client.cpp -o tcp_client
终端 A 启动服务器:
bash
./echo_server 8888
终端 B 启动客户端:
bash
./tcp_client 127.0.0.1 8888
输入 hello、中文和一个空行,观察每次响应的字节长度。输入 /quit 后客户端结束,服务端可以继续接收下一个客户端。
11.2 用 ss 分开观察监听与连接
bash
ss -lnt 'sport = :8888'
ss -nt '( sport = :8888 or dport = :8888 )'
第一条聚焦监听入口,第二条查看相关 TCP 连接。观察客户端的本地临时端口,再和服务端所看到的远端端口对应起来。
这些命令只是观察工具,不替程序创建连接。监听存在,也不能保证应用此刻有空闲执行线程处理新业务。
11.3 不能直接用 nc 发送纯文本
因为完整示例加入了 4 字节长度头,直接运行 echo hello | nc ... 不符合本文协议。服务端会把前四个正文字符误读成长度头,然后拒绝或等待。
可以用 Python 构造一帧验证:
python
import socket, struct
def read_exact(s, length):
chunks = bytearray()
while len(chunks) < length:
chunk = s.recv(length - len(chunks))
if not chunk:
raise EOFError("response truncated")
chunks.extend(chunk)
return bytes(chunks)
with socket.create_connection(("127.0.0.1", 8888), timeout=3) as s:
body = "你好,TCP".encode("utf-8")
s.sendall(struct.pack("!I", len(body)) + body)
size = struct.unpack("!I", read_exact(s, 4))[0]
if size > 65536:
raise ValueError("response too large")
print(read_exact(s, size).decode("utf-8"))
!I 表示网络字节序的 32 位无符号整数。它与 C++ 的 htonl/ntohl 构成同一约定。
11.4 更值得验证的情况

除了 hello,还可以逐步验证:
- 空正文与包含
\0的正文,确认长度与字符串终止符不会混淆; - 把长度头和正文分段发送,确认接收循环能够累计;
- 一次发送多帧,确认服务端能分开解析;
- 发送最后一帧后
shutdown(SHUT_WR),确认仍能读取最终响应; - 声明超大长度或发送半帧后断开,确认该连接结束后服务仍能接待下一位客户端。
这些实验验证的是应用的帧处理,不需要赌"网络一定会按某种方式拆包"。分段发送可以人为制造数据分批到达条件,合并发送则可以验证消息边界不依赖调用次数。
实际构建与检查结果集中保存在 验证记录,避免把示意输出当成未经验证的运行结果。
十二、第二个客户端为什么迟迟没有响应
保留客户端 A 的连接,但暂时不要输入新内容。随后打开客户端 B,连接同一个服务端,再输入 hello。

此时主线程可能正在等待 A 的下一帧。它还没有从 A 的服务函数返回,因此也没有执行下一次 accept 来处理 B。
12.1 B 可能已经 connect 成功
只要监听队列尚有空间,服务端内核仍然可以处理 B 的普通 TCP 建连。B 的 connect 成功并不与"A 的业务还在阻塞"矛盾。
但 B 的请求暂时没有应用线程处理,所以它可能在等待响应。不能把这个现象概括成"TCP 不允许同时连接两个客户端"。
12.2 先定位阻塞在哪一层
| 观察 | 优先判断 |
|---|---|
connect 报错或超时 |
地址、监听、网络路径与建连条件 |
connect 成功,发送后无响应 |
应用是否接收连接、是否处理请求、协议是否一致 |
| 响应只有一部分 | 帧读取是否完整、发送返回值是否处理 |
| 第一位退出后第二位立刻有响应 | 串行服务是否一直被第一条连接占用 |
12.3 下篇要改变什么
本篇的收发代码不需要重新设计。关键变化是把 Service(connection) 交给另外的执行者,使主线程能尽快回到 accept。
执行者可以是子进程、独立线程或线程池的工作线程。不同模型的成本、资源边界和退出方式不同,我们将在下篇逐一展开。
总结
这篇文章沿着一条连接的生命周期,串起了 TCP Socket 编程的基本流程:
socket创建通信资源,bind确定本地入口,listen建立被动监听角色;accept返回连接描述符,监听描述符继续承担接收新连接的职责;- 客户端通过
connect指定远端,成功后继续使用原来的 Socket; - TCP 交付有序字节流,应用自己决定一条消息在哪里结束;
- 收发按实际返回值推进,资源按明确的所有权释放;
- 串行服务器的问题来自执行流程,需要通过并发模型继续改善。
如果现在能解释"监听 fd 与连接 fd 为什么不是一回事",也能解释"连接成功为什么还可能没有业务响应",后面多进程、多线程和 IO 多路复用的学习就有了清晰起点。
🌟 如果这篇文章帮你把 TCP 的调用链和字节流理解串起来,欢迎点赞、收藏,也欢迎在评论区记录你的实验现象。这里是 星光编译者,我们继续在编译与调试中搭建自己的技术世界。