TCP三次握手与四次挥手:核心知识点整理

这篇文章整理了TCP连接管理相关的核心问题,包括自己的理解、踩过的坑,以及在C++ Reactor项目中的实践经验。
在这里插入图片描述

目录


前言

这段时间在看王道的计算机网络课程,发现TCP连接管理这部分概念特别多:三次握手、四次挥手、TIME_WAIT、CLOSE_WAIT…光记住流程还不够,更重要的是理解为什么要这样设计。

这篇文章整理了我认为最重要的10个问题,每个问题我都会写下自己的理解,以及在实现Reactor项目时遇到的实际问题。


Q1: TCP三次握手的完整过程

我的理解

TCP建立连接需要三次握手,完整流程如下:

第一次握手:

客户端发送:SYN=1, seq=x
客户端状态:CLOSED → SYN_SENT

客户端向服务器发起连接请求。

第二次握手:

服务器发送:SYN=1, ACK=1, seq=y, ack=x+1
服务器状态:LISTEN → SYN_RCVD

服务器确认收到请求,同时也向客户端发起连接请求。

第三次握手:

客户端发送:ACK=1, ack=y+1
客户端:SYN_SENT → ESTABLISHED
服务器:SYN_RCVD → ESTABLISHED

客户端确认,连接建立完成。

状态变化图:

客户端                           服务器
CLOSED                          LISTEN
  |                               |
  |--- SYN, seq=x -------------->|
  |                               |
SYN_SENT                      SYN_RCVD
  |                               |
  |<-- SYN+ACK, seq=y, ack=x+1 --|
  |                               |
  |--- ACK, ack=y+1 ------------->|
  |                               |
ESTABLISHED                   ESTABLISHED

我原来的疑问

Q:为什么ACK号是x+1,而不是x?

一开始我也搞不清楚,后来理解了:

  • seq表示"我这次发送的数据从序号x开始"
  • ack表示"我期望你下次从序号x+1开始发"
  • 所以确认号永远是"对方seq + 1"

Q:客户端和服务器的状态为什么不同?

因为两者的角色不同:

  • 客户端是主动发起方,状态是:CLOSED → SYN_SENT → ESTABLISHED
  • 服务器是被动接受方,状态是:LISTEN → SYN_RCVD → ESTABLISHED

Q2: 为什么需要三次握手?两次不行吗?

我的理解

看完王道视频后,我总结了两个关键原因:

原因1:确认双方的收发能力

  • 第一次握手:服务器确认"客户端能发"
  • 第二次握手:客户端确认"服务器能发能收,客户端能收"
  • 第三次握手:服务器确认"客户端能收"

如果只有两次握手,服务器无法确认"客户端能收"。

原因2:防止历史连接

这个例子帮我理解了:

场景:客户端发送的旧SYN因为网络延迟,在连接关闭后才到达服务器

两次握手的问题:
  旧SYN → 服务器:建立连接
  服务器:分配资源,等待数据
  客户端:不知情,不会发数据
  结果:服务器资源浪费

三次握手的解决:
  旧SYN → 服务器:发送SYN+ACK
  客户端:收到后发现不是自己期望的连接,发送RST
  服务器:释放资源
  结果:避免了错误连接

项目中的体现

在实现Reactor项目时,我用accept()从全连接队列中取出已经完成三次握手的连接:

int connfd = accept(listenfd, NULL, NULL);
// 此时TCP已经完成了三次握手
// connfd已经是ESTABLISHED状态

这个过程对应用层是透明的,内核自动完成了三次握手。


Q3: 第三次握手可以携带数据吗?

我的理解

可以!

前两次握手不能携带数据,因为连接还没完全建立,如果允许携带数据,攻击者可以发送大量带数据的SYN,造成SYN Flood攻击。

但第三次握手时,客户端已经进入ESTABLISHED状态,连接已经建立,所以可以携带数据。

实际应用

HTTP/1.1的请求就是在第三次握手中发送的:

第一次:客户端 → 服务器:SYN
第二次:服务器 → 客户端:SYN+ACK
第三次:客户端 → 服务器:ACK + HTTP请求(GET /index.html)

这样可以节省一个RTT,提高效率。


Q4: 什么是SYN攻击?如何防御?

我的理解

SYN攻击原理:

攻击者发送大量SYN请求(伪造IP地址),但不回复ACK。服务器会为每个SYN分配资源并等待,直到超时(默认63秒)。大量半连接会占满服务器资源,导致无法处理正常请求。

防御方法:SYN Cookie

最有效的防御方法是SYN Cookie:

  • 不分配资源,将连接信息编码到seq中
  • seq = hash(客户端IP, 端口, 服务器IP, 端口, 密钥)
  • 收到ACK后验证Cookie,再分配资源

这样攻击者发再多SYN也不会占用服务器资源。

在Linux中启用:

echo 1 > /proc/sys/net/ipv4/tcp_syncookies

项目中的考虑

在部署Reactor服务器时,我查了一下系统配置:

cat /proc/sys/net/ipv4/tcp_syncookies
# 输出:1(已启用)

现代Linux系统默认都开启了SYN Cookie,所以不用太担心SYN攻击。


Q5: 什么是半连接队列和全连接队列?

我的理解

服务器维护两个队列:

半连接队列(SYN队列):

  • 存储处于SYN_RCVD状态的连接
  • 收到SYN后,发送SYN+ACK,加入半连接队列
  • 队列长度:tcp_max_syn_backlog

全连接队列(Accept队列):

  • 存储处于ESTABLISHED状态的连接
  • 收到第三次握手ACK后,从半连接队列移到全连接队列
  • accept()从这里取出连接
  • 队列长度:min(somaxconn, backlog)

项目中遇到的问题

一开始我的Reactor服务器在高并发时会丢连接,后来发现是全连接队列满了。

原因分析:

// 我的代码
listen(listenfd, 5);  // backlog太小了!

解决方法:

listen(listenfd, 128);  // 增大backlog

同时检查系统参数:

cat /proc/sys/net/core/somaxconn
# 输出:128

现在高并发下也不会丢连接了。


Q6: TCP四次挥手的完整过程

我的理解

TCP关闭连接需要四次挥手:

第一次挥手:

客户端:调用close(),发送FIN=1
状态:ESTABLISHED → FIN_WAIT_1

第二次挥手:

服务器:收到FIN,发送ACK
服务器:ESTABLISHED → CLOSE_WAIT
客户端:FIN_WAIT_1 → FIN_WAIT_2

第三次挥手:

服务器:发送完数据后,调用close(),发送FIN
状态:CLOSE_WAIT → LAST_ACK

第四次挥手:

客户端:收到FIN,发送ACK
客户端:FIN_WAIT_2 → TIME_WAIT(等待2MSL)→ CLOSED
服务器:LAST_ACK → CLOSED

我原来的疑问

Q:为什么关闭需要四次,而建立只需要三次?

因为TCP是全双工的:

  • 建立连接时,双方都没有数据要发送,可以把SYN和ACK合并
  • 关闭连接时,一方想关闭,但另一方可能还有数据要发送,不能立刻关闭
  • 所以ACK和FIN通常不能合并,需要四次

Q7: TIME_WAIT状态的作用

我的理解

TIME_WAIT持续2MSL(Linux默认60秒),有两个作用:

作用1:确保最后的ACK能到达对方

如果最后的ACK丢失了:

客户端                      服务器
  |-------- ACK ------------->|  X(丢失)
  |                           |
TIME_WAIT                   LAST_ACK
  |                           |(超时,重发FIN)
  |<------- FIN --------------|
  |-------- ACK ------------->|(重新发送)

如果客户端立刻关闭,就无法收到重发的FIN,服务器就无法正常关闭。

作用2:防止旧连接的数据干扰新连接

关闭后,网络中可能还有延迟的数据包。TIME_WAIT等待2MSL,确保所有旧数据包都消失了,才允许建立相同四元组的新连接。

项目中遇到的问题

我的Reactor服务器关闭后,立刻重启会报错:

bind error: Address already in use

原因: 服务器主动关闭连接,进入TIME_WAIT,端口被占用60秒。

解决方法: 设置SO_REUSEADDR

int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

这样即使处于TIME_WAIT状态,也可以重新bind。


Q8: 大量CLOSE_WAIT说明什么问题?

我的理解

CLOSE_WAIT状态表示:对方已经关闭连接(发送了FIN),但我方还没调用close()

如果出现大量CLOSE_WAIT,说明程序有BUG:收到对方的FIN后,忘记调用close()

项目中踩过的坑

一开始我的代码:

// 错误代码
int n = recv(connfd, buf, sizeof(buf), 0);
if (n == 0) {
    // 对方关闭,进入CLOSE_WAIT
    // 但忘记调用close()
    // 一直停留在CLOSE_WAIT状态
}

正确做法:

if (n == 0) {
    // 检测到对方关闭,立刻关闭连接
    epoll_ctl(epollfd, EPOLL_CTL_DEL, connfd, NULL);
    close(connfd);  // 必须调用!
    LOG_INFO("Connection closed by peer, fd=%d", connfd);
}

检测方法:

netstat -an | grep CLOSE_WAIT | wc -l

现在我的服务器不会有CLOSE_WAIT堆积了。


Q9: close() vs shutdown()

我的理解

在学习过程中,我发现了两个关闭连接的函数:

close():

  • 彻底关闭连接,发送FIN
  • 引用计数-1,为0时才真正关闭
  • 无法控制只关闭读或写

shutdown():

  • 可以只关闭读端或写端
  • 立即关闭,不管引用计数
  • SHUT_WR:关闭写端,发送FIN,但还能读
  • SHUT_RD:关闭读端,不发送FIN
  • SHUT_RDWR:关闭读写,相当于close()

项目中的应用

有时候需要只关闭写端,但还能继续读取数据:

void graceful_shutdown(int connfd) {
    // 1. 关闭写端,发送FIN
    shutdown(connfd, SHUT_WR);
    
    // 2. 继续读取对方可能发送的数据
    char buf[1024];
    while (true) {
        int n = recv(connfd, buf, sizeof(buf), 0);
        if (n == 0) {
            // 对方也关闭了
            break;
        }
        // 处理数据...
    }
    
    // 3. 彻底关闭
    close(connfd);
}

Q10: 在项目中如何处理连接关闭

我的实现

在Reactor项目中,我需要处理两种情况:

情况1:检测对方关闭(被动关闭)

if (events[i].events & EPOLLIN) {
    int n = recv(connfd, buf, sizeof(buf), 0);
    
    if (n == 0) {
        // 对方关闭连接(收到FIN)
        epoll_ctl(epollfd, EPOLL_CTL_DEL, connfd, NULL);
        close(connfd);
        LOG_INFO("Connection closed by peer");
    }
    else if (n < 0) {
        // 错误处理
        if (errno != EAGAIN && errno != EWOULDBLOCK) {
            epoll_ctl(epollfd, EPOLL_CTL_DEL, connfd, NULL);
            close(connfd);
        }
    }
    else {
        // 处理数据
    }
}

情况2:主动关闭连接

// HTTP短连接:处理完请求后主动关闭
void handle_http_request(int connfd) {
    // 接收请求
    // 处理请求
    // 发送响应
    
    // 主动关闭
    close(connfd);  // 发送FIN,进入FIN_WAIT_1
}

注意事项:

  1. 检测到recv() == 0,立刻close()(避免CLOSE_WAIT)
  2. 所有错误路径都要close()(避免fd泄漏)
  3. 主动关闭会进入TIME_WAIT(60秒)
  4. 设置SO_REUSEADDR,允许服务器重启

学习总结

这段时间系统复习TCP连接管理,收获很大:

理论层面:

  • 理解了三次握手和四次挥手的完整流程
  • 搞清楚了各种状态的含义和转换条件
  • 知道了TIME_WAIT、CLOSE_WAIT的作用

实践层面:

  • 在项目中正确处理连接关闭
  • 解决了Address already in use的问题
  • 避免了CLOSE_WAIT堆积

遇到的坑:

  1. 忘记调用close()导致CLOSE_WAIT堆积
  2. listen()的backlog太小导致丢连接
  3. 没设置SO_REUSEADDR导致无法快速重启

下一步学习:

  • TCP可靠传输机制(序列号、ACK、重传)
  • 滑动窗口和拥塞控制
  • HTTP协议和缓存机制

参考资料

  • 王道计算机网络视频课程

这篇文章记录了我对TCP连接管理的理解和项目实践经验。如果有错误或不准确的地方,欢迎指正!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐