计算机网络传输层-TCP三次握手底层详情

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. 步骤1:客户端发起SYN请求(1.SYN同步)
  • 客户端行动:

    • 标志位:SYN=1,ACK=0
    • 序列化:初始化序列号x
  • 状态变化:客户端发送报文后,状态从CLOSED变为SYN-SENT(同步已发送)。

  • 服务器接收:服务器接收到该报文后,会创建一个包含以一下关键信息的TCP报文段,作为响应。

  1. 步骤2:服务器响应SYN+ACK
  • 服务器行动:服务器接收到客户端SYN请求,会创建并发送一个包含以下关键信息的TCP报文段:

    • 标志位:YSN=1,ACK=1
    • 序列号:初始序列号y
    • 确认号:x+1(确认收到客户端seq=x,并请求下一个字节)
  • 状态变化:服务器发送报文后,状态变为SYN-RECEIVED(同步已接收)。

  • 客户端接收:客户端接收后,会再次发送一个ACK报文。

  1. 步骤3:客户端最后确认(3. ACK(确认))
  • 客户端行动:客户端确认服务端的SYN+ACK报文后,发送以下关键信息的报文段:

    • 标志位:YSN=0,ACK=1
    • 序列号:x+1(自己的序列号加1)
    • 确认号:y+1 (确认收到了服务器序列号y的数据,请求下一个字节)
  • 状态变化:客户端发送报文后,其状态变为ESTABLISHED(已建立连接),服务器接收到ACK报文后,状态也变为SETABLISHED。

注意:三次握手前两次不可以携带数据,最后一次可以携带数据。

3. TCP为什么需要三次握手?

  1. 确认双方的收发能力
  • 第一次握手:服务器知道客户端具备发送能力。
  • 第二次握手:客户端知道服务器收发能力正常,知道服务器具备发送接收能力。
  • 第三次握手:服务器知道自己收发能力正常,客户端收发能力正常。

如果不进行三次握手,服务端无法知道自己发送的数据是否被客户端接收到,也无法确认客户端是否已经准备好接收数据。

  1. 防止旧的已经失效的连接突然传到连接端

    这是很经典的原因:

    1. 客户端发送一个连接请求A,因为网络原因,A在网络中滞留了。
    2. 客户端很长时间没有收到服务器的的第二次握手响应,于是以为丢包了,于是重写发送连接请求B,顺利完成了两次握手连接,通信结束后,关闭了连接。
    3. 这是服务器突然收到了连接请求A。
    4. 如果是两次握手连接:服务器接收到了A的请求后,会立即创建连接,等待客户端发送数据。但客户端已经不使用这个连接了,这会导致服务器的资源浪费。
    5. 如果是三次握手连接:服务端接收到A后会回一个ACK,客户端发现这并不是自己当前的请求序号,于是拒绝响应,连接建立失败。
    6. 三次握手客户端拒绝响应的过程:客户端判断出这是一个无效响应。会给服务器发送一个RST(复位)标志位为1的TCP报文。客户端收到RST报文后,立刻明白刚才连接是无效的。它会终止连接过程,并释放半连接的内存等资源,并重新恢复到LISTEN(监听)状态。
  2. 同步序列号

    TCP是可靠传输,靠的是序列号。

  • 三次握手的过程其实是双方交换初始序列号的过程。
  • 客户端告诉服务器:我的初始序列号是X;服务器回复:收到X,我的初始序列号是Y;最后客户端再回一句:收到Y。

总结:

  • 为什么不是两次:只能保证单向通畅,容易产生死锁或浪费资源。
  • 为什么不是四次:理论上可以,但没有必要。因为第二次握手,服务端把对客户端的确认(ACK)和自己的同步请求(YSN)合并成同一个包发送,从而提高了效率。

4. TCP三次握手,客户端第三次发送的确认包丢失了发生什么?

  1. 双方当前的状态
  • 客户端:在发出第三个ACK包后,客户端状态为ESTABLISHED(已建立连接)。在客户端视角里,它可以发送应用层数据了。
  • 服务端:由于没收到ACK,它的状态停留在SYN_RCVD(半连接状态),这个连接会暂时存放在服务器的半连接队列(SYN Queue)中。
  1. 服务器的自救:超时重连
  • 服务器有个定时器,如果超时没接收到ACK,服务器会以为自己的第二次SYN和ACK丢失,或者客户端第三次ACK丢失,于是重写发送SYN和ACK。
  • 如果重传后,还是没有收到ACK,服务器会采取指数退避的方式(例如等待1s,2
  • s,4s,8s...)继续重传。
  • 在Linux内核中,有个参数net.ip4.tcp_synack-retries控制最大重传次数(通常默认是5次)。
  1. 接下来3种核心场景

    场景1:客户端没有发送数据(空闲等待)

    1. 客户端处于SETABLISHED状态,但没有立即发送数据。

    2. 服务器多次重传SYN+ACK。

    3. 客户端每次接收到重传的SYN+ACK,都会重新回复一个ACK。

    4. 如果其中某次ACK重新到达服务端,服务端状态转为ESTABLISHED,握手最终成功。

    场景2:客户端立刻发送数据(网络层自动携带ACK)

    1. 客户端发送第三次ACK后,立即发送数据。

    2. TCP协议在封装数据报文时,会自动把ACK置1,并且携带确认号。

    3. 如果携带数据和ACK报文到达处于SYN_RCVD状态的服务端,服务端会提取其中的ACK信息,顺利完成三次握手,状态转为SETABLISHED,然后正常接收这批数据。

    4. 这个机制极大的提高了网络通信的容错率。

    场景3:服务端已经放弃连接,客户端才发送数据

    1. 服务端重传次数超过上限,会丢弃该半连接,恢复到LISTEN状态。

    2. 过了很久客户端,才发送应用层数据。

    3. 服务器接收到数据包后,发现这不是一个正常连接的数据包。

    4. 服务端立即回复一个RST包。

    5. 客户端收到RST包,知道该连接崩溃,套接字API会向应用层抛出Connection reset by peer的错误,客户端随即关闭连接。

5. 三次握手和accept是什么关系?accept做了哪些事情?

  1. 它们是完全解耦 的关系。

    握手是内核全自动完成的: TCP 的三次握手完全由底层的 Linux 内核网络协议栈负责,根本不需要应用程序(用户态代码)的参与。

    独立存在: 只要服务端调用了 listen(),哪怕代码里一直不调用 accept(),只要内核的队列没满,客户端依然可以成功完成三次握手,建立连接。accept 只是负责"消费"已经建立好的连接。

  2. 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报文,服务器没有接收到怎么办?

  1. 客户端的"自救":超时重传与指数退避
  • 当客户端调用 connect() 发出SYN后,内核TCP协议会启动一个定时器。如果在这个时间内没有接收到 SYN+ACK 响应,客户端会认为SYN包丢了,并重新发送SYN报文。
  • 为了无脑发包,重传采用的是指数避让,即每次等待时间翻倍(1s,2s,4s...)。
  1. Linux内核中的重传细节
  • Linux中控制重传报文次数是有限的,由内核参数 net.ip4.tcp_syn_retries 控制,默认一般是5次。
  • 假如第一次失败,后续重传等待时间为1s,2s,4s,8s,16s。
  • 重传次数耗尽不会立即确认连接失败,会等待最后一次时间的两倍,32s,这个时间结束,还没有收到 SYN+ACK 响应,就可以确认彻底失败。
  1. 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报文就会丢弃,导致客户端和服务器无法连接。

  1. 开启 SYN Cookies (终极杀招):
  • 开启 nrt.ipv4.tcp_syncookies
bash 复制代码
echo 1 > /proc/sys/net/ipv4/tcp_syncookies
  • 当半连接队列被打满时,服务端不再为新的 SYN 请求分配队列内存,而是根据源 IP、端口等信息通过密码学哈希计算出一个 Cookie,并把它作为 SYN+ACK 的初始序列号(ISN)发回去。
  • 如果对方是正常的客户端,它会在第三次握手的 ACK 中把这个 Cookie 加 1 发回来。服务端通过校验这个序列号,就能确认合法性并直接建立连接,从而绕过了半连接队列的限制。
  1. 减小 SYN+ACK 重传次数:
  • 修改 net.ipv4.tcp_synack_retries,将其从默认的 5 降低到 1 或 2,让服务器更快地放弃那些不回复的"僵尸"连接,加速队列内存的回收。
  1. 扩大半连接队列容量:
  • 适当调大 net.ipv4.tcp_max_syn_backlog,但这只是治标不治本,通常配合 SYN Cookies 一起使用。
  1. 防火墙与限流:
  • 在系统层使用 iptables 限制单个 IP 发送 SYN 包的速率,或者在云服务商层面开启 DDoS 高防 IP 清洗流量。
相关推荐
kyle~4 小时前
FANUC 机械臂 --- 配置字
网络·c++·机器人·ros2
达不溜的日记4 小时前
CAN总线网络传输层CanTp详解
网络·stm32·嵌入式硬件·网络协议·网络安全·信息与通信·信号处理
wanhengidc4 小时前
网站服务器具体功能有哪些?
运维·服务器·网络·网络协议·智能手机
优化Henry5 小时前
LTE-TDD小区光路闪断故障处理典型案例
运维·网络·5g·信息与通信
杨凯凡5 小时前
【006】常见 WebSocket 场景与后端 session/鉴权的关系
网络·websocket·网络协议
CDN3606 小时前
高防切换后网站打不开?DNS 解析与回源路径故障排查
前端·网络·数据库
西西弟6 小时前
网络编程基础之TCP循环服务器
运维·服务器·网络·网络协议·tcp/ip
Oll Correct6 小时前
实验十六:路由环路问题
网络·笔记
@insist1236 小时前
网络工程师-虚拟专用网技术(一):核心精讲
网络·网络工程师·软考·软件水平考试