这篇文章上次修改于 399 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

1 背景

我们知道 UDP 协议乐观且心大,相信网络环境比较健康,数据是可以送达的,即使送达不了也没关系。而 TCP(Transmission Control Protocol,传输控制协议) 就不一样了,它是悲观且严谨,认为网络环境是恶劣的,丢包、乱序、重传和拥塞是常有的事,一言不合就可能送达不了了,因而要从算法层面来保证可靠性。

TCP 是一种面向连接的、可靠的、基于字节流的传输层协议。本文一起看一下 TCP 协议保证可靠性的机制之一:提供可靠的连接服务,通过三次握手建立连接,通过四次挥手关闭连接。

2 概念讲解

2.1 TCP 包头部

我们先来看下 TCP 头的格式。从下图可以看成,它比 UDP 复杂得多。

3.jpg

首先,源端口号和目标端口号必不可少,这跟 UDP 是一样的。因为如果没有这两个端口号,数据就不知道发给哪个应用。

接下来是包的序号。给包编号的好处是可以解决乱序的问题。

还应该有确认序号。发出的包应该有确认,不然怎么知道对方收到了没?没收到就应该重新发送,直到送达,可以有效地解决丢包问题。

接下来是一些状态位。例如 SYN(Synchronize Sequence Number)是发起一个连接,ACK(Acknowledgement)是回复,FIN(Finish)是结束连接等。TCP 是面向连接的,所以双方要维护连接的状态,这些带状态位的包的发送,会引起双方的状态的变更。

还有一个重要的就是窗口大小。TCP 要做流量控制,通信双方各声明一个窗口,标识自己当前的处理能力,避免对方发送太快或太慢。

TCP 除了做流量控制,还会做拥塞控制,对于道路的拥堵无能为力,只能控制自己的发送速度。不过拥塞窗口没有体现在 TCP 包头部。

2.2 TCP 三次握手建立连接

无论哪一方向另一方发送数据,都必须先在双方之间建立一条连接。

TCP 的连接建立,我们常常称为三次握手。

  • A:您好,我是 A。
  • B:您好 A,我是 B。
  • A:您好 B。

具体到 TCP 协议,三次握手建立连接如下图所示:

1.png

一开始,A 和 B 都处于 CLOSED 状态。B 主动监听某个端口,处于 LISTEN 状态。

  1. A 主动发起连接,发送一个 SYN 报文段,以表明自己的起始序列号,之后进入 SYN\_SENT(SYN 已被发送) 状态。
  2. B 采用 SYN + ACK 报文段响应 A 的请求。其中,ACK 用来应答 A,表明收到了 A 的请求;SYN 用来表明自己的起始序列号。之后进入 SYN\_RCVD (SYN 已被接收)状态。
  3. A 收到 B 的响应,发送 ACK 的 ACK,进入 ESTABLISHED 状态,因为它一发一收成功了。B 收到 ACK 的 ACK 后,也进入 ESTABLISHED 状态,因为它也一发一收成功了。
A、B 会在第一次发送 SYN 时分别生成随机初始序列号 seq=x,seq=y
接下来,每次交流时,seq=自己上一个包的序列号+1,ack=对方上一个包的序列号+1(期望收到对方下一包数据的序列号)。

可以看到,三次握手后,就可以确认双方的接收能力和发送能力是否正常、同步双方的序列号,为后面的可靠性传输做准备。

为什么序号不能都从 1 开始?

因为这样往往会出现冲突。例如,A 连上 B 后,发送了 1、2、3 三个包,但发送 3 时,丢了或有延迟,于是重新发送。后来 A 掉线了,重新连上 B,序号又从 1 开始,然后发送 2,但并没有想发送 3,但上次延迟的 3 又到了,发给了 B,B 以为这是下一个包,于是发生了错误。

为什么需要三次握手?

我们知道,三次握手主要是为了确认双方的接收能力和发送能力是否正常、同步双方的初始序列号,那么两次或四次可以吗?

  • 可用性

    • 不采用两次握手的原因 1:B 不能确认 A 是否具备接收数据的能力,所以就不能建立可靠的连接。
    • 不采用两次握手的原因 2:A 和 B 可以就 A 的初始化序列号达成一致,但无法就 B 的初始化序列号达成一致,所以达不到同步初始序列号的目标。
    • 不采用两次握手的原因 3:防止历史连接的建立

    防止 A 已经失效的连接请求报文段突然又传到 B,从而产生错误。
    设想这样一种情况,A 和 B 已经建立连接,做了简单的通信后,结束了连接。但是由于 A 建立连接的时候网络环境较差,所以它会发送多个建立连接请求,有的请求过了好一会儿后终于到达了 B,B 会认为这也是一个正常请求,因此建立了连接,从而造成了资源的浪费。
    如果是三次握手,A 在收到 B 的 seq+1 消息后,可以判断当前的连接是否为历史连接。如果是历史连接,就会发送终止报文 RST 给服务端,终止连接,从而避免历史连接的建立。

  • 安全性

    • 不采用两次握手的原因 4:无法避免拒绝服务攻击(DDos)

    服务器无法验证客户端的真伪,对每个进来的连接都分配连接资源,伪造的海量 SYN 报文很容易欺骗服务器,导致服务器开辟出大量的连接资源,很快导致服务端连接资源耗尽。等合法的连接请求到达时,服务端没有多余的资源,导致无法提供服务,这就是拒绝服务攻击(DDos)。

  • 效率

    • 不采用四次握手的原因:只要确认双方的接收能力和发送能力是否正常即可,所以相对于四次握手或更多次握手,三次握手已经足够了。

同样,我们在设计中往往也是需要考虑各种异常情况的,这样才能提高程序的健壮性。

2.3 TCP 四次挥手关闭连接

看完了建立连接,我们看下关闭连接,关闭连接通常被称为四次挥手。

  • A:我说完了。
  • B:好的,我知道了。

这时候,只是 A 没有要说的了,即 A 不会再发送数据,但 B 能不能在 ACK 的时候直接关闭呢?不行的,很可能 B 还有话要说,还是可以发送数据,所以称为半关闭状态。

这个时候,A 可以选择不再接收数据,也可以选择最后在接收一段数据,等待 B 也主动关闭。

  • B: 嗨 A,我也说完了,拜拜。
  • A:好的,拜拜。

这样整个连接就关闭了。

具体到 TCP 协议,四次挥手关闭连接如下图所示:

2.png

客户端和服务端都处于 ESTABLISHED 状态。

  1. A 请求关闭连接,发送 FIN 报文段,进入 FIN\_WAIT\_1(终止等待-1)状态。
  2. B 响应 A 请求,发送 ACK 应答报文段,进入 CLOSE\_WAIT(关闭等待)状态。A 收到响应后,进入 FIN\_WAIT\_2(终止等待-2)状态。
  3. 过了一会儿,B 的数据也传送完了,想要关闭连接,发送 FIN 报文段,进入 LAST\_ACK(最后确认)状态。
  4. A 收到请求后,发送 ACK 应答报文段,进入 TIME\_WAIT(时间等待)状态。B 收到应答后,进入 CLOSED 状态。
    A 等待 2MSL 后,进入 CLOSED 状态。

为什么客户端在四次挥手后还要等待 2MSL 后才会真正关闭连接?

MSL(Maximum Segment Lifetime,报文最大生存时间),是任何报文在网络上存在的最长时间,超过这个时间的报文将被丢弃。

  • 为了保证 A 最后一次挥手的报文能够到达 B
    如果 A(主动关闭方)发送的最后一条 ACK 丢失,B 就会觉得 A 没有收到我发送的 FIN,从而重新发送 FIN。如果 A 等待 2MSL,它就可以再次发出 ACK 并重新启动 2MSL 计时器。
  • 为了确保旧连接的数据包从网络中消失
    A 直接关闭连接还有另外一个问题,A 的端口直接空出来了,但是 B 原来发过的很多包还在路上,如果 A 的端口被一个新的应用占用了,这个新的应用会收到上个连接中 B 发过来的旧包。虽然序列号是重新生成的,但为了保险,A 还是要在 TIME\_WAIT 状态等 2MSL,以确保 B 发送的所有旧包都从网络中消失。

为什么需要四次挥手?

TCP 是全双工的(任何时刻数据都可以双向收发), A 到 B 是一个通道,B 到 A 又是另一个通道。

当 A 执行完第一次挥手后,只能证明 A 不会再向 B 请求数据,B 返回确认后,便不再接收 A 的数据。

但是 B 可能还在给 A 发送数据,A 还是可以接收数据的。只有当 B 需要把数据传输完毕后才能发送关闭请求,且确认 A 接收后,两边才会真正断开连接。

3 Socket 编程

Socket 封装了底层 TCP / IP 协议栈的功能,供应用层使用。Socket 在不同的操作系统上有不同的版本,我们下面看下 Linux 系统上的 C 语言版本。

为了简单起见,以下程序省略了一些错误处理。

服务端 server.c:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
​
int main(int argc , char *argv[]) {
    // Create a socket with IPv4 domain and TCP protocol
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        printf("Fail to create a socket.");
    }
​
    struct sockaddr_in server_address, client_address;
    unsigned int addr_len = sizeof(client_address);
    bzero(&server_address, sizeof(server_address));
​
    // Bind the socket with the values address and port from the sockaddr_in structure
    server_address.sin_family = AF_INET;
    server_address.sin_addr.s_addr = INADDR_ANY;
    server_address.sin_port = htons(8700);
    bind(sockfd, (struct sockaddr *)&server_address, sizeof(server_address));
​
    // listen on specified port with a maximum of 5 requests
    listen(sockfd, 5);
​
    char input_buffer[256] = {};
    char message[] = {"Hi, client!"};
​
    while (1) {
        // Accept connection signals from the client
        int client_sockfd = accept(sockfd, (struct sockaddr*)&client_address, &addr_len);
​
        // Receive data sent by the client
        recv(client_sockfd, input_buffer, sizeof(input_buffer), 0);
        printf("[2] Recv: %s\n",input_buffer);
​
        sleep(1);
​
        // Send data to the client
        send(client_sockfd, message, sizeof(message), 0);
        printf("[3] Send: %s\n", message);
​
        sleep(1);
​
        // Terminate the socket connection
        close(client_sockfd);
        printf("[6] Close Socket\n");
    }
    return 0;
}

客户端 client.c:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
​
int main(int argc , char *argv[]) {
    // Create a socket with IPv4 domain and TCP protocol
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        printf("Fail to create a socket.");
    }
​
    struct sockaddr_in info;
    bzero(&info, sizeof(info));
​
    // Connect to the server
    info.sin_family = AF_INET;
    info.sin_addr.s_addr = inet_addr("127.0.0.1");
    info.sin_port = htons(8700);
    int err = connect(sockfd, (struct sockaddr *)&info, sizeof(info));
    if (err == -1) {
        printf("Connection error");
    }
​
    // Send message to the server
    char message[] = {"Hi, server! I'm client."};
    send(sockfd, message, sizeof(message), 0);
    printf("[1] Send: %s\n", message);
​
    // Receive a message from the server
    char receiveMessage[100] = {};
    recv(sockfd, receiveMessage, sizeof(receiveMessage), 0);
    printf("[4] Recv: %s\n", receiveMessage);
​
    // Terminate the socket connection
    close(sockfd);
    printf("[5] Close Socket\n");
    return 0;
}

程序运行结果如下。

服务端:

$ gcc server.c -o server # Linux 上编译 C 语言程序
$ ./server               # Linux 上运行服务端
[2] Recv: Hi, server! I'm client.
[3] Send: Hi, client!
[6] Close Socket

新打开一个终端,运行客户端:

$ gcc client.c -o client # Linux 上编译 C 语言程序
$ ./client               # Linux 上运行客户端
[1] Send: Hi, server! I'm client.
[4] Recv: Hi, client!
[5] Close Socket

如果在运行客户端之前先打开一个新的终端并运行 tcpdump 命令进行抓包:

sudo tcpdump -S -i any tcp port 8700

可以得到如下输出:

# 三次握手
# 标志位:SYN
14:04:19.846717 IP localhost.57400 > localhost.8700: Flags [S], seq 3120777010, win 65495, options [mss 65495,sackOK,TS val 1331636511 ecr 0,nop,wscale 7], length 0
# 标志位:SYN + ACK
14:04:19.846741 IP localhost.8700 > localhost.57400: Flags [S.], seq 1145627628, ack 3120777011, win 65483, options [mss 65495,sackOK,TS val 1331636511 ecr 1331636511,nop,wscale 7], length 0
# 标志位:ACK
14:04:19.846761 IP localhost.57400 > localhost.8700: Flags [.], ack 1145627629, win 512, options [nop,nop,TS val 1331636511 ecr 1331636511], length 0
​
# 收发数据
14:04:19.846794 IP localhost.57400 > localhost.8700: Flags [P.], seq 3120777011:3120777035, ack 1145627629, win 512, options [nop,nop,TS val 1331636511 ecr 1331636511], length 24
14:04:20.847301 IP localhost.8700 > localhost.57400: Flags [P.], seq 1145627629:1145627641, ack 3120777035, win 512, options [nop,nop,TS val 1331637512 ecr 1331636511], length 12
14:04:20.847328 IP localhost.57400 > localhost.8700: Flags [.], ack 1145627641, win 512, options [nop,nop,TS val 1331637512 ecr 1331637512], length 0
​
# 四次挥手
# 标志位:FIN + ACK
14:04:20.847499 IP localhost.57400 > localhost.8700: Flags [F.], seq 3120777035, ack 1145627641, win 512, options [nop,nop,TS val 1331637512 ecr 1331637512], length 0
# 标志位:ACK
14:04:20.888037 IP localhost.8700 > localhost.57400: Flags [.], ack 3120777036, win 512, options [nop,nop,TS val 1331637553 ecr 1331637512], length 0
# 标志位:FIN + ACK
14:04:21.847617 IP localhost.8700 > localhost.57400: Flags [F.], seq 1145627641, ack 3120777036, win 512, options [nop,nop,TS val 1331638512 ecr 1331637512], length 0
# 标志位:ACK
14:04:21.847644 IP localhost.57400 > localhost.8700: Flags [.], ack 1145627642, win 512, options [nop,nop,TS val 1331638512 ecr 1331638512], length 0

也可以尝试将以上服务端和客户端程序分别运行在两台机器上,并将 IP 地址 "127.0.0.1" 替换成所在机器的真实 IP,体验下两台机器间的通信。

4 小结

TCP 通过三次握手建立连接,通过四次挥手断开连接。