0%

TCP - 连接与状态

TCP/IP(Transmission Control Protocol/Internet Protocol,传输控制协议/网际协议),是现代互联网的基础通信架构,是由FTP、SMTP、TCP、UDP、IP等协议构成的协议簇,只是因为在TCP/IP协议中TCP协议和IP协议最具代表性,所以被称为TCP/IP协议。

建立/断开连接

在互联网的传输中,本身是没有“连接”的概念的,数据通过网络从A传输到B之后,这一次网络通信就完成了。

所谓的TCP连接,其实是双方按照约定的协议,通过几次网络数据的传输,沟通好双方的状态,以便后续的数据通信。本质上是双方各自维护了一个连接状态,来模拟的“连接”,比如“我明确知道你的存在了,并且你也明确知道我的存在”。

所以创建/断开连接,其实是双方进行数据沟通,更新彼此连接状态的一个过程。

建立连接

先举一个日常打电话过程中的例子,假设A和B正在打电话,电话拨通之后:

1

  • Step 1(红框)

    由于A的信号不太好,A需要先确认B是否能正常听到电话,于是问:“你能听到吗”,B听到之后回复A:“能听到”。此时A就可以确定B能听到自己的声音。

  • Step 2(蓝框)

    B现在只能确认自己能听到A的声音,并且进行了肯定的回复,但是B不确定A是否能听见自己的声音,于是B也同样发起问话;“你能听到吗”,A听到之后回复B:“能听到”。此时B也可以确定A能听到自己的声音。

自此,双方都能确定对方能听到自己的声音,于是对话开启。

一个TCP连接的建立,跟上述流程类似,只不过将问话内容换成了固定的协议内容:

2

  • Client向Server发送SYN包;
  • Server收到之后,回复ACK,同时向Client发送SYN包,两个命令会合并成一个ACK+SYN包,发送给Client;
  • Client收到ACK+SYN包之后,表示自己发送的SYN得到了确认的ACK回复,然后发送ACK回复Server的SYN;
  • Server收到ACK之后,双方都明确了彼此的状态,“连接”建立成功。

上面的三次数据传输,也就是常说的“三次握手”。

未连接队列

Server内部会维护一个未连接队列,存放那些回复了ACK+SYN之后,等待Client回复ACK的连接,队列中的连接,处于一个既没有连接成功,也没有失败的中间状态。

Server在一定时间内没有收到ACK的话,则会重发ACK+SYN包,默认情况下会重发5次,每次间隔从1s开始翻倍,即是1s,2s, 4s, 8s, 16s,共31s,最后一次发出后还要等32s才能确认第5次也超时了,所以队列中的连接,最长需要等待63s之后,TCP才会将其释放。

未连接队列的长度是有限的,如果队列被塞满了,就无法处理正常的连接了,于是衍生出来一些攻击手段,向服务器发送SYN之后就下线,使得服务器的队列被恶意SYN占满,无法处理正常连接,不过也有一些方法可以缓解(引用自TCP 的那些事儿(上)):

一些恶意的人就为此制造了SYN Flood攻击——给服务器发了一个SYN后,就下线了,于是服务器需要默认等63s才会断开连接,这样,攻击者就可以把服务器的syn连接的队列耗尽,让正常的连接请求不能处理。于是,Linux下给了一个叫tcp_syncookies的参数来应对这个事——当SYN队列满了后,TCP会通过源地址端口、目标地址端口和时间戳打造出一个特别的Sequence Number发回去(又叫cookie),如果是攻击者则不会有响应,如果是正常连接,则会把这个 SYN Cookie发回来,然后服务端可以通过cookie建连接(即使你不在SYN队列中)。请注意,请先千万别用tcp_syncookies来处理正常的大负载的连接的情况。因为,synccookies是妥协版的TCP协议,并不严谨。对于正常的请求,你应该调整三个TCP参数可供你选择,第一个是:tcp_synack_retries 可以用他来减少重试次数;第二个是:tcp_max_syn_backlog,可以增大SYN连接数;第三个是:tcp_abort_on_overflow 处理不过来干脆就直接拒绝连接了。

保活计时器

当一个TCP连接长时间没有数据交换时,如何知道对端是否是正常的呢,答案就是保活计时器。

在一个TCP连接中,开启了保活计时器的一侧,在每次收到对端的请求后都会重置计时器,若在tcp_keepalive_time(默认值7200s)参数的时间内没有收到任何客户端的数据,那么服务器就会发送一个探测报文,此时有几种情况:

  • 对端正常响应,则连接继续保持;

  • tcp_keepalive_intvl(默认75s)参数的时间内没有收到对探测报文的响应,则继续发送探测报文,最多发送tcp_keepalive_probes(默认9)次,如果仍然没有响应,则关闭连接;

  • 对端进行了异常响应(比如对端服务崩溃之后重启了,则会返回RST报文,表示对端复位了),也会关闭连接。

假设是一个最终超时的连接,那么一共需要等待tcp_keepalive_time + tcp_keepalive_intvl * tcp_keepalive_probes,7875s之后才会关闭,可以通过调整socket中的这些参数来控制。

断开链接

断开连接的逻辑与创建连接类似:

3

  • Client向Server发送FIN包,同时停止数据传输;
  • Server收到之后,回复ACK;
  • Client收到ACK包之后,确认了Server已经收到了FIN包,此时Client包进入等待状态,等待Server发送FIN包;
  • Server在合适的时候,向Client发送FIN包;
  • Client收到FIN包之后,回复ACK。

在断开连接时,并没有把Server发送给Client的ACK和FIN合并成一个包,因为当Server收到FIN包时,很可能并不会立即关闭连接,可能还有一些工作没有处理完,所以只能先回复ACK,以回复Client的FIN包,然后等到工作都结束之后,再发送FIN包给Client,所以才会有四次数据传输。这四次数据传输,也就是常说的“四次挥手”。

四次挥手不是绝对的,如果Server在收到FIN之后,并没有其他数据需要传给Client,则Server发送的FIN和ACK也是可以合并的,可以参考下面的例子。

TCP数据头

简单了解了TCP连接建立的逻辑之后,我们再来结合TCP数据头看一看:

4

TCP Flags

上图中有个TCP Flags,占用了8个bit,其中每一位都是一个标记位,用来标记当前TCP数据包的含义:

  • C:0x80,Reduced(CWR)
  • E:0x40,ECN Echo(ECE)
  • U:0x20,Urgent
  • A:0x10,Ack
  • P:0x08,Push
  • R:0x04,Reset
  • S:0x02,Syn
  • F:0x01,Fin

比如在连接建立的过程中,一个SYN数据报包会把SYN位置置为1;一个ACK+SYN数据包会把ACK和SYN都置为1。

Sequence Number 与 Acknowledgment Number

TCP是可靠传输协议,必须确保每一个字节的数据都成功传输到对端,Sequence Number与Acknowledgment Number便是用来确保可靠传输的方法之一。

  • Sequence Number:用来标识这个数据包第一个字节的序号,假设之前已经发送了1000个字节(数据头不算,只计算Data部分),那么在下一次数据发送中,数据头的Sequence Number字段会被设置成1001,表示前面已经发送了1000个字节了,这个数据包中的数据是从第1001开始;
  • Acknowledgment Number:用来标识已经收到了来自对端的多少个字节,假设一共收到对方发送的1000个字节,那么在收到最后一个数据包之后,回复的ACK中,会将Acknowledgment Number设置为1000,告诉对方前1000个字节我已经收到了。

结合这两个字段,发送端就能确认哪些数据包是对方明确收到了的,没有收到的数据包,后面会进行重传。另外,Sequence Number的初始值,是不能hard code的(引用自TCP 的那些事儿(上)):

ISN(Inital Sequence Number,初始序号)是不能hard code的,不然会出问题的——比如:如果连接建好后始终用1来做ISN,如果client发了30个segment过去,但是网络断了,于是 client重连,又用了1做ISN,但是之前连接的那些包到了,于是就被当成了新连接的包,此时,client的Sequence Number 可能是3,而Server端认为client端的这个号是30了。全乱了。

举个例子来说明一下,用C实现一个Server和一个Client,Server开启8000端口监听,收到连接之后,发送一个“Welcome”给Client,Client连接上Server之后,分别发送两次消息,内容为“1”和“23”(测试代码见文末),然后利用Wireshark的Flow Graph功能,得到如下:

5

注意,上图中的Sequence Number看起来是从0开始的,这是Wireshark为了显示更加友好,做了一个相对序号,比如SYN包:

6

相对序号是0,而实际序号是2601276280,下文中的序号为了方便阅读,也按照相对序号来描述。

  • 建立连接

    初始时,双方的Sequence都为0,在交换了SYN包之后,Sequence更新为1,表示已经发送了1个字节的数据(注意这是相对序号,从0开始,所以1表示下一个包的第一个字节编号为1,在此之前已经发送了编号为0的一个字节了)。

    这里有个需要注意的地方,前面介绍Sequence时,提到字节数的计算是不包含头的,而一个SYN包或者断开连接的FIN包,仅仅是通过数据头中的TCP Flags来标记包类型,除了数据头之外是没有携带数据的,那么为什么还是会给这两个包算一个字节的大小呢?

    原因是SYN和FIN信号都是需要ACK的,如果它不占有一个字节的话,无法判断ACK是回复的那个包。例如Client发送一个正常的数据包之后,再发送一个FIN包,那么这两个数据包的ACK回复中的Acknowledgment会相同,此时如果收到一个ACK回复,Client无法区分这个ACK是回复的数据包,还是回复的FIN包。

  • Server发送“Welcome”

    Server接收到新的Client连接之后,发送一个“Welcome”(数据长度为7)给Client,Client收到之后,回复ACK中的Acknowledgment为8,表示一共收到Server的数据字节数为8(SYN + “Welcome”)。

  • Client发送“1”

    Client发送一个“1”给Server,Server收到之后,回复ACK中的Acknowledgment为2,表示一共收到Client的数据字节数为2(SYN + “1”)。

  • Client发送“23”

    Client继续发送“23”给Server,Server收到之后,回复ACK中的Acknowledgment为4,表示一共收到Client的数据字节数为4(SYN + “1” + “23”)。

TCP状态

最开始我们提到,TCP连接本质上是双方各自维护了一个连接状态,来模拟的“连接”,那么TCP连接状态的定义及维护,就显得尤为重要了。TCP一共有11种状态,我们先来看一个完整的状态机:

7

CLOSED为初始状态,表示TCP连接是关闭着的或者未打开的,其他状态可以分为四条线来说:

  • 主动建立连接

    • 当Client执行connect()请求连接时,会发送SYN包,然后进入到SYN-SENT状态,等待Server回复ACK+SYN包;
    • Server回复ACK+SYN包之后,回复ACK,然后进入到ESTABLISHED状态,表示连接建立成功。
  • 被动建立连接

    • Server执行listen()之后,进入监听状态,即是LISTEN;
    • Server收到Client发送过来的SYN包之后,回复ACK+SYN包,然后进入到SYN-RECEIVED状态,等待Client回复ACK;
    • Client回复ACK之后,Server进入到ESTABLISHED状态,表示连接建立成功。
  • 主动关闭连接

    • Client调用close关闭连接之后,进入FIN-WAIT-1状态,等待ACK回复;

    • 此时有三种情况:

      • Client单方面发起关闭请求:

        • Server回复ACK之后,Client进入FIN-WAIT-2状态,等待Server发送FIN包;

          FIN-WAIT-2状态是没有超时时间的,如果一直无法转换到下一步,那么状态会一直保持。

        • Server发送FIN包之后,Client回复ACK,然后进入TIME-WAIT状态。

      • 正好双方都想关闭,Server在接收到Client的FIN之前,也发送了一个FIN给Client:

        • Client收到Server发送的FIN之后,回复ACK,然后进入CLOSING状态;
        • Client收到Server回复的ACK之后,Client进入到TIME-WAIT状态。
      • Client收到Server回复的ACK+FIN包:

        • Client收到ACK+FIN包之后,直接进入TIME-WAIT状态。
    • 进入TIME-WAIT状态之后,等待2 * MSL(Max Segment Lifetime)时长之后,进入CLOSED状态。

      MSL(Max Segment Lifetime),最大分段生存期,是指一个TCP报文在网络中的最长生存时长,RFC规范中建议的值是2分钟,但各个具体的TCP协议实现方式不同,Linux下默认是30s。

      为什么不能直接转换成CLOSED状态,需要有个TIME-WAIT中间状态,并且等待2MSL时间?主要有两个原因:

      • 假设Client的ACK回复发送失败了,最终没有达到Server那边,那么Server超时等不到ACK,会重发FIN包,此时如果Client已经进入CLOSED状态,那么根本就不会响应重发的FIN,因此Client需要等待2MSL时间,2MSL也就是一次发送和接收所需的最大时间,期间如果收到了Server的FIN包,则会再次回复ACK,并再次等待2MSL时间;如果没有收到,则Client认为Server已经收到ACK了,则进入CLOSED状态;
      • 引用自TCP 的那些事儿(上)。有足够的时间让这个连接不会跟后面的连接混在一起(你要知道,有些自做主张的路由器会缓存IP数据包,如果连接被重用了,那么这些延迟收到的包就有可能会跟新连接混在一起)。你可以看看这篇文章《TIME_WAIT and its design implications for protocols and scalable client server systems》。
  • 被动关闭连接

    • Server收到FIN之后,回复ACK,然后进入CLOSE-WAIT状态,等待应用做连接收尾工作(通常是判断是否还有数据要发送,或者丢弃);
    • 应用收尾工作做完之后,Server回复Client一个FIN包,然后进入LAST-ACK状态;
    • Server收到Client回复的ACK之后,进入CLOSED状态,连接关闭。

参考

测试代码

Server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
int fd, new_fd, struct_len, numbytes,i;
struct sockaddr_in server_addr;
struct sockaddr_in client_addr;
char buff[BUFSIZ];

server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8000);
server_addr.sin_addr.s_addr = INADDR_ANY;
bzero(&(server_addr.sin_zero), 8);
struct_len = sizeof(struct sockaddr_in);

fd = socket(AF_INET, SOCK_STREAM, 0);
while(bind(fd, (struct sockaddr *)&server_addr, struct_len) == -1);
while(listen(fd, 10) == -1);
new_fd = accept(fd, (struct sockaddr *)&client_addr, &struct_len);
numbytes = send(new_fd,"Welcome",7,0);
while((numbytes = recv(new_fd, buff, BUFSIZ, 0)) > 0) {
buff[numbytes] = '\0';
printf("%s\n",buff);
}
close(new_fd);
close(fd);
return 0;
}

Client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <netdb.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main(int argc,char *argv[]) {
int sockfd,numbytes;
char buf[BUFSIZ];
struct sockaddr_in their_addr;
while((sockfd = socket(AF_INET,SOCK_STREAM,0)) == -1);
their_addr.sin_family = AF_INET;
their_addr.sin_port = htons(8000);
their_addr.sin_addr.s_addr=inet_addr("127.0.0.1");
bzero(&(their_addr.sin_zero), 8);

while(connect(sockfd,(struct sockaddr*)&their_addr,sizeof(struct sockaddr)) == -1);
numbytes = recv(sockfd, buf, BUFSIZ,0);
buf[numbytes]='\0';
printf("%s\n",buf);
while(1) {
printf("input: ");
scanf("%s",buf);
numbytes = send(sockfd, buf, strlen(buf), 0);
}
close(sockfd);
return 0;
}



-=全文完=-