本节重点
理解应用层的作用, 初识HTTP协议
理解传输层的作用, 深入理解TCP的各项特性和机制
对整个TCP/IP协议有系统的理解
对TCP/IP协议体系下的其他重要协议和技术有一定的了解
学会使用一些分析网络问题的工具和方法
1.应用层
我们程序员写的一个个解决我们实际问题, 满足我们日常需求的网络程序, 都是在应用层.
1.1再谈 "协议"
协议是一种 "约定". socket api的接口, 在读写数据时, 都是按 "字符串" 的方式来发送接收的. 如果我们要传输一些"结构化的数据" 怎么办呢?
1.2网络版计算器
例如, 我们需要实现一个服务器版的加法器. 我们需要客户端把要计算的两个加数发过去, 然后由服务器进行计算, 最后再把结果返回给客户端
约定方案一:
客户端发送一个形如"1+1"的字符串;
这个字符串中有两个操作数, 都是整形;
两个数字之间会有一个字符是运算符, 运算符只能是 + ;
数字和运算符之间没有空格;
约定方案二:
定义结构体来表示我们需要交互的信息;
发送数据时将这个结构体按照一个规则转换成字符串, 接收到数据的时候再按照相同的规则把字符串转化回结构体;
这个过程叫做 "序列化" 和"反序列化"
cpp
// proto.h 定义通信的结构体
typedef struct Request {
int a;
int b;
} Request;
typedef struct Response {
int sum;
} Response;
// client.c 客户端核心代码
Request request;
Response response;
scanf("%d,%d", &request.a, &request.b);
write(fd, request, sizeof(Request));
read(fd, response, sizeof(Response));
// server.c 服务端核心代码
Request request;
read(client_fd, &request, sizeof(request));
Response response;
response.sum = request.a + request.b;
write(client_fd, &response, sizeof(response));
无论我们采用方案一, 还是方案二, 还是其他的方案, 只要保证, 一端发送时构造的数据, 在另一端能够正确的进行解析, 就是ok的. 这种约定, 就是 应用层协议
1.3HTTP****协议
虽然我们说, 应用层协议是我们程序猿自己定的. 但实际上, 已经有大佬们定义了一些现成的, 又非常好用的应用层协议, 供我们直接参考使用. HTTP(超文本传输协议) 就是其中之一.
1.3.1认识****URL
平时我们俗称的 "网址" 其实就是说的 URL

| 组成部分 | 示例内容 | 核心作用与补充说明 |
|---|---|---|
| 协议方案名 | http |
定义客户端与服务器的通信规则,:// 是协议与后续内容的固定分隔符。常见同类协议:https(加密 HTTP)、ftp(文件传输)、wss(WebSocket 加密)等;HTTP 默认端口 80,HTTPS 默认端口 443。 |
| 登录认证信息 | user:pass |
可选模块,用于访问需身份校验的资源时,明文传递用户名(user)和密码(pass),@ 是认证信息与服务器地址的分隔符。安全提示:该方式会明文暴露账号凭证,存在严重安全风险,当前主流网站已完全弃用。 |
| 服务器地址 | www.example.jp |
定位目标资源所在的服务器,可填写域名(如示例)或 IP 地址;域名会通过 DNS 系统解析为服务器的 IP 地址,最终实现网络寻址。 |
| 服务器端口号 | 80 |
可选模块,指定访问服务器的具体服务端口,: 是地址与端口的分隔符。每个网络服务对应唯一端口,使用协议默认端口时,该部分可省略不写。 |
| 带层次的文件路径 | /dir/index.htm |
定位服务器上目标资源的具体存储路径,对应服务器的文件目录结构,示例中即根目录下 dir 文件夹中的 index.htm 文件。 |
| 查询字符串 | ?uid=1 |
可选模块,? 是查询字符串的起始标记,后续为键值对格式的请求参数,用于向服务器传递额外的自定义数据,多参数用&分隔(如?uid=1&type=2),服务器可根据参数动态返回内容。 |
| 片段标识符 | #ch1 |
可选模块,也叫锚点,# 是起始标记,用于定位资源内部的子位置(如网页的指定章节、段落)。关键特性:该部分仅在浏览器本地生效,不会发送到服务器端。 |
1.3.2urlencode和urldecode
像 / ? : 等这样的字符, 已经被url当做特殊意义理解了. 因此这些字符不能随意出现.
比如, 某个参数中需要带有这些特殊字符, 就必须先对特殊字符进行转义.
转义的规则如下:
将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY格式
例如:

- urlencode(URL 编码) :将不安全字符转换为
%XX格式(XX 是字符 ASCII 码的两位十六进制数),空格常额外编码为+; - urldecode(URL 解码) :将
%XX格式还原为原始字符,+还原为空格。
urldecode就是urlencode的逆过程;
1.3.3HTTP****协议格式
HTTP请求

首行: [方法] + [url] + [版本]
Header: 请求的属性, 冒号分割的键值对;每组属性之间使用\n分隔;遇到空行表示Header部分结束
Body: 空行后面的内容都是Body. Body允许为空字符串. 如果Body存在, 则在Header中会有一个
Content-Length属性来标识Body的长度;
HTTP响应

首行: [版本号] + [状态码] + [状态码解释]
Header: 请求的属性, 冒号分割的键值对;每组属性之间使用\n分隔;遇到空行表示Header部分结束
Body: 空行后面的内容都是Body. Body允许为空字符串. 如果Body存在, 则在Header中会有一个
Content-Length属性来标识Body的长度; 如果服务器返回了一个html页面, 那么html页面内容就是在
body中.
**1.3.**4HTTP的方法

其中最常用的就是GET方法和POST方法.
- 高频业务方法(开发中最常用)
- GET :用于查询 / 获取资源,参数通常放在 URL 中,幂等且安全(多次请求不改变服务器状态),适合被浏览器缓存。
- POST :用于提交数据(如表单、JSON),参数在请求体中,非幂等(多次提交可能创建多个资源),适合创建、提交类操作。
- PUT :用于完整上传 / 覆盖资源,幂等(多次请求效果相同),适合更新已有资源。
- DELETE :用于删除指定资源,幂等,适合资源删除操作。
- HEAD:与 GET 逻辑一致,但仅返回响应头(无响应体),常用于检查资源是否存在、获取文件大小 / 修改时间等元数据。
- HTTP/1.1 新增方法
- OPTIONS:查询服务器支持的请求方法,也是跨域请求(CORS)的预检方法,用于确认跨域请求是否被允许。
- TRACE:回显服务器收到的原始请求,主要用于网络调试,但存在 XSS 安全风险,现代浏览器默认禁用。
- CONNECT:用于建立隧道连接,核心场景是 HTTPS 代理,让代理服务器转发加密的 SSL/TLS 数据。
- 已废弃方法
- LINK/UNLINK:仅在 HTTP/1.0 中定义,用于建立 / 断开资源间的关联,已被 RFC 2616 正式废弃,现代 Web 开发中无实际使用场景。
1.3.5HTTP****的状态码

- 1XX(信息性状态码)
仅用于临时告知客户端请求正在处理,无最终结果:
100 Continue:客户端可继续发送剩余请求体(常用于大文件上传)101 Switching Protocols:服务器同意切换协议(如升级到 WebSocket)
- 2XX(成功状态码)
代表服务器已成功接收并完成请求处理:
200 OK:最常见的成功状态,请求完全正常201 Created:请求成功并创建了新资源(如 POST 新建用户)204 No Content:请求成功,但无响应体返回(如 DELETE 删除资源)
- 3XX(重定向状态码)
需要客户端进一步操作(如跳转新地址)才能完成请求:
301 Moved Permanently:资源永久迁移,搜索引擎会更新索引302 Found:资源临时迁移,搜索引擎不会更新索引304 Not Modified:资源未变更,客户端可直接使用本地缓存
- 4XX(客户端错误状态码)
问题出在客户端请求本身,服务器无法处理:
400 Bad Request:请求语法错误或参数无效401 Unauthorized:未通过身份验证,需要登录 / 授权403 Forbidden:服务器拒绝访问(权限不足)404 Not Found:请求的资源不存在405 Method Not Allowed:请求方法不被允许(如用 POST 访问仅支持 GET 的接口)
- 5XX(服务器错误状态码)
问题出在服务器端,处理请求时发生内部故障:
500 Internal Server Error:服务器内部未知错误502 Bad Gateway:网关 / 代理收到上游服务器的无效响应503 Service Unavailable:服务器暂时过载或维护504 Gateway Timeout:网关 / 代理等待上游服务器响应超时
最常见的状态码, 比如 200(OK), 404(Not Found), 403(Forbidden), 302(Redirect, 重定向), 504(Bad Gateway)
1.3.6HTTP常见Header
Content-Type: 数据类型(text/html等)
Content-Length: Body的长度
Host: 客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上;
User-Agent: 声明用户的操作系统和浏览器版本信息;
referer: 当前页面是从哪个页面跳转过来的;
location: 搭配3xx状态码使用, 告诉客户端接下来要去哪里访问;
Cookie: 用于在客户端存储少量信息. 通常用于实现会话(session)的功能;
1.3.7最简单的HTTP服务器
实现一个最简单的HTTP服务器, 只在网页上输出 "hello world"; 只要我们按照HTTP协议的要求构造数据, 就很容易能做到;
cpp
// 网络编程核心头文件:提供socket、bind、listen、accept等函数声明
#include <sys/socket.h>
// 定义IPv4地址结构体sockaddr_in
#include <netinet/in.h>
// 提供inet_addr(字符串IP转网络字节序)等IP地址转换函数
#include <arpa/inet.h>
// 提供close、read、write等文件/套接字操作函数
#include <unistd.h>
// 标准输入输出(printf、perror)
#include <stdio.h>
// 字符串操作(strlen、memset、sprintf等)
#include <string.h>
// 标准库(atoi:字符串转整数)
#include <stdlib.h>
// 打印程序使用方法的辅助函数
void Usage() {
// 提示用户:程序运行时需要传入IP和端口参数,例如 ./server 127.0.0.1 8080
printf("usage: ./server [ip] [port]\n");
}
int main(int argc, char* argv[]) {
// 检查命令行参数个数:程序名+IP+端口 共3个参数,否则打印使用方法并退出
if (argc != 3) {
Usage();
return 1;
}
// 1. 创建TCP套接字(socket)
// AF_INET:使用IPv4协议族
// SOCK_STREAM:流式套接字(TCP协议,可靠、面向连接)
// 0:默认协议(TCP)
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { // socket创建失败返回-1
perror("socket"); // 打印错误原因(如权限不足、资源耗尽)
return 1;
}
// 2. 初始化服务器地址结构体
struct sockaddr_in addr; // IPv4地址结构体
memset(&addr, 0, sizeof(addr)); // 初始化结构体为0,避免脏数据
addr.sin_family = AF_INET; // 协议族:IPv4
// 把字符串格式的IP(如127.0.0.1)转为网络字节序的32位整数
addr.sin_addr.s_addr = inet_addr(argv[1]);
// 把主机字节序的端口(如8080)转为网络字节序(大端序)
// 网络协议统一使用大端序,避免不同主机字节序差异
addr.sin_port = htons(atoi(argv[2]));
// 3. 绑定套接字到指定IP和端口(bind)
// fd:待绑定的套接字描述符
// (struct sockaddr*)&addr:通用地址结构体(强制转换)
// sizeof(addr):地址结构体长度
int ret = bind(fd, (struct sockaddr*)&addr, sizeof(addr));
if (ret < 0) { // 绑定失败(如端口被占用、IP不可用)
perror("bind");
return 1;
}
// 4. 将套接字设为监听状态(listen)
// fd:服务器套接字
// 10:监听队列长度(最多同时挂起10个未处理的连接请求)
ret = listen(fd, 10);
if (ret < 0) { // 监听失败
perror("listen");
return 1;
}
// 5. 无限循环:持续接受客户端连接(服务器核心逻辑)
for (;;) {
struct sockaddr_in client_addr; // 存储客户端的IP和端口信息
socklen_t len = sizeof(client_addr); // 客户端地址结构体长度(入参+出参)
// 接受客户端连接(阻塞函数,无连接时等待)
// fd:监听套接字
// (struct sockaddr*)&client_addr:输出客户端地址信息
// &len:输入结构体长度,输出实际地址长度
int client_fd = accept(fd, (struct sockaddr*)&client_addr, &len);
if (client_fd < 0) { // 接受连接失败(如被信号中断)
perror("accept");
continue; // 跳过本次错误,继续等待下一个连接
}
// 6. 读取客户端发送的HTTP请求数据
char input_buf[1024 * 10] = {0}; // 10KB缓冲区,初始化全0
// 从客户端套接字读取数据(阻塞)
// client_fd:客户端连接套接字
// input_buf:存储数据的缓冲区
// sizeof(input_buf) - 1:预留1字节避免越界,保证字符串以\0结尾
ssize_t read_size = read(client_fd, input_buf, sizeof(input_buf) - 1);
if (read_size < 0) { // 读取失败
close(client_fd); // 关闭客户端套接字,避免资源泄漏
continue;
}
// 打印客户端的HTTP请求内容(调试用)
printf("[Request] %s", input_buf);
// 7. 构造HTTP响应数据(符合HTTP/1.0协议规范)
char buf[1024] = {0}; // 响应缓冲区
const char* hello = "<h1>hello world</h1>"; // 响应体内容
// 格式化HTTP响应:
// HTTP/1.0 200 OK:状态行(协议版本+状态码+原因短语)
// Content-Length:响应体长度(必须指定,否则客户端不知道何时读完)
// 空行:HTTP头和响应体的分隔符(\n\n)
// %lu:strlen返回unsigned long,匹配格式符避免警告
sprintf(buf, "HTTP/1.0 200 OK\nContent-Length:%lu\n\n%s", strlen(hello), hello);
// 8. 发送HTTP响应给客户端
write(client_fd, buf, strlen(buf));
// 注意:此处未关闭client_fd,会导致连接复用/资源泄漏
// 生产环境需:close(client_fd); 或基于keep-alive处理
}
// 理论上不会执行到这里(无限循环),关闭监听套接字(规范)
close(fd);
return 0;
}
编译, 启动服务. 在浏览器中输入 http://[ip]:[port], 就能看到显示的结果 "Hello World"


备注**:**
此处我们使用 9090 端口号启动了HTTP服务器. 虽然HTTP服务器一般使用80端口,
但这只是一个通用的习惯. 并不是说HTTP服务器就不能使用其他的端口号.
使用chrome测试我们的服务器时, 可以看到服务器打出的请求中还有一个 GET /favicon.ico HTTP/1.1 这样的请求.
2.传输层
负责数据能够从发送端传输接收端.
2.1再谈端口号
端口号(Port)标识了一个主机上进行通信的不同的应用程序;

| 应用类型 | 端口号 | 作用 |
|---|---|---|
| FTP 服务器 | 21 | 接收 FTP 文件传输请求 |
| SSH 服务器 | 22 | 接收远程登录加密连接请求 |
| SMTP 服务器 | 25 | 接收邮件发送请求 |
| HTTP 服务器 | 80 | 接收网页访问请求 |
| FTP 客户端 | 2000 | 发起 FTP 客户端连接 |
| HTTP 客户端 | 2001 | 发起网页浏览请求 |
在TCP/IP协议中, 用 "源IP", "源端口号", "目的IP", "目的端口号", "协议号" 这样一个五元组来标识一个通信(可以通过netstat -n查看);

- 设备与端口分布
| 设备 | IP 地址 | 应用 / 进程 | 端口说明 |
|---|---|---|---|
| 服务器 | 172.20.100.32 | 3 个 httpd 服务进程 | 均绑定 TCP 80 端口(HTTP 标准端口),并发处理请求 |
| 客户端 A | 172.20.100.34 | Web 浏览器(2 个标签页) | 标签页 1:源端口 2001 ;标签页 2:源端口 2002,目标端口均为 80 |
| 客户端 B | 172.20.100.33 | Web 浏览器(1 个标签页) | 源端口 2001,目标端口为 80 |
- 数据包与连接对应关系
下方的 3 个数据包分别对应 3 条独立的 TCP 连接:
| 数据包 | 源 IP | 目标 IP | 协议号 | 源端口 | 目标端口 | 对应连接 |
|---|---|---|---|---|---|---|
| ① | 172.20.100.34 | 172.20.100.32 | 6(TCP) | 2001 | 80 | 客户端 A 标签页 1 → 服务器 |
| ② | 172.20.100.34 | 172.20.100.32 | 6(TCP) | 2002 | 80 | 客户端 A 标签页 2 → 服务器 |
| ③ | 172.20.100.33 | 172.20.100.32 | 6(TCP) | 2001 | 80 | 客户端 B 标签页 1 → 服务器 |
- 核心机制:五元组唯一标识通信
图中明确指出:源 IP 地址 + 目标 IP 地址 + 协议号 + 源端口号 + 目标端口号 这 5 个字段,共同构成「五元组」,是操作系统区分不同 TCP 连接的唯一依据:
- 即使客户端 A 和客户端 B 都使用源端口 2001,因源 IP 不同,属于两条完全独立的连接;
- 客户端 A 的两个标签页,因源端口不同,也被识别为两条独立连接;
- 服务器的 80 端口可同时处理多个连接,操作系统会根据五元组将数据交付给对应的 httpd 进程。
2.2端口号范围划分
0 - 1023: 知名端口号, HTTP, FTP, SSH等这些广为使用的应用层协议, 他们的端口号都是固定的.
1024 - 65535: 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作系统从这个范围分配的.
2.3认识知名端口号****(Well-Know Port Number)
有些服务器是非常常用的, 为了使用方便, 人们约定一些常用的服务器, 都是用以下这些固定的端口号:
ssh服务器, 使用22端口
ftp服务器, 使用21端口
telnet服务器, 使用23端口
http服务器, 使用80端口
https服务器, 使用443
执行下面的命令, 可以看到知名端口号
bash
cat /etc/services
我们自己写一个程序使用端口号时, 要避开这些知名端口号.
2.4两个问题
- 一个进程是否可以bind多个端口号?
一个进程完全可以绑定多个端口号,这是网络编程中非常常见的场景。
进程可以在内部创建多个套接字(socket) ,每个套接字独立调用bind()函数,绑定不同的端口号。操作系统会为该进程记录 "进程 ID - 套接字 - 端口号" 的映射关系,只要端口号未被其他进程占用,绑定就会成功。
典型场景
- 一个后端服务进程同时提供 HTTP(80 端口)和 HTTPS(443 端口)服务;
- 一个网关进程同时监听 8080(HTTP 接口)和 8888(内部 RPC 接口)两个端口;
cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
// 套接字1:绑定8080端口
int sock1 = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr1 = {0};
addr1.sin_family = AF_INET;
addr1.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
addr1.sin_port = htons(8080);
bind(sock1, (struct sockaddr*)&addr1, sizeof(addr1));
listen(sock1, 10);
// 套接字2:绑定8888端口
int sock2 = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr2 = {0};
addr2.sin_family = AF_INET;
addr2.sin_addr.s_addr = INADDR_ANY;
addr2.sin_port = htons(8888);
bind(sock2, (struct sockaddr*)&addr2, sizeof(addr2));
listen(sock2, 10);
printf("进程已绑定 8080 和 8888 端口\n");
pause(); // 挂起进程,保持端口绑定
return 0;
}
- 一个端口号是否可以被多个进程bind?
核心结论:❌ 默认不可以 ,✅ 特殊场景下可以
端口号默认具有 "独占性",但通过操作系统提供的套接字选项或特殊绑定方式,可实现多进程共享同一端口。
- 默认情况:不允许(最常见)
如果一个进程已通过 bind() 占用某个端口(如 80),其他进程再调用 bind() 绑定该端口时,会直接返回 Address already in use 错误。
- 原因:操作系统无法确定将发往该端口的数据包交付给哪个进程,会导致数据混乱,违背端口 "应用寻址" 的核心设计。
- 特殊场景 1:使用
SO_REUSEPORT选项(推荐)
Linux 3.9+、FreeBSD、macOS 等系统支持 SO_REUSEPORT 套接字选项,允许多个进程绑定同一端口:
- 核心逻辑:所有绑定该端口的进程必须都设置此选项,操作系统内核会将收到的连接 / 数据包均匀分发给这些进程,实现负载均衡;
- 典型应用:Nginx、Redis、Apache 等高性能服务,通过多进程共享端口提升并发处理能力(对应你之前看到的 3 个 httpd 进程共享 80 端口的场景)。
- 特殊场景 2:使用
SO_REUSEADDR选项(有限允许)
- 主要作用:解决
TIME_WAIT状态下端口无法快速复用的问题(比如服务器重启后,旧连接还在等待关闭,新进程可绑定同一端口); - 限制:TCP 场景下仍不支持多个 "活跃进程" 同时绑定,仅允许 "重启后的进程" 复用;UDP 场景下更宽松,可允许多进程绑定。
- 特殊场景 3:绑定不同 IP 地址
如果进程 A 绑定 0.0.0.0:80(监听所有网卡),进程 B 绑定 192.168.1.100:80(仅监听特定网卡),部分系统(如 Linux)允许这种组合:
- 内核会根据数据包的目标 IP 地址 区分流向:发往
192.168.1.100的数据交给进程 B,其他数据交给进程 A。
2.5netstat
netstat是一个用来查看网络状态的重要工具.
语法:netstat [选项]
功能:查看网络状态
常用选项:
n 拒绝显示别名,能显示数字的全部转化成数字
l 仅列出有在 Listen (监听) 的服務状态
p 显示建立相关链接的程序名
t (tcp)仅显示tcp相关选项
u (udp)仅显示udp相关选项
a (all)显示所有选项,默认不显示LISTEN相关
2.6pidof
在查看服务器的进程id时非常方便.
语法:pidof [进程名]
功能:通过进程名, 查看进程id
3.UDP协****议
3.1UDP****协议端格式

| 字段 | 长度 | 作用与说明 |
|---|---|---|
| 16 位源端口号 | 16 位 | 标识发送端应用程序的端口号,用于让接收端回复数据;若无需回复,可设为 0(可选字段)。 |
| 16 位目的端口号 | 16 位 | 标识接收端应用程序的端口号,是操作系统将数据交付给对应进程的核心依据(必选字段)。 |
| 16 位 UDP 长度 | 16 位 | 表示UDP 首部 + 数据部分的总字节数,最小值为 8(仅首部,无数据),最大值为 65535 字节。 |
| 16 位 UDP 检验和 | 16 位 | 用于检测报文在传输过程中是否出现比特错误;- IPv4:可选,设为 0 表示不进行校验;- IPv6:必须校验,校验范围包含 UDP 首部、数据及 IP 层伪首部。 |
| 数据部分(如有) | 可变 | 承载应用层数据,长度 = UDP 总长度 - 8 字节首部。 |
16位UDP长度, 表示整个数据报(UDP首部+UDP数据)的最大长度;
如果校验和出错, 就会直接丢弃;
3.2UDP****的特点
UDP传输的过程类似于寄信.
无连接: 知道对端的IP和端口号就直接进行传输, 不需要建立连接;
不可靠: 没有确认机制, 没有重传机制; 如果因为网络故障该段无法发到对方, UDP协议层也不会给应用层
返回任何错误信息;
面向数据报: 不能够灵活的控制读写数据的次数和数量
3.3面向数据报
应用层交给UDP多长的报文, UDP原样发送, 既不会拆分, 也不会合并;
用UDP传输100个字节的数据:
如果发送端调用一次sendto, 发送100个字节, 那么接收端也必须调用对应的一次recvfrom, 接收100个字节; 而不能循环调用10次recvfrom, 每次接收10个字节;
UDP 是「基于报文的协议」,其底层逻辑是:
- 发送端调用一次
sendto(),应用层的一段完整数据(比如 100 字节)会被封装成一个 UDP 数据报(首部 + 100 字节数据),作为一个整体发送; - 接收端内核会把这个完整的 UDP 数据报暂存到接收缓冲区,直到应用层调用
recvfrom()读取; - 接收端一次
recvfrom()只能读取一个完整的 UDP 数据报 :- 如果
recvfrom()的缓冲区足够大(≥100 字节),能一次性读取全部 100 字节; - 如果缓冲区不够大(比如只有 10 字节),只会读取前 10 字节,剩余 90 字节会直接丢失(UDP 无重传、无缓存);
- 如果
- 绝对无法通过「循环 10 次
recvfrom()、每次读 10 字节」的方式拼接出完整的 100 字节(因为内核只把 100 字节当作 "一个报文",第一次读 10 字节后,剩余数据已丢失,后续recvfrom()会等待下一个 UDP 数据报)。
3.4UDP****的缓冲区
UDP没有真正意义上的 发送缓冲区. 调用sendto会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;
UDP具有接收缓冲区. 但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致; 如果缓冲区满了, 再到达的UDP数据就会被丢弃;
UDP的socket既能读, 也能写, 这个概念叫做 全双工
3.5UDP****使用注意事项
📌 一、先明确 UDP 的长度限制
UDP 首部中16 位长度字段定义了「UDP 首部 + 数据部分」的总长度:
- 理论最大值:
2^16 - 1 = 65535字节(约 64KB) - 实际可用数据长度:
65535 - 8(UDP首部)= 65527字节 - 也就是说:单个 UDP 报文最多只能携带~64KB 的数据(含首部) ,超过这个长度的应用层数据,必须在应用层手动拆分为多个 UDP 报文发送,再在接收端手动拼装还原。
📦 二、应用层分包与拼装的核心方案
当应用层数据 > 65527 字节时,需要按「分包发送 → 缓存排序 → 收齐拼装」的流程处理:
- 分包策略(推荐)
为了避免 IP 层分片(分片会大幅增加丢包风险),实际分包时不要用满 64KB 上限,而是控制在以太网 MTU 以内:
- 以太网 MTU 通常为 1500 字节
- IP 首部(20 字节)+ UDP 首部(8 字节)= 28 字节
- 因此单个 UDP 报文的数据部分建议 ≤ 1472 字节(1500 - 28)
- 示例:传输 1MB(1048576 字节)数据,分包数 ≈
ceil(1048576 / 1472) = 712个小包
- 自定义应用层头部(核心)
为了让接收端能识别「哪些包属于同一份大数据」「顺序是怎样的」「是否收齐」,需要在每个 UDP 报文的数据前添加自定义头部,例如:
| 字段 | 长度(字节) | 作用 |
|---|---|---|
| 消息 ID | 4 | 标识同一份大数据的唯一 ID(区分不同消息) |
| 包序号 | 2 | 当前包在序列中的位置(从 0 开始) |
| 总包数 | 2 | 这份大数据总共拆成了多少个包 |
| 片段长度 | 2 | 当前包携带的有效数据长度 |
| 校验和 | 2(可选) | 验证当前包数据完整性 |
-
发送端流程
-
把应用层大数据按「1472 字节 - 自定义头部长度」的大小拆成多个片段;
-
为每个片段分配唯一的消息 ID,标记序号和总包数;
-
把「自定义头部 + 片段数据」封装成 UDP 报文,逐个调用
sendto发送; -
(可选)实现超时重传机制:等待接收端 ACK,若超时未收到则重发对应包。
-
接收端流程
-
收到 UDP 报文后,解析自定义头部,提取消息 ID、包序号、总包数;
-
按「消息 ID」分组缓存所有片段,按「包序号」排序;
-
检查是否收齐该消息 ID 的所有包:
- ✅ 收齐:按顺序拼接所有片段,还原为完整应用层数据;
- ❌ 未收齐:继续等待,若超时则丢弃该消息(或请求发送端重发缺失包);
-
(可选)发送 ACK 给发送端,确认已收到对应包。
3.6基于UDP的应用层协议
NFS: 网络文件系统
TFTP: 简单文件传输协议
DHCP: 动态主机配置协议
BOOTP: 启动协议(用于无盘设备启动)
DNS: 域名解析协议
当然, 也包括你自己写UDP程序时自定义的应用层协议;
4.TCP****协议
TCP全称为 "传输控制协议(Transmission Control Protocol"). 人如其名, 要对数据的传输进行一个详细的控制;
4.1TCP****协议段格式

这张图展示了TCP(传输控制协议)的首部结构 ,TCP 首部分为固定 20 字节 和可变长度选项部分(最多 40 字节),整体长度范围为 20~60
- 核心字段完整说明
| 字段 | 长度 | 作用与核心含义 |
|---|---|---|
| 16 位源端口号 | 16 位 | 标识发送端应用进程的端口,与源 IP 共同确定连接的发送方。 |
| 16 位目的端口号 | 16 位 | 标识接收端应用进程的端口,与目的 IP 共同确定连接的接收方。 |
| 32 位序号(SN) | 32 位 | TCP 是面向字节流的协议,每个字节都有唯一序号;此字段表示本报文段发送数据的第一个字节的序号。在连接建立时(SYN=1),序号用于同步初始序列号(ISN)。 |
| 32 位确认序号(ACK Number) | 32 位 | 期望接收对方下一个报文段的第一个字节的序号,仅当 ACK 标志位为 1 时有效;表示之前所有字节都已成功接收。 |
| 4 位首部长度 | 4 位 | 以4 字节为单位计量首部长度:- 最小值:5(5×4=20 字节,固定首部)- 最大值:15(15×4=60 字节,含选项) |
| 保留位 | 6 位 | 保留给未来扩展使用,当前必须置为 0。 |
| 6 个控制标志位 | 6 位 | 每个标志位占 1 位,控制 TCP 连接状态与行为:• URG :紧急指针有效• ACK :确认序号有效• PSH :接收方立即将数据交付应用层• RST :强制重置连接(异常断开)• SYN :同步序号,用于建立连接(三次握手第一步)• FIN:释放连接,发送端无更多数据发送(四次挥手第一步) |
| 16 位窗口大小 | 16 位 | 用于流量控制,告知对方本端接收缓冲区剩余容量,单位为字节;窗口为 0 时表示暂时无法接收数据。 |
| 16 位检验和 | 16 位 | 校验范围包含TCP 首部 + 数据 + IP 伪首部,用于检测传输过程中的比特错误,是 TCP 可靠性的基础之一。 |
| 16 位紧急指针 | 16 位 | 仅当 URG=1 时有效,指向紧急数据的最后一个字节的序号,用于优先处理紧急数据(如中断信号)。 |
| 选项部分 | 可变 | 最多 40 字节,扩展 TCP 功能,常见选项:・MSS(最大段大小):协商单个 TCP 段最大数据长度・窗口扩大选项:将 16 位窗口扩展为 32 位,提升高带宽场景下的流量控制能力・时间戳选项:用于 RTT 计算、防止序号绕回 |
| 数据部分 | 可变 | 承载应用层数据,长度 = TCP 总长度 - 首部长度;TCP 无报文边界,数据是连续字节流。 |
- 关键特性补充
- 面向连接:TCP 通过 SYN/ACK/FIN 等标志位实现三次握手(建立连接)和四次挥手(释放连接),保证连接状态可靠。
- 可靠传输:序号 + 确认序号 + 检验和 + 重传机制,确保数据不丢失、不乱序、不重复。
- 流量控制:窗口大小字段让发送端根据接收端能力调整发送速率,避免缓冲区溢出。
- 拥塞控制:结合窗口大小与网络拥塞状态,动态调整发送速率,避免网络过载。
- 与 UDP 首部的核心对比
| 特性 | TCP 首部 | UDP 首部 |
|---|---|---|
| 固定长度 | 20 字节(+ 可选 40 字节选项) | 固定 8 字节 |
| 核心功能 | 可靠传输、连接管理、流量控制 | 轻量寻址、无连接传输 |
| 序号机制 | 32 位序号 + 确认序号 | 无序号机制 |
| 控制标志 | 6 个标志位管理连接状态 | 无控制标志位 |
| 适用场景 | 可靠数据传输(文件、网页) | 实时通信(语音、DNS) |
4.2确认应答**(ACK)**机制
- 通信步骤拆解
步骤 1:主机 A 发送第一段数据
- 发送内容:数据字节范围 1~1000(共 1000 字节)
- 序号含义:本段数据的第一个字节序号为
1,最后一个为1000 - 行为:主机 A 将这段数据封装为 TCP 报文段,发送给主机 B
步骤 2:主机 B 返回确认应答
- 确认内容:
确认应答(下一个是1001) - 确认序号含义:
- 表示主机 B已成功接收完 1~1000 的所有字节
- 告知主机 A:「我接下来期望接收从1001开始的字节」
- 机制:这是 TCP 的累积确认(Cumulative ACK) ------ 只要确认序号是
N,就代表N之前的所有字节都已被正确接收,无需逐个确认每个字节。
步骤 3:主机 A 发送第二段数据
- 发送内容:数据字节范围 1001~2000(共 1000 字节)
- 序号含义:本段数据的第一个字节序号为
1001,最后一个为2000 - 行为:主机 A 收到确认后,继续发送下一段数据
步骤 4:主机 B 再次确认应答
- 确认内容:
确认应答(下一个是2001) - 确认序号含义:
- 主机 B 已成功接收完 1001~2000 的所有字节
- 告知主机 A:「接下来期望接收从2001开始的字节」
- 核心原理与特性
① 面向字节流的序号机制
TCP 将数据视为连续的字节流,每个字节都分配唯一的 32 位序号:
- 发送端:每个报文段的序号是本段数据第一个字节的序号
- 接收端:确认序号是「期望接收的下一个字节的序号」,实现累积确认
② 停止等待协议
图中体现了「停止等待」的核心逻辑:
- 发送端发送一段数据后,必须等待接收端的确认,才能发送下一段数据
- 优点:实现简单,能保证数据有序、不丢失
- 缺点:信道利用率较低(等待确认期间链路空闲),实际 TCP 会用滑动窗口机制优化为流水线传输
③ 可靠传输的保障
- 丢包处理:如果某段数据丢失,接收方不会发送确认,发送方超时后会重传该段数据
- 乱序处理:序号保证接收方可以按顺序拼接数据,即使报文段乱序到达
- 重复处理:序号让接收方可以识别重复报文,丢弃重复数据
- 与 UDP 的本质区别
| 特性 | TCP(图中场景) | UDP |
|---|---|---|
| 序号 / 确认机制 | 有,通过序号 + 确认保证可靠交付 | 无,不保证数据到达、顺序或完整性 |
| 传输方式 | 面向字节流,分段传输 + 确认 | 面向报文,一次发送一个报文 |
| 可靠性 | 可靠,数据不丢、不乱序 | 不可靠,可能丢包、乱序、重复 |
TCP将每个字节的数据都进行了编号. 即为序列号.

每一个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下一次你从哪里开始发.
4.3超时重传机制

主机A发送数据给B之后, 可能因为网络拥堵等原因, 数据无法到达主机B;
如果主机A在一个特定时间间隔内没有收到B发来的确认应答, 就会进行重发;
但是, 主机A未收到B发来的确认应答, 也可能是因为ACK丢失了;

因此主机B会收到很多重复数据. 那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉., 就可以很容易做到去重的效果. 这时候我们可以利用前面提到的序列号
那么, 如果超时的时间如何确定?
最理想的情况下, 找到一个最小的时间, 保证 "确认应答一定能在这个时间内返回".
但是这个时间的长短, 随着网络环境的不同, 是有差异的.
如果超时时间设的太长, 会影响整体的重传效率;
如果超时时间设的太短, 有可能会频繁发送重复的包;
TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间.
Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间都是500ms的整数倍.
如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.
累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接.
4.4连接管理机制
在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接

一、阶段 1:连接建立(三次握手)
TCP 是面向连接的协议,必须通过三次握手建立可靠连接,避免无效连接占用资源。
| 步骤 | 主体 | TCP 状态 | 系统调用(应用层) | 报文交互 | 核心逻辑 |
|---|---|---|---|---|---|
| 1 | 客户端 | CLOSED → SYN_SENT |
fd = socket() → 分配文件描述符connect(...) → 发起连接 |
发送 SYN | 客户端主动发起连接,请求同步初始序列号(ISN),进入 SYN_SENT 等待服务器响应 |
| 2 | 服务器端 | CLOSED → LISTEN → SYN_RCVD |
socket() → bind() → listen() → 进入监听状态accept() → 阻塞等待客户端连接 |
回复 SYN+ACK | 服务器收到 SYN 后,确认客户端序号并同步自身序号,进入 SYN_RCVD 等待客户端确认 |
| 3 | 客户端 / 服务器 | SYN_SENT → ESTABLISHED``SYN_RCVD → ESTABLISHED |
客户端 connect() 返回服务器 accept() 返回 |
发送 ACK | 客户端收到 SYN+ACK 后返回 ACK,双方进入 ESTABLISHED (已建立连接)状态;服务器 accept 解除阻塞,分配 connfd 用于通信 |
关键状态说明
- LISTEN:服务器端专属状态,代表已绑定地址 / 端口,正在监听客户端连接请求。
- SYN_SENT/SYN_RCVD:三次握手过程中的中间状态,短暂存在。
- ESTABLISHED:连接正式建立,是后续数据传输的基础状态。
二、阶段 2:数据传输(可靠字节流)
连接建立后,客户端与服务器进入 ESTABLISHED 状态,可循环多次读写数据,依托序号 / 确认机制保证可靠交付。
| 主体 | 系统调用(应用层) | 报文交互 | 核心逻辑 |
|---|---|---|---|
| 客户端 | write(fd, buf, size) → 发送数据read(fd, buf, size) → 读取响应 |
发送 DATA 接收 ACK | 客户端将应用层数据封装为 TCP 报文段(带序号),发送给服务器;收到服务器的 ACK 后,确认数据送达 |
| 服务器端 | read(connfd, buf, size) → 读取请求write(connfd, buf, size) → 发送响应 |
接收 DATA 发送 ACK | 服务器从 connfd 读取客户端数据,按序号整理后返回 ACK;处理完业务后发送响应数据 |
| 核心特性 | 循环多次(读写可重复) | 累积确认 | TCP 是面向字节流协议,无报文边界;ACK 为累积确认(如确认序号 1001,代表 1~1000 字节均已接收),提升传输效率 |
关键机制
- DATA 报文:承载应用层数据,附带序号(标记本段第一个字节的位置)。
- ACK 报文:确认已接收的数据,告知对端 "期望接收的下一个字节序号",是 TCP 可靠传输的核心。
三、阶段 3:连接释放(四次挥手)
TCP 连接是全双工 的(双向均可独立传输),因此断开连接需要四次挥手,确保双方都完成数据传输后再断开。
| 步骤 | 主体 | TCP 状态 | 系统调用(应用层) | 报文交互 | 核心逻辑 |
|---|---|---|---|---|---|
| 1 | 客户端(主动关闭) | ESTABLISHED → FIN_WAIT_1 |
close(fd) → 主动关闭连接 |
发送 FIN | 客户端无数据发送,向服务器发送 FIN 报文,请求关闭写通道,进入 FIN_WAIT_1 状态 |
| 2 | 服务器端 | ESTABLISHED → CLOSE_WAIT |
读取到 read(connfd) 返回 0(表示对端无数据) |
回复 ACK | 服务器收到 FIN 后,返回 ACK 确认,进入 CLOSE_WAIT(等待服务器应用层处理完剩余数据);客户端收到 ACK 进入 FIN_WAIT_2 |
| 3 | 服务器端 | CLOSE_WAIT → LAST_ACK |
处理完剩余数据,调用 close(connfd) |
发送 FIN | 服务器应用层数据处理完毕,主动关闭连接,发送 FIN 报文,进入 LAST_ACK 状态 |
| 4 | 客户端 | FIN_WAIT_2 → TIME_WAIT |
无(被动关闭) | 回复 ACK | 客户端收到 FIN 后,返回 ACK 确认,进入 TIME_WAIT (关键状态,需等待 2MSL);服务器收到 ACK 后进入 CLOSED(连接彻底释放) |
| 最终 | 客户端 | TIME_WAIT → CLOSED |
无(超时后自动进入) | 无 | 客户端等待 2MSL(报文最大生存时间)后,进入 CLOSED 状态,连接彻底释放 |
关键状态说明
- CLOSE_WAIT:服务器端收到 FIN 但未关闭连接时的状态,需等待应用层处理完数据。
- LAST_ACK :服务器端主动关闭后的最终等待状态,收到客户端 ACK 后立即进入
CLOSED。 - TIME_WAIT :客户端主动关闭的核心状态 (必须等待 2MSL):
- 确保最后一个 ACK 被服务器接收(避免服务器未收到 ACK 而重传 FIN)。
- 防止旧连接的序号被新连接误用(避免序号绕回)。
四、核心状态流转总结
| 阶段 | 核心状态链 |
|---|---|
| 建立连接 | 客户端:CLOSED → SYN_SENT → ESTABLISHED服务器:CLOSED → LISTEN → SYN_RCVD → ESTABLISHED |
| 数据传输 | 双方均保持 ESTABLISHED(循环读写) |
| 释放连接 | 客户端:ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED服务器:ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED |
编程核心要点
- 服务器端 :需先执行
socket→bind→listen→accept(阻塞等待连接),accept返回后才是与客户端通信的connfd。 - 客户端 :执行
socket→connect(阻塞等待连接),连接建立后通过fd读写数据。 - 关闭连接 :主动关闭端(如客户端)会进入
TIME_WAIT,被动关闭端(如服务器)直接进入CLOSED,这是网络编程中端口复用、TIME_WAIT 问题的核心根源。
下图是TCP状态转换的一个汇总:

较粗的虚线表示服务端的状态变化情况;
较粗的实线表示客户端的状态变化情况 ;
CLOSED是一个假想的起始点, 不是真实状态;
4.5理解TIME_WAIT状态
现在做一个测试,首先启动server,然后启动client,然后用Ctrl-C使server终止,这时马上再运行server, 结果是:

这是因为,虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监 听同样的server端口.我们用netstat命令查看一下:

TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime) 的时间后才能回到CLOSED状态.
我们使用Ctrl-C终止了server, 所以server是主动关闭连接的一方, 在TIME_WAIT期间仍然不能再次监听同样的server端口;
MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s;
可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值;
规定TIME_WAIT的时间请读者参考UNP 2.7节

想一想, 为什么是TIME_WAIT的时间是2MSL?
MSL是TCP报文的最大生存时间, 因此TIME_WAIT持续存在2MSL的话
就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启, 可能会收到来自上一个进程的迟到的数据, 但是这种数据很可能是错误的);
同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失, 那么服务器会再重发一个FIN. 这时虽然客户端的进程不在了, 但是TCP连接还在, 仍然可以重发LAST_ACK);
4.6解决TIME_WAIT状态引起的bind失败的方法
首先明确失败原因 :当进程绑定的端口被处于 TIME_WAIT 状态的旧连接占用时,新进程调用 bind() 会返回 Address already in use 错误(因为 TCP 四元组的端口还未被释放)。
一、编程层面:套接字选项
在创建 socket 后、调用 bind() 前,设置对应的套接字选项,让内核允许复用处于 TIME_WAIT 状态的端口,是最常用且安全的方式。
- 核心选项:
SO_REUSEADDR
允许绑定已被 TIME_WAIT 连接占用的端口,核心规则:
- 只要新连接的四元组(源 IP + 源端口 + 目的 IP + 目的端口)与旧
TIME_WAIT连接不完全相同,就允许绑定; - 即使四元组相同,只要旧连接处于
TIME_WAIT状态,也允许复用端口(这是解决 bind 失败的核心)。
代码示例(C/C++)
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#define SERVER_PORT 8888
int main() {
// 1. 创建TCP套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket create failed");
return -1;
}
// 2. 设置SO_REUSEADDR选项(关键!解决TIME_WAIT导致的bind失败)
int reuse = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) {
perror("setsockopt SO_REUSEADDR failed");
close(sockfd);
return -1;
}
// 3. 绑定端口(此时即使端口处于TIME_WAIT,也能成功bind)
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
server_addr.sin_port = htons(SERVER_PORT);
if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
printf("bind failed: %s\n", strerror(errno));
close(sockfd);
return -1;
}
printf("bind port %d success!\n", SERVER_PORT);
// 4. 监听端口
listen(sockfd, 10);
pause(); // 挂起进程
close(sockfd);
return 0;
}
注意事项
SO_REUSEADDR必须在bind()前设置,否则无效;- 该选项仅解决「TIME_WAIT 导致的 bind 失败」,不会影响连接的可靠性;
- 跨平台兼容:Linux/macOS/Windows 均支持,是通用方案。
- 进阶选项:
SO_REUSEPORT(Linux 3.9+ 支持)
不仅允许复用端口,还支持多个进程同时绑定同一个端口(内核会均匀分发连接),同时也能解决 TIME_WAIT 问题。
区别于 SO_REUSEADDR
| 特性 | SO_REUSEADDR |
SO_REUSEPORT |
|---|---|---|
| 核心作用 | 复用 TIME_WAIT 端口 | 多进程共享端口 + 复用 TIME_WAIT |
| 多进程绑定同一端口 | 不支持(会冲突) | 支持(内核负载均衡) |
| 跨平台 | 通用 | 仅 Linux 3.9+、macOS 等支持 |
代码示例(仅需修改 setsockopt 部分)
// 设置SO_REUSEPORT(需Linux 3.9+)
int reuse_port = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, &reuse_port, sizeof(reuse_port)) < 0) {
perror("setsockopt SO_REUSEPORT failed");
close(sockfd);
return -1;
}
4.7理解CLOSE_WAIT状态
以之前写过的 TCP 服务器为例, 我们稍加修改
将 new_sock.Close(); 这个代码去掉
cpp
// 头文件保护宏,防止头文件被重复包含(等价于传统的#ifndef ... #define ... #endif)
#pragma once
// 引入C++函数对象库,用于定义处理请求的回调函数类型
#include <functional>
// 引入自定义的TCP套接字封装类,封装了socket、bind、listen、accept、recv、send等底层操作
#include "tcp_socket.hpp"
/**
* @brief 定义请求处理函数的类型别名(回调函数)
* @param req 客户端发送的请求字符串(输入参数)
* @param resp 用于存储服务器生成的响应字符串(输出参数,通过指针修改)
*/
typedef std::function<void (const std::string& req, std::string* resp)> Handler;
/**
* @brief TCP服务器核心类
* @details 封装了TCP服务器的核心流程:创建套接字、绑定端口、监听、接收连接、循环读写数据
* 采用回调函数的方式处理业务逻辑,解耦网络层和业务层
*/
class TcpServer {
public:
/**
* @brief 构造函数,初始化服务器的监听IP和端口
* @param ip 服务器监听的IP地址(如"0.0.0.0"表示监听所有网卡,"127.0.0.1"仅监听本地回环)
* @param port 服务器监听的端口号(范围:1024~65535,避免使用知名端口)
*/
TcpServer(const std::string& ip, uint16_t port) : ip_(ip), port_(port) {
}
/**
* @brief 启动TCP服务器,进入事件循环处理客户端连接
* @param handler 业务处理回调函数,由调用方实现具体的请求处理逻辑
* @return 启动成功返回true,失败返回false
*/
bool Start(Handler handler) {
// 1. 创建监听套接字(TCP类型)
// CHECK_RET是自定义宏(推测),用于检查函数执行结果,失败则直接返回
CHECK_RET(listen_sock_.Socket());
// 2. 将监听套接字绑定到指定的IP和端口
// 绑定失败会通过CHECK_RET终止流程,确保服务器启动的核心步骤不跳过
CHECK_RET(listen_sock_.Bind(ip_, port_));
// 3. 将监听套接字设置为监听状态,开始等待客户端连接
// 参数5是监听队列长度(backlog),表示最多同时等待处理的连接数
CHECK_RET(listen_sock_.Listen(5));
// 4. 进入服务器主事件循环(无限循环,直到进程终止)
for (;;) {
// 5. 接收客户端连接(阻塞等待,直到有新客户端连接)
// new_sock:用于和新客户端通信的套接字(每个客户端对应一个独立的sock)
TcpSocket new_sock;
// 用于存储新连接客户端的IP和端口(输出参数)
std::string ip;
uint16_t port = 0;
// Accept失败(如系统中断)则跳过本次循环,继续等待下一个连接
if (!listen_sock_.Accept(&new_sock, &ip, &port)) {
continue;
}
// 日志打印:新客户端连接成功
printf("[client %s:%d] connect!\n", ip.c_str(), port);
// 6. 与当前客户端进行循环读写(处理该客户端的多次请求)
for (;;) {
// 存储客户端发送的请求数据
std::string req;
// 7. 读取客户端发送的请求数据
// Recv失败(如客户端断开、网络错误)则结束当前客户端的读写循环
bool ret = new_sock.Recv(&req);
if (!ret) {
// 日志打印:客户端断开连接
printf("[client %s:%d] disconnect!\n", ip.c_str(), port);
// [注意!] 此处注释掉了关闭socket的代码
// 原因推测:TcpSocket类的析构函数会自动关闭套接字,无需手动调用;
// 或此处仅退出循环,由外层逻辑处理资源释放
// new_sock.Close();
break;
}
// 8. 调用业务回调函数,根据请求计算响应
// 业务逻辑由调用方实现(如解析req,生成对应的resp)
std::string resp;
handler(req, &resp);
// 9. 将响应数据写回给客户端
new_sock.Send(resp);
// 日志打印:本次请求-响应的完整信息
printf("[%s:%d] req: %s, resp: %s\n", ip.c_str(), port,
req.c_str(), resp.c_str());
}
}
return true;
}
private:
TcpSocket listen_sock_; // 监听套接字:用于接收客户端连接请求
std::string ip_; // 服务器监听的IP地址
uint64_t port_; // 服务器监听的端口号(注:建议改为uint16_t,端口范围仅0~65535)
};
我们编译运行服务器. 启动客户端链接, 查看 TCP 状态, 客户端服务器都为 ESTABLELISHED 状态, 没有问题.然后我们关闭客户端程序, 观察 TCP 状态
cpp
tcp 0 0 0.0.0.0:9090 0.0.0.0:* LISTEN
5038/./dict_server
tcp 0 0 127.0.0.1:49958 127.0.0.1:9090 FIN_WAIT2 -
tcp 0 0 127.0.0.1:9090 127.0.0.1:49958 CLOSE_WAIT
5038/./dict_server
此时服务器进入了 CLOSE_WAIT 状态, 结合我们四次挥手的流程图, 可以认为四次挥手没有正确完成.
小结: 对于服务器上出现大量的 CLOSE_WAIT 状态, 原因就是服务器没有正确的关闭 socket, 导致四次挥手没有正确
完成. 这是一个 BUG. 只需要加上对应的 close 即可解决问题.
5.滑动窗口
刚才我们讨论了确认应答策略, 对每一个发送的数据段, 都要给一个ACK确认应答. 收到ACK后再发送下一个数据段. 这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候.

既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了).

- 场景拆解:发送与确认的对应关系
-
发送方(主机 A):连续发送 9 个数据段,每个段包含 1000 字节,序号范围分别为:
- 段 1:
1 ~ 1000(报文段序号 = 1) - 段 2:
1001 ~ 2000(报文段序号 = 1001) - ...
- 段 9:
8001 ~ 9000(报文段序号 = 8001)无需等待前一个段的确认,直接连续发送,充分利用链路带宽。
- 段 1:
-
接收方(主机 B) :返回累积确认应答,格式为「下一个是 X」:
- 收到
1 ~ 1000→ 确认「下一个是 1001」 - 收到
1001 ~ 2000→ 确认「下一个是 2001」 - ...
- 收到
8001 ~ 9000→ 确认「下一个是 8001」含义:已成功接收所有序号小于 X 的字节,期望接收从 X 开始的下一段数据。
- 收到
- 核心机制:滑动窗口 + 累积确认
① 流水线传输(提升信道利用率)
- 停等协议:发送 1 个段 → 等待确认 → 再发下一个,链路在等待确认时长期空闲,信道利用率极低。
- 滑动窗口 :发送方可以连续发送窗口内所有未确认的段,像流水线一样持续传输,充分利用带宽,尤其适合高延迟、高带宽的网络(如跨地域传输)。
- 图中主机 A 一次性发送多个段,就是滑动窗口的典型表现:窗口大小至少为 4(可同时发送 4 个未确认的段,收到确认后窗口滑动,继续发送后续段)。
② 累积确认(减少确认开销)
TCP 采用累积确认(Cumulative ACK):接收方只需确认「期望接收的下一个字节序号」,无需为每个报文段单独确认。
- 优势:
- 减少确认报文数量,降低网络开销;
- 即使中间某个报文段延迟 / 乱序到达,只要后续段已接收,仍可通过累积确认告知发送方已收到的最大序号。
- 例:若
1001~2000段延迟到达,但2001~3000段已接收,接收方仍可确认「下一个是 1001」,告知发送方1~1000已收到,1001及以后的字节还未收齐。
- 与停等协议的核心对比
| 特性 | 停等协议 | 滑动窗口(本图) |
|---|---|---|
| 发送模式 | 发 1 个段 → 等确认 → 再发下一个 | 连续发送窗口内多个段,无需逐个等待确认 |
| 信道利用率 | 低(等待确认时链路空闲) | 高(充分利用带宽,流水线传输) |
| 确认方式 | 逐个确认每个报文段 | 累积确认,确认到某个序号为止的所有字节 |
| 适用场景 | 低带宽、短延迟网络 | 高带宽、长延迟网络(如互联网大数据传输) |
- 延伸:滑动窗口的流量控制
- 窗口大小由接收方的接收缓冲区剩余容量决定,接收方会在确认应答中通过「16 位窗口大小字段」告知发送方自己的接收能力。
- 若接收方处理速度慢,窗口大小会缩小,限制发送方的发送速率,避免接收缓冲区溢出;若接收方空闲,窗口大小扩大,允许发送方发送更多数据。
- 图中窗口大小足够大,因此主机 A 可以连续发送多个段,直到窗口占满后再等待确认滑动。
发送前四个段的时候, 不需要等待任何ACK, 直接发送;
收到第一个ACK后, 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推;
操作系统内核为了维护这个滑动窗口, 需要开辟 发送缓冲区来记录当前还有哪些数据没有应答; 只有确 认应答过的数据, 才能从缓冲区删掉;
窗口越大, 则网络的吞吐率就越高;
- 阶段①:初始滑动窗口状态
- 字节流序号 :主机 A 的字节流按 1000 字节分段,序号为
1, 1001, 2001, 3001... - 滑动窗口定义 :
- 窗口大小:4 个分段(共 4000 字节) ,覆盖范围
1001 ~ 5000(对应序号 1001 到 5001 的前一位) - 区域划分:
- 左侧灰色:已发送且被接收方确认的字节(
1 ~ 1000) - 窗口内白色:待发送 / 已发送未确认的字节(
1001 ~ 5000) - 右侧灰色:尚未发送的字节(
5001 ~ ...)
- 左侧灰色:已发送且被接收方确认的字节(
- 窗口大小:4 个分段(共 4000 字节) ,覆盖范围
- 阶段②:数据发送与累积确认
- 发送数据 :主机 A 向主机 B 发送窗口内第一个分段:
1001 ~ 2000(以序号 1001 开头的 1000 字节) - 累积确认应答 :主机 B 接收后返回确认:
下一个是2001- 含义:已成功接收所有序号 < 2001 的字节(即
1 ~ 2000) ,期望接收从2001开始的下一段数据 - 这个确认应答最终到达主机 A,触发窗口滑动
- 含义:已成功接收所有序号 < 2001 的字节(即
- 阶段③:窗口滑动(核心机制)
主机 A 收到确认后,滑动窗口向右移动:
- 原窗口内已确认的部分(
1001 ~ 2000)变为左侧灰色(已确认区域) - 窗口整体右移,保持 4 个分段(4000 字节)的大小不变 ,新窗口覆盖范围变为
2001 ~ 6000 - 右侧原本未发送的部分(
5001 ~ 6000)进入窗口,成为新的待发送数据 - 滑动后,主机 A 可继续发送窗口内的新分段(如
2001 ~ 3000、3001 ~ 4000),实现流水线传输
核心机制总结
- 窗口大小恒定:滑动过程中,窗口大小(可发送的未确认字节数)保持不变,由接收方的接收能力(流量控制)决定
- 滑动触发条件 :只有左侧字节被成功确认后,窗口才会右滑,释放右侧新字节进入窗口
- 累积确认:接收方通过「下一个是 X」的确认,一次性确认所有 < X 的字节,无需逐个确认每个分段,减少网络开销
- 流水线传输:窗口内可同时存在多个未确认分段,发送方可连续发送多段,无需等待前一段确认,大幅提升信道利用率
那么如果出现了丢包, 如何进行重传? 这里分两种情况讨论.
情况一: 数据包已经抵达, ACK被丢了.

这种情况下, 部分ACK丢了并不要紧, 因为可以通过后续的ACK进行确认
情况二: 数据包就直接丢了

当某一段报文段丢失之后, 发送端会一直收到 1001 这样的ACK, 就像是在提醒发送端 "我想要的是 1001"一样;
如果发送端主机连续三次收到了同样一个 "1001" 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;
这个时候接收端收到了 1001 之后, 再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中
这种机制被称为 "高速重发控制"(也叫 "快重传")
6.流量控制
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,
就会造成丢包, 继而引起丢包重传等等一系列连锁反应.因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制****(Flow Control);
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端;
窗口大小字段越大, 说明网络的吞吐量越高;
接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
发送端接受到这个窗口之后, 就会减慢自己的发送速度;
如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端.

接收端如何把窗口大小告诉发送端呢? 回忆我们的TCP首部中, 有一个16位窗口字段, 就是存放了窗口大小信息;
那么问题来了, 16位数字最大表示65535, 那么TCP窗口最大就是65535字节么?
实际上, TCP首部40字节选项中还包含了一个窗口扩大因子M, 实际窗口大小是 窗口字段的值左移 M 位;
- 基础限制:16 位窗口字段的天花板
TCP 首部的 16 位窗口大小字段(Window Size) 是最初传递窗口信息的核心字段,它的取值范围是 0 ~ 65535(2¹⁶ - 1),所以:
- 仅靠这个字段,窗口最大只能是 65535 字节(约 64KB)。
- 这个设计在早期低速网络中足够,但在高带宽、长延迟的现代网络(如千兆 / 万兆网、跨洋传输)中,64KB 的窗口太小了 ------ 发送方刚发一点数据就必须等待确认,信道利用率极低,无法充分利用带宽。
- 突破限制:窗口扩大选项(Window Scale Option)
为了解决 64KB 瓶颈,TCP 引入了窗口扩大选项 (定义在 RFC 1323 中),放在 TCP 首部的 选项字段(Options) 里:
- 核心原理 :新增一个 窗口扩大因子
M(取值范围0 ~ 14),实际窗口大小 =16 位窗口字段的值 × 2^M(等价于左移M位)。 - 最大窗口计算 :
- 当
M=14时,实际窗口最大为:65535 × 2¹⁴ = 65535 × 16384 = 1,073,725,440 字节(约 1GB),完全能满足高带宽场景的需求。
- 当
- 协商时机 :仅在 TCP 三次握手阶段 协商 ------ 双方在 SYN 报文段中携带这个选项,告知对方自己的
M值,握手完成后M就不能再修改了。 - 限制 :
M最大为 14,因为超过这个值会导致 32 位序号绕回问题(旧报文的序号可能和新报文的序号重叠,破坏可靠性)。
- 为什么需要这么大的窗口?(带宽延迟积)
窗口大小的本质是 发送方最多能发送的未确认字节数,它需要匹配网络的「带宽延迟积」(BDP):BDP=带宽(bps)×往返延迟(秒)÷8
- 例子:1Gbps 带宽、100ms 往返延迟 → BDP ≈ 12.5MB。
- 如果窗口只有 64KB,发送方最多发 64KB 就必须等确认,信道利用率只有
64KB / 12.5MB ≈ 0.5%; - 用窗口扩大到 8MB(
M=7),利用率就能提升到8MB / 12.5MB ≈ 64%,大幅提升传输效率。
7.拥塞控制
虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题.
因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据,是很有可能引起雪上加霜的.
TCP引入 慢启动机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;

此处引入一个概念程为拥塞窗口
发送开始的时候, 定义拥塞窗口大小为1;
每次收到一个ACK应答, 拥塞窗口加1;
每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口
像上面这样的拥塞窗口增长速度, 是指数级别的. "慢启动" 只是指初使时慢, 但是增长速度非常快.
为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.
此处引入一个叫做慢启动的阈值
当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长

- 初始状态:TCP 刚启动
- 拥塞窗口
cwnd:初始值为1(只能发 1 个报文段),代表慢开始的起点。 - 慢启动阈值
ssthresh:初始等于窗口最大值(图中为16),是「慢开始」和「拥塞避免」的分界点。
- 阶段 1:慢开始(指数增长)
- 规则 :每经过 1 个传输轮次(RTT),
cwnd指数翻倍(1→2→4→8→16)。 - 目的:快速探测网络可用带宽,用最小代价试探网络能承受的流量。
- 结束条件 :当
cwnd增长到等于ssthresh(图中cwnd=16)时,退出慢开始,进入拥塞避免阶段。 - 图中表现:0~4 轮次,
cwnd从 1 陡峭增长到 16,标注「指数规律增长」。
- 阶段 2:拥塞避免(加法增大)
- 规则 :
cwnd不再翻倍,改为每轮次 +1(16→17→18→...→24),缓慢线性增长。 - 目的:在不引发拥塞的前提下,尽可能榨干网络带宽,避免突然增大导致网络过载。
- 结束条件 :直到网络出现超时重传(判定为网络拥塞)。
- 图中表现:4~12 轮次,
cwnd平缓上升,标注「拥塞避免'加法增大'」。
- 阶段 3:网络拥塞(超时重传,乘法减小)
- 触发条件:发生超时重传(TCP 认为网络严重拥塞,报文大量丢失)。
- 核心调整 :
- 慢启动阈值
ssthresh减半 :更新为当前cwnd的一半(图中当前cwnd=24,新ssthresh=12),这是「乘法减小」。 - 拥塞窗口
cwnd重置为 1:回到慢开始起点,重新试探网络。
- 慢启动阈值
- 图中表现:12~13 轮次,
cwnd从 24 骤降到 1,标注「网络拥塞 」「乘法减小」。
- 阶段 4:重启循环(慢开始 → 拥塞避免)
- 重置后
cwnd=1、ssthresh=12,再次进入慢开始 :cwnd指数增长到12(1→2→4→8→12)。 - 当
cwnd达到新的ssthresh=12后,再次进入拥塞避免 ,线性增长(12→13→14→...),循环之前的逻辑。 - 图中表现:13 轮次之后,
cwnd重新从 1 开始增长,重复「慢开始 + 拥塞避免」流程。
8.延迟应答
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小.
假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K;
但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;
在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M;
一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;
那么所有的包都可以延迟应答么? 肯定也不是;
数量限制: 每隔N个包就应答一次;
时间限制: 超过最大延迟时间就应答一次;
具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms;

9.捎带应答
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 "一发一收" 的. 意味着客户端给服务器说了 "How are you", 服务器也会给客户端回一个 "Fine, thank you";
那么这个时候ACK就可以搭顺风车, 和服务器回应的 "Fine, thank you" 一起回给客户端

10.面向字节流
创建一个TCP的socket, 同时在内核中创建一个 发送缓冲区和一个 接收缓冲区;
调用write时, 数据会先写入发送缓冲区中;
如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
然后应用程序可以调用read从接收缓冲区拿数据;
另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工
由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如:
写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次;
11.粘包问题
- 什么是粘包?
你明明调用了 3 次 send,发了 3 条独立消息:
helloworld123
但接收端 1 次 recv 就读到了:
helloworld123
接收端分不清哪里是一条消息的开头、哪里是结尾 ,这就叫 粘包。
-
为什么会粘包?(根本原因)
-
TCP 是字节流协议
- 发送:把数据当成一串连续的字节往外发,不关心你发了几次。
- 接收:把收到的字节全部塞进缓冲区,也不保留你原来的发送次数。
-
发送端优化(Nagle 算法) 内核会把多个小数据包合并成一个大包发送,减少网络包数量。
-
接收端缓冲你没及时读,内核就把多包数据攒在一起,你一读就全读出来了。
对比你刚学的:
- UDP:面向报文,有边界 → 一次 sendto 对应一次 recvfrom → 永远不粘包
- TCP:面向字节流,无边界 → 必然会粘包
3. 怎么解决?(3 种方案,最常用第 3 种)
粘包的核心是:接收端不知道一条消息多长 。所以我们必须自己定义消息边界。
方案 1:固定长度(简单但垃圾)
每条消息都定死长度,不够补空格。缺点:浪费空间、不灵活。
方案 2:分隔符(如 \n)
发消息时末尾加特殊符号,接收端读到分隔符就截断。适合文本,不适合二进制。
方案 3:长度 + 数据(工业标准,推荐!)
自定义协议头:
- 前 4 字节 :表示后面真实数据的长度
- 后面 N 字节:真实数据
接收端逻辑:
-
先读 4 字节,得到消息长度 LEN
-
再精确读 LEN 字节,就是一条完整消息
-
循环执行,永远不会粘包
-
对照TcpServer 代码
bool ret = new_sock.Recv(&req);
直接读一串字节,没有消息边界 → 一定会粘包!
正确改造思路(伪代码)
// 1. 先读 4 字节长度
uint32_t len;
recv(fd, &len, 4, ...);
len = ntohl(len); // 转主机字节序
// 2. 再读 len 字节数据
string req;
req.resize(len);
recv(fd, &req[0], len, ...);
这样就能精准切分每一条消息,彻底解决粘包。
12.TCP****异常情况
一、连接建立阶段异常
- 端口被占用(bind 失败)
-
现象 :调用
bind()返回Address already in use,服务器启动失败。 -
核心原因 :
- 端口被其他进程(如旧的服务器进程)占用(
ESTABLISHED/LISTEN状态); - 端口被
TIME_WAIT状态的旧连接占用(主动关闭方未释放端口)。
- 端口被其他进程(如旧的服务器进程)占用(
-
解决方案 :✅ 编程层面:绑定前设置
SO_REUSEADDR(推荐):int reuse = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));✅ 排查层面:用
netstat -anp | grep 端口号查看占用进程,kill 掉异常进程;✅ 系统层面:缩短TIME_WAIT超时(echo 30 > /proc/sys/net/ipv4/tcp_fin_timeout)。
- 连接超时(connect 失败)
- 现象 :客户端
connect()阻塞超时,或返回ETIMEDOUT。 - 原因 :
- 目标服务器端口未监听(
listen()未调用); - 网络不通(防火墙拦截、路由故障);
- 服务器半连接队列满(SYN Flood 攻击)。
- 目标服务器端口未监听(
- 解决方案 :✅ 检查服务器:
netstat -anp | grep 端口号确认端口处于LISTEN状态;✅ 网络排查:ping 服务器IP+telnet 服务器IP 端口验证连通性;✅ 抗攻击:调大监听队列(listen(backlog)增大 backlog),开启SYN Cookie(echo 1 > /proc/sys/net/ipv4/tcp_syncookies)。
- 三次握手异常(SYN 丢包)
- 现象:客户端发 SYN 后无响应,最终超时。
- 原因:SYN 报文在网络中丢失,服务器未收到连接请求。
- 解决方案:TCP 自身会超时重传 SYN(默认重传 5 次),无需应用层处理;若频繁丢包,需排查网络链路。
二、数据传输阶段异常
- 粘包(最常见!)
-
现象 :接收端一次读取到多条发送端的消息(如发
hello+world,收helloworld)。 -
原因:TCP 是「面向字节流、无消息边界」,内核会合并小数据包(Nagle 算法)或接收端缓冲未及时读取。
-
解决方案 (工业标准):✅ 自定义协议:
4字节长度 + 真实数据(先读长度,再读对应字节):// 接收端核心逻辑 uint32_t len; // 第一步:读4字节长度(网络字节序转主机字节序) recv(fd, &len, 4, 0); len = ntohl(len); // 第二步:读len字节真实数据 string data; data.resize(len); recv(fd, &data[0], len, 0);
- 丢包 / 超时重传
- 现象:数据传输延迟高,服务器日志出现重传,吞吐量下降。
- 原因:网络拥塞、链路故障,TCP 判定报文丢失。
- 解决方案:✅ TCP 自身处理:超时重传(RTO 动态调整)、快重传(收到 3 个重复 ACK 立即重传);✅ 应用层优化:避免突发大流量,配合拥塞控制(如开启 CUBIC 算法)。
- 乱序 / 重复数据
- 现象 :
- 乱序:接收端收到的报文段序号无序;
- 重复:接收端收到重复的报文段。
- 原因 :
- 乱序:报文走不同路由,到达时间不一致;
- 重复:丢包导致重传,原报文又延迟到达。
- 解决方案:TCP 自身会通过「序号重排」(乱序)、「序号去重」(重复)处理,应用层无需关心。
- 缓冲区溢出(send/recv 阻塞)
- 现象 :
send()阻塞,或返回EAGAIN(非阻塞模式);接收端数据堆积。 - 原因:发送端发太快,接收端处理速度慢,接收缓冲区满(滑动窗口为 0)。
- 解决方案:✅ TCP 流量控制:接收端通过窗口字段告知发送端「可发送的字节数」,发送端自动限流;✅ 应用层:用非阻塞 IO + 事件驱动(如 epoll),避免长时间阻塞。
- 连接重置(RST 报文)
- 现象 :
recv()/send()返回-1,errno = ECONNRESET(连接被重置)。 - 原因 :
- 访问未监听的端口;
- 对方进程异常退出(未调用
close()); - 接收端缓冲区溢出,内核发 RST。
- 解决方案 :✅ 捕获错误码,重新建立连接;✅ 确保进程优雅退出(调用
close()释放连接)。
三、连接释放阶段异常
- TIME_WAIT 过多(端口耗尽)
- 现象 :客户端 / 服务器大量
TIME_WAIT状态连接,新连接无法绑定端口。 - 原因 :主动关闭方(如客户端)在
TIME_WAIT状态等待 2MSL,端口未释放。 - 解决方案 :✅ 编程层面:设置
SO_REUSEADDR复用端口;✅ 系统层面:开启tcp_tw_reuse(echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse),缩短tcp_fin_timeout。
- CLOSE_WAIT 过多(最易踩坑!)
- 现象 :服务器大量
CLOSE_WAIT状态连接,最终耗尽文件描述符。 - 原因 :被动关闭方(服务器)收到 FIN 后,未调用
close()释放套接字(比如你的 TcpServer 代码若漏写close()就会出现)。 - 解决方案 :✅ 检查代码:确保所有连接关闭时调用
close()(栈上 TcpSocket 析构自动 close 也可);✅ 监控:用netstat -anp | grep CLOSE_WAIT定位异常进程,修复代码。
- FIN_WAIT_2 挂起
- 现象 :主动关闭方长期处于
FIN_WAIT_2状态,不释放连接。 - 原因:被动关闭方未发 FIN(如进程卡死),主动关闭方一直等待。
- 解决方案 :设置系统参数
tcp_fin_timeout(如 30 秒),内核自动回收超时的FIN_WAIT_2连接。
13.TCP****小结
为什么TCP这么复杂? 因为要保证可靠性, 同时又尽可能的提高性能
可靠性:
校验和
序列号(按序到达)
确认应答
超时重发
连接管理
流量控制
拥塞控制
提高性能:
滑动窗口
快速重传
延迟应答
捎带应答
其他:
定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)
14.基于TCP应用层协议
HTTP
HTTPS
SSH
Telnet
FTP
SMTP
当然, 也包括你自己写TCP程序时自定义的应用层协议;
15.TCP/UDP对比
我们说了TCP是可靠连接, 那么是不是TCP一定就优于UDP呢? TCP和UDP之间的优点和缺点, 不能简单, 绝对的进行
比较
TCP用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景;
UDP用于对高速传输和实时性要求较高的通信领域, 例如, 早期的QQ, 视频传输等. 另外UDP可以用于广播;
归根结底, TCP和UDP都是程序员的工具, 什么时机用, 具体怎么用, 还是要根据具体的需求场景去判定.
16.用UDP实现可靠传输
参考TCP的可靠性机制, 在应用层实现类似的逻辑;
例如:
引入序列号, 保证数据顺序;
引入确认应答, 确保对端收到了数据;
引入超时重传, 如果隔一段时间没有应答, 就重发数据;
......
