TCP连接过程
- [1. TCP的头部字段](#1. TCP的头部字段)
- [2. TCP三次握手过程](#2. TCP三次握手过程)
- [3. TCP为什么需要三次握手?](#3. TCP为什么需要三次握手?)
- [4. TCP三次握手,客户端第三次发送的确认包丢失了发生什么?](#4. TCP三次握手,客户端第三次发送的确认包丢失了发生什么?)
- [5. 三次握手和accept是什么关系?accept做了哪些事情?](#5. 三次握手和accept是什么关系?accept做了哪些事情?)
- [6. 客户端发送第一个SYN报文,服务器没有接收到怎么办?](#6. 客户端发送第一个SYN报文,服务器没有接收到怎么办?)
- [7. 假设客户端重传了SYN报文后,服务端这边又收到重复的SYN报文怎么办?](#7. 假设客户端重传了SYN报文后,服务端这边又收到重复的SYN报文怎么办?)
- [8. 第一次握手,客户端发送SYN,服务端回复ACK报文,这个过程服务端内部做了什么?](#8. 第一次握手,客户端发送SYN,服务端回复ACK报文,这个过程服务端内部做了什么?)
- [9. 大量SYN包发送给服务端会发生什么事情?](#9. 大量SYN包发送给服务端会发生什么事情?)
1. TCP的头部字段

序列号:在建立连接时由计算机生成数计数作为初始值,通过YSN包传递给接收主机,后面每发送一次数据,就累加一次该数据字节数的大小。用来解决网络包乱序问题。
确认应答号:接收端接收到信号后,下一次期待收到的数据的序列号,发送端收到该确认应答号,就知道接收端,该号之前的数据已经接收到了。用来解决丢包问题。
控制位:
- ACK-确认位:该为为1时,确认应答的字段变为有效,TCP规定除了最初建立连接时的SYN包之外,的包该位必须设为1。
- SYN-同步位:用于发起一个连接并同步序列号。该位只有在连接3次握手前两步出现,当SYN=1,ACK=0时,表明这是一个连接请求报文。
- RST-重置位:当该位为1时,表示TCP连接出现了严重错误,必须强制释放连接,重写连接。
- FIN-终止位:用于释放连接。当该位为1时,表示发送方数据已经发送完毕,没有数据要发送,请求断开TCP连接。
2. TCP三次握手过程
TCP是面向连接的协议,因此使用TCP前必须建立连接,连接是通过三次握手完成的。
- 步骤1:客户端发起SYN请求(1.SYN同步)
-
客户端行动:
- 标志位:SYN=1,ACK=0
- 序列化:初始化序列号x
-
状态变化:客户端发送报文后,状态从CLOSED变为SYN-SENT(同步已发送)。
-
服务器接收:服务器接收到该报文后,会创建一个包含以一下关键信息的TCP报文段,作为响应。
- 步骤2:服务器响应SYN+ACK
-
服务器行动:服务器接收到客户端SYN请求,会创建并发送一个包含以下关键信息的TCP报文段:
- 标志位:YSN=1,ACK=1
- 序列号:初始序列号y
- 确认号:x+1(确认收到客户端seq=x,并请求下一个字节)
-
状态变化:服务器发送报文后,状态变为SYN-RECEIVED(同步已接收)。
-
客户端接收:客户端接收后,会再次发送一个ACK报文。
- 步骤3:客户端最后确认(3. ACK(确认))
-
客户端行动:客户端确认服务端的SYN+ACK报文后,发送以下关键信息的报文段:
- 标志位:YSN=0,ACK=1
- 序列号:x+1(自己的序列号加1)
- 确认号:y+1 (确认收到了服务器序列号y的数据,请求下一个字节)
-
状态变化:客户端发送报文后,其状态变为ESTABLISHED(已建立连接),服务器接收到ACK报文后,状态也变为SETABLISHED。
注意:三次握手前两次不可以携带数据,最后一次可以携带数据。
3. TCP为什么需要三次握手?
- 确认双方的收发能力
- 第一次握手:服务器知道客户端具备发送能力。
- 第二次握手:客户端知道服务器收发能力正常,知道服务器具备发送接收能力。
- 第三次握手:服务器知道自己收发能力正常,客户端收发能力正常。
如果不进行三次握手,服务端无法知道自己发送的数据是否被客户端接收到,也无法确认客户端是否已经准备好接收数据。
-
防止旧的已经失效的连接突然传到连接端
这是很经典的原因:
- 客户端发送一个连接请求A,因为网络原因,A在网络中滞留了。
- 客户端很长时间没有收到服务器的的第二次握手响应,于是以为丢包了,于是重写发送连接请求B,顺利完成了两次握手连接,通信结束后,关闭了连接。
- 这是服务器突然收到了连接请求A。
- 如果是两次握手连接:服务器接收到了A的请求后,会立即创建连接,等待客户端发送数据。但客户端已经不使用这个连接了,这会导致服务器的资源浪费。
- 如果是三次握手连接:服务端接收到A后会回一个ACK,客户端发现这并不是自己当前的请求序号,于是拒绝响应,连接建立失败。
- 三次握手客户端拒绝响应的过程:客户端判断出这是一个无效响应。会给服务器发送一个RST(复位)标志位为1的TCP报文。客户端收到RST报文后,立刻明白刚才连接是无效的。它会终止连接过程,并释放半连接的内存等资源,并重新恢复到LISTEN(监听)状态。
-
同步序列号
TCP是可靠传输,靠的是序列号。
- 三次握手的过程其实是双方交换初始序列号的过程。
- 客户端告诉服务器:我的初始序列号是X;服务器回复:收到X,我的初始序列号是Y;最后客户端再回一句:收到Y。
总结:
- 为什么不是两次:只能保证单向通畅,容易产生死锁或浪费资源。
- 为什么不是四次:理论上可以,但没有必要。因为第二次握手,服务端把对客户端的确认(ACK)和自己的同步请求(YSN)合并成同一个包发送,从而提高了效率。
4. TCP三次握手,客户端第三次发送的确认包丢失了发生什么?
- 双方当前的状态
- 客户端:在发出第三个ACK包后,客户端状态为ESTABLISHED(已建立连接)。在客户端视角里,它可以发送应用层数据了。
- 服务端:由于没收到ACK,它的状态停留在SYN_RCVD(半连接状态),这个连接会暂时存放在服务器的半连接队列(SYN Queue)中。
- 服务器的自救:超时重连
- 服务器有个定时器,如果超时没接收到ACK,服务器会以为自己的第二次SYN和ACK丢失,或者客户端第三次ACK丢失,于是重写发送SYN和ACK。
- 如果重传后,还是没有收到ACK,服务器会采取指数退避的方式(例如等待1s,2
- s,4s,8s...)继续重传。
- 在Linux内核中,有个参数net.ip4.tcp_synack-retries控制最大重传次数(通常默认是5次)。
-
接下来3种核心场景
场景1:客户端没有发送数据(空闲等待)
-
客户端处于SETABLISHED状态,但没有立即发送数据。
-
服务器多次重传SYN+ACK。
-
客户端每次接收到重传的SYN+ACK,都会重新回复一个ACK。
-
如果其中某次ACK重新到达服务端,服务端状态转为ESTABLISHED,握手最终成功。
场景2:客户端立刻发送数据(网络层自动携带ACK)
-
客户端发送第三次ACK后,立即发送数据。
-
TCP协议在封装数据报文时,会自动把ACK置1,并且携带确认号。
-
如果携带数据和ACK报文到达处于SYN_RCVD状态的服务端,服务端会提取其中的ACK信息,顺利完成三次握手,状态转为SETABLISHED,然后正常接收这批数据。
-
这个机制极大的提高了网络通信的容错率。
场景3:服务端已经放弃连接,客户端才发送数据
-
服务端重传次数超过上限,会丢弃该半连接,恢复到LISTEN状态。
-
过了很久客户端,才发送应用层数据。
-
服务器接收到数据包后,发现这不是一个正常连接的数据包。
-
服务端立即回复一个RST包。
-
客户端收到RST包,知道该连接崩溃,套接字API会向应用层抛出Connection reset by peer的错误,客户端随即关闭连接。
-
5. 三次握手和accept是什么关系?accept做了哪些事情?
-
它们是完全解耦 的关系。
握手是内核全自动完成的: TCP 的三次握手完全由底层的 Linux 内核网络协议栈负责,根本不需要应用程序(用户态代码)的参与。
独立存在: 只要服务端调用了 listen(),哪怕代码里一直不调用 accept(),只要内核的队列没满,客户端依然可以成功完成三次握手,建立连接。accept 只是负责"消费"已经建立好的连接。
-
accept 具体做了哪些事情?
当应用程序调用 accept() 时,它本质上是一个"消费者",主要做了以下三件事:
消费全连接队列: 它会去检查内核维护的全连接队列(Accept Queue)。这个队列里存放的都是已经完成三次握手、状态为 ESTABLISHED 的连接。
分配新的文件描述符(核心): 如果队列里有连接,accept 会把这个连接摘取出来,并在内核中为它分配一个新的"已连接套接字文件描述符(connfd) "返回给用户态。这个新的 FD 专门用来和当前客户端进行读写,而原来的监听 FD 继续去接待新连接。
提取客户端信息: 它会将连进来的客户端的 IP 地址和端口号等元信息,从内核态拷贝到我们传入的 sockaddr 结构体内存中。
cpp
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
int main() {
// 1. 初始化监听 Socket (为了简洁,省略了前置的错误检查)
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr{};
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
serv_addr.sin_addr.s_addr = INADDR_ANY;
bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
listen(listenfd, 128); // 开始监听,维护全连接队列
std::cout << "服务器已启动,正在监听 8080 端口..." << std::endl;
// ================= 核心 accept 逻辑 =================
// 2. 准备传出参数:用于接收客户端的 IP 和端口信息
struct sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
// 3. 调用 accept:从全连接队列中取出一个已完成三次握手的连接
// 注意:默认情况下,如果队列为空,这里会阻塞等待
int connfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_len);
if (connfd < 0) {
std::cerr << "获取连接失败!" << std::endl;
return -1;
}
// 4. 成功获取连接!通过传出参数解析客户端信息
std::cout << "\n[成功] 获取到新连接!" << std::endl;
std::cout << "客户端 IP : " << inet_ntoa(client_addr.sin_addr) << std::endl;
std::cout << "客户端 Port : " << ntohs(client_addr.sin_port) << std::endl;
std::cout << "分配的通信FD: " << connfd << std::endl;
// ====================================================
// 5. 释放资源
close(connfd); // 关闭与该客户端的通信连接
close(listenfd); // 关闭监听套接字
return 0;
}
6. 客户端发送第一个SYN报文,服务器没有接收到怎么办?
- 客户端的"自救":超时重传与指数退避
- 当客户端调用 connect() 发出SYN后,内核TCP协议会启动一个定时器。如果在这个时间内没有接收到 SYN+ACK 响应,客户端会认为SYN包丢了,并重新发送SYN报文。
- 为了无脑发包,重传采用的是指数避让,即每次等待时间翻倍(1s,2s,4s...)。
- Linux内核中的重传细节
- Linux中控制重传报文次数是有限的,由内核参数 net.ip4.tcp_syn_retries 控制,默认一般是5次。
- 假如第一次失败,后续重传等待时间为1s,2s,4s,8s,16s。
- 重传次数耗尽不会立即确认连接失败,会等待最后一次时间的两倍,32s,这个时间结束,还没有收到 SYN+ACK 响应,就可以确认彻底失败。
- C++ Socket API 的表现
- 阻塞模式(默认):你调用connect()会一直卡住 (挂起当前线程)。直到成功或彻底失败,失败后才会返回-1,并且全局错误码erron被设置为ETIMEDOUT(连接超时)。
cpp
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
int main() {
// 1. 初始化监听 Socket (为了简洁,省略了前置的错误检查)
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr{};
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
serv_addr.sin_addr.s_addr = INADDR_ANY;
bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
listen(listenfd, 128); // 开始监听,维护全连接队列
std::cout << "服务器已启动,正在监听 8080 端口..." << std::endl;
// ================= 核心 accept 逻辑 =================
// 2. 准备传出参数:用于接收客户端的 IP 和端口信息
struct sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
// 3. 调用 accept:从全连接队列中取出一个已完成三次握手的连接
// 注意:默认情况下,如果队列为空,这里会阻塞等待
int connfd = accept(listenfd, (struct sockaddr*)&client_addr, &client_len);
if (connfd < 0) {
std::cerr << "获取连接失败!" << std::endl;
return -1;
}
// 4. 成功获取连接!通过传出参数解析客户端信息
std::cout << "\n[成功] 获取到新连接!" << std::endl;
std::cout << "客户端 IP : " << inet_ntoa(client_addr.sin_addr) << std::endl;
std::cout << "客户端 Port : " << ntohs(client_addr.sin_port) << std::endl;
std::cout << "分配的通信FD: " << connfd << std::endl;
// ====================================================
// 5. 释放资源
close(connfd); // 关闭与该客户端的通信连接
close(listenfd); // 关闭监听套接字
return 0;
}
- 非阻塞模式(配合 epoll): connect() 会立刻返回 -1 且 errno 为 EINPROGRESS(表示正在建立连接)。你可以把这个 Socket 加入 epoll 监听可写事件。如果底层 SYN 重传最终失败,epoll 会触发 EPOLLERR (严重错误)或 EPOLLHUP(连接挂断)事件,此时通过 getsockopt 检查套接字错误,就能拿到 ETIMEDOUT。
7. 假设客户端重传了SYN报文后,服务端这边又收到重复的SYN报文怎么办?
继续发送第二次握手报文,因为不知道第一次给客户端的SYN+ACK是不是丢包了。
8. 第一次握手,客户端发送SYN,服务端回复ACK报文,这个过程服务端内部做了什么?
服务端收到客户端SYN后,会把连接放到半连接队列中,然后向客户端发送SYN和ACK包,服务端收到第三次握手ACK后,将连接从半连接队列移入全连接队列,等待进程调用accept函数把连接取出来。
注意:不管是半连接还是全连接,都有最大连接限度,超过队列限度,的连接直接丢弃或者返回RST。
9. 大量SYN包发送给服务端会发生什么事情?
有可能导致TCP半连接队列满了,这样后续的SYN报文就会丢弃,导致客户端和服务器无法连接。
- 开启 SYN Cookies (终极杀招):
- 开启 nrt.ipv4.tcp_syncookies
bash
echo 1 > /proc/sys/net/ipv4/tcp_syncookies
- 当半连接队列被打满时,服务端不再为新的 SYN 请求分配队列内存,而是根据源 IP、端口等信息通过密码学哈希计算出一个 Cookie,并把它作为 SYN+ACK 的初始序列号(ISN)发回去。
- 如果对方是正常的客户端,它会在第三次握手的 ACK 中把这个 Cookie 加 1 发回来。服务端通过校验这个序列号,就能确认合法性并直接建立连接,从而绕过了半连接队列的限制。
- 减小 SYN+ACK 重传次数:
- 修改 net.ipv4.tcp_synack_retries,将其从默认的 5 降低到 1 或 2,让服务器更快地放弃那些不回复的"僵尸"连接,加速队列内存的回收。
- 扩大半连接队列容量:
- 适当调大 net.ipv4.tcp_max_syn_backlog,但这只是治标不治本,通常配合 SYN Cookies 一起使用。
- 防火墙与限流:
- 在系统层使用 iptables 限制单个 IP 发送 SYN 包的速率,或者在云服务商层面开启 DDoS 高防 IP 清洗流量。