TCP连接的建立和关闭详解

  1. 1. TCP建立连接(三次握手)
    1. 1.1. tcpdump三次握手
    2. 1.2. tcp同时打开
  2. 2. TCP释放连接(四次挥手)
    1. 2.1. tcpdump四次挥手
    2. 2.2. tcp同时关闭
  3. 3. TCP的半关闭(half-close)
  4. 4. TCP的半打开(half-open)
  5. 5. TCP的复位报文段
  6. 6. TCP的延迟关闭
  7. 7. TCP状态说明
  8. 8. 参考

1. TCP建立连接(三次握手)

下面两个图是从协议和接口两个角度来解释TCP的三次握手过程(分别摘自计算机网络-谢希仁和UNIX网络编程卷1):

1.1. tcpdump三次握手

下面通过tcpdump来查看tcp建立连接的过程:用nc命令来进行测试,

1
2
3
4
server:nc –l 9000
client:nc 9000

[anonymalias@qcloud ~]$sudo tcpdump -i lo port 9000 -X –S

tcpdump默认情况下,只会在SYN报文段中显示绝对的sequence number, 其他报文段只会显示相对的sequence number,所以需要加上-S才能在握手以外的阶段显示绝对sequence number。-X参数用于解析和显示每一个包的包头和包体。这里只显示IP header + TCP packet, 不显示链路层link level的头部。

图中第一个tcp建立连接的请求包中6个控制位字段:URG|ACK|PSH|RST|SYN|FIN,SYN置为1,表示是一个连接请求包;
图中第二个tcp包为服务器对连接请求的应答包,6个控制位字段:URG|ACK|PSH|RST|SYN|FIN,ACK和SYN均置为1,表示连接请求的应答包。TCP协议规定,链接建立后,所有的报文的ACK控制位必须置为1
图中第三个tcp包为客户端对应答包的确认包,6个控制位字段:URG|ACK|PSH|RST|SYN|FIN,ACK置为1。

对于tcp建立链接的过程,控制位SYN只有在链接建立前两次握手中会置为1, 其他阶段的包该控制位不能设置。控制位ACK,在第二次握手开始及以后会一直置为1,直到最后一个报文包。

  • 为什么要采用三次握手,而不是两次握手

三次握手每次都是接收到数据包的一方可以得知对方的情况,但发送方其实没有任何头绪。我虽然有发包的动作,但是我怎么知道我有没有发出去,而对方有没有接收到呢?所以经历了上面的三次握手过程,客户端和服务端都确认了自己的接收、发送能力是正常的。之后就可以正常通信了。

其次还是是为了防止已失效的连接请求报文段突然又传送到服务器,产生错误。
已失效的连接请求报文段的产生原因:当客户A发送连接请求,但因连接请求报文丢失而未收到确认。于是A会再次重传一次连接请求,此时服务器端B收到再次重传的连接请求,建立了连接,然后进行数据传输,数据传输完了后,就释放了此连接。假设A第一次发送的连接请求并没有丢失,而是在网络结点中滞留了太长时间,以致在AB通信完后,才到达B。此时这个连接请求其实已经是被A认为丢失的了。如果不进行第三次握手,那么服务器B可能在收到这个已失效的连接请求后,进行确认,然后单方面进入ESTABLISHED状态,而A此时并不会对B的确认进行理睬,这样就白白的浪费了服务器的资源。

1.2. tcp同时打开

TCP协议也是支持同时打开的,尽管这个概率是非常小的,因为这要求通信双方都知道对方的端口号,这需要双方都事先bind()各自的端口,并同时发出SYN包。TCP针对同时打开的情况,仅建立一条连接而不是两条连接。
对于同时打开连接的双方,在发送SYN包后,都进入SYN_SEND状态,在收到对端的SYN包后,进入SYN_RCVD状态,并对此SYN包再次回复SYN包进行确认,当双方都收到对端的SYN包ack后,就会进入ESTABLISHED状态。如下:

tcp同时打开需要4次握手,比正常的建立连接多了一个包。

2. TCP释放连接(四次挥手)


下面两个图是从协议和接口两个角度来解释TCP的释放过程(分别摘自计算机网络-谢希仁和UNIX网络编程卷1)

2.1. tcpdump四次挥手

和建立连接的三次握手一样,在链接建立后,然后关闭链接,tcpdump的结果如下:

tcpdump的结果如下:
图中第一个tcp的请求包中6个控制位字段:URG|ACK|PSH|RST|SYN|FIN,ACK和FIN控制位置为1, client发起关闭tcp连接的请求。
图中第二个tcp包,为服务器响应客户端的关闭包,其中6个控制位字段:URG|ACK|PSH|RST|SYN|FIN, ACK和FIN控制位置为1, 且回包的ack = 前一个包seq + 1,
图中第三个tcp包,为客户端对服务器关闭链接的应答,其中6个控制位字段:URG|ACK|PSH|RST|SYN|FIN,ACK控制位被置为1,这个包代表链接正式关闭;

TCP规定,FIN报文段即使不携带数据,它也消耗掉一个序号
TCP关闭时的状态两点需要说明:

  • CLOSE_WAIT状态
    在被动关闭连接情况下,在已经接收到FIN,但是还没有发送自己的FIN的时刻,连接处于CLOSE_WAIT状态,此时该tcp连接就处于半关闭状态。
  • TIME_WAIT状态
    在客户端收到服务器端最后的连接释放报文段后,客户端不会立即进入CLOSED状态。必须经过2MSL(Maximum Segment Lifetime)后,才进入CLOSED状态。
    • 为了确保客户端发送的最后一个ACK报文能够到达服务器端。
    • 防止TCP连接过程中所说的“已失效的连接请求报文段“出现在本连接中。客户端在发送完最后一个ACK报文后,再经过2MSL,就可以是本连接持续时间内的所有报文都在从网络中消失。这样就可以使下一个新的连接中不会出现旧的连接请求报文。

2.2. tcp同时关闭

TCP协议是允许通信的双方同时执行关闭操作,即同时发送FIN包,当双方发送FIN包后,都进入FIN_WAIT_1状态(双方互不知对方已发送FIN,所以和正常关闭一样),当双方都收到FIN包后,TCP协议层就会知道是双方是同时关闭,然后就会进入CLOSING状态,并对对方的SYN包进行ack确认,当双方收到对方的ack确认后,就会进入TIME_WAIT状态。过程如下图:

3. TCP的半关闭(half-close)


TCP之所以在释放链接的时候进行四次挥手,是因为TCP链接是全双工的,因为每个方向的链接都需要单独关闭。这个是TCP协议的最基本原则,这个原则要求:当一方完成数据的发送后,就发送FIN包来终止这个方向的链接。
所以,TCP的半关闭就是:在连接一端发送结束并关闭后,还拥有可以从另一端接收数据的能力。

不过这个功能只有很少数的应用会使用它,我们平时基本上都是通过close()直接全关闭一个链接。如果应用程序要使用这个功能,可以通过调用shutdown,且第二个参数传1

下面介绍一下:shutdown 与 close 函数 的区别

1
int close(int sockfd);

close一个TCP的socket,默认行为是把该套接字标记成已关闭,然后立即返回到调用进程,该套接字描述符不能再由调用进程使用,也就是说它不能被read或write,否则会报错,bad file descriptor,然后TCP将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP终止序列。
server端调用close()函数后,该链接不再是半关闭,而是全关闭。调用方TCP协议层会把该链接的FD标识为全关闭。server发送完FIN包后,对于客户端并不知道所谓的全关闭, 因为client只会收到一个FIN包, 按照tcp协议规定: client只会知道server到client方向的数据传输已经关闭,所以client理论上是允许再次向server端发送数据。
如果,此时client再次向server发送数据会成功,send()只负责把数据交给内核TCP发送缓冲区,然后就成功返回了,所以不会出错。但是client的tcp内核协议层会收到server的一个RST回包,表示server已不能接收数据,连接被重置。client的tcp内核协议层收到这个RST回包,无法立刻通知应用层,只是保存这个状态,如果client再次进行:

  • read()操作:返回0,表示链接已关闭。
  • write()操作:tcp协议层已经处于RST状态了,会直接触发SIGPIPE信号,默认该信号会终止程序。
    1
    int shutdown(int sockfd, int how);
    shutdown()可以选择关闭TCP全双工的单向或双向的连接。第二参数how值如下:
how的值 描述
0 Further receives are disallowed
1 Further sends are disallowed
2 Further sends and receives are disallowed (like close())

当how为0时,shutdown()关闭连接的读通道,并丢弃上层还没有读走的所有数据以及调用shutdown之后到达的数据,并且此时不会发送FIN包。
当how为1时,shutdown()关闭连接的写通道,所有的剩余数据被发送,完成后发送FIN包,执行TCP的半关闭

这里需要指出(基本不会发生):
close()不能保证一定会发送FIN包,只有当某个sockfd的引用计数为0,close 才会发送FIN段,否则只是将引用计数减1而已。也就是说只有当所有进程(可能fork多个子进程都打开了这个套接字)都关闭了这个套接字,close 才会发送FIN 段。

4. TCP的半打开(half-open)


TCP的半打开是一种非常常见的链接异常情况:一方已经关闭或异常终止连接,而另外一方去还不知道。
这种情况在:server调用close()关闭连接后,client在收到FIN包后,并没有关闭链接,此时这个TCP链接就是半打开的,此时client再次发送数据对端就会在TCP协议层触发RST回包,例如下面的tcpdump抓包:

当然还有经常会发生的就是,另一端网络断开和断电等导致的TCP处于半打开状态。

因为客户端的多样性已经网络的复杂,服务器端很容易就产生很多半打开的连接,半打开链接的检测,有很多方法,

  • 通过应用层,对每一个连接添加定时器监听,超时无数据就进行关闭操作。
  • 通过TCP协议层的keepalive选项来开始TCP的保活定时器,但保活不是TCP标准规范的(但很多tcp的实现中实现了此功能),TCP RFC中给出了3个不使用保活定时器的理由:
    • 在出现一个短暂的差错情况下,可能会是一个非常好的连接被释放掉;
    • 保活功能会耗费不必要的带宽;
    • 在按流量计费的情况下,会花掉更多的money;

下面是通过SO_KEEPALIVE选项来设置TCP连接支持保活的代码:

1
2
3
4
5
6
7
8
int open = 1;       // 开启keepalive属性. 缺省值: 0(关闭)  
int idle = 60; // 如果在60秒内没有任何数据交互,则进行探测. 缺省值:7200(s)
int interval = 5; // 探测时发探测包的时间间隔为5秒. 缺省值:75(s)
int retry_cnt = 2; // 探测重试的次数. 全部超时则认定连接失效..缺省值:9(次)
setsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &open, sizeof(open));
setsockopt(s, SOL_TCP, TCP_KEEPIDLE, idle, sizeof(idle));
setsockopt(s, SOL_TCP, TCP_KEEPINTVL, interval, sizeof(interval));
setsockopt(s, SOL_TCP, TCP_KEEPCNT, retry_cnt, sizeof(retry_cnt));

5. TCP的复位报文段


TCP的复位报文是一个很常见的数据,引用TCP/IP详解的话:无论何时,一个报文段发往基准的连接(referenced connection)出现错误,TCP都会发出一个复位报文段
主要有三种情况:

  • 前面提到的半打开状态:一个连接的另一端已经关闭,此时发送数据到对端,就会触发RST回包;
  • 到不存在的端口的连接请求:请求连接一个并没有监听的端口,TCP则会返回RST(UDP将会产生一个ICMP端口不可达的信息)。
  • 异常终止一个连接:在关闭连接的时候不是通过FIN报文进行正常关闭,而是通过直接发送RST进行连接关闭。两者的区别:
    • 通过FIN包进行关闭,又称:有序释放(orderly release),因为close()/shutdown()都会将缓冲区中的数据全部发送出去之后,才会发送FIN。
    • 通过RST复位包进行关闭,又称:异常释放(abortive release)。异常释放的特点:
      • 丢弃掉发送缓冲区中的全部数据,立刻发送RST报文;
      • RST接收方会区分另一端执行的是正常关闭,还是异常关闭。

直接通过发送RST报文进行连接的异常关闭的代码如下,具体细节详见下节的TCP的延迟关闭

1
2
3
4
5
6
7
8
9
10
11
12
struct linger so_linger;
so_linger.l_onoff = true;
so_linger.l_linger = 0;

ret = setsockopt(events[i].data.fd, SOL_SOCKET, SO_LINGER, &so_linger, sizeof(so_linger));
if(ret < 0)
{
printf("%s():[ERROR]: setsockopt error, errno:%d, info:%s\n", \
__FUNCTION__, errno, strerror(errno));
}

close(events[i].data.fd);

6. TCP的延迟关闭


TCP支持通过SO_LINGER选项来设置连接延迟关闭。前面说过close()一个socket的默认行为是把该套接字标记成已关闭,然后立即返回到调用进程,然后TCP协议层将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP终止序列。
SO_LINGER选项可以改变close()的默认行为,选项要求传入内核的参数的结构体如下:

1
2
3
4
struct linger{
int l_onoff; //0=off, non-zero = on
int l_linger; //延迟关闭的时间,单位second
};

l_onoff开启时,l_linger为非0,调用close()关闭连接时,

  • fd阻塞情况下,如果socket的发送缓冲区残留为发送的数据,那么进程将会被投入睡眠,直到所有数据被发送完并收到确定或则l_linger时间超时。如果延迟时间超时发送缓冲区的数据还没有发送完毕,那么close()会返回EWOULDBLOCK错误,并丢弃发送缓冲区的全部数据。
  • fd非阻塞情况下,close()会立即返回,接下来TCP协议层就是和fd阻塞情况一样的操作。

l_onoff开启时,l_linger为0,调用close()关闭连接时,就会触发前面说的异常释放的流程,即发送复位报文进行连接的关闭。具体TCP协议层的操作和这么做的好处请见前文。

下图是close()的行为在有无SO_LINGER选项下的行为对比,截自《UNIX网络编程劵1:套接字网络API》

7. TCP状态说明


TCP连接的双方在连接的不同阶段会处于不同的阶段,对每一个阶段的了解,是很好的使用TCP的一个基础,下表是TCP连接的各个状态:

TCP端口状态 描述
LISTEN 等待从任何远端TCP和端口的连接请求
SYN_SENT 发送完一个连接请求后等待一个匹配的连接请求。
SYN_RECEIVED 发送连接请求并且接收到匹配的连接请求以后等待连接请求确认。
ESTABLISHED 表示一个打开的连接,接收到的数据可以被投递给用户。连接的数据传输阶段的正常状态。
FIN_WAIT_1 等待远端TCP的连接终止请求,或者等待之前发送的连接终止请求的确认。
FIN_WAIT_2 等待远端TCP的连接终止请求
CLOSE_WAIT 等待本地用户的连接终止请求
CLOSING (同时关闭时)等待远端TCP的连接终止请求确认
LAST_ACK 等待先前发送给远端TCP的连接终止请求的确认(包括它字节的连接终止请求的确认)
TIME_WAIT 等待足够的时间过去以确保远端TCP接收到它的连接终止请求的确认
CLOSED 不在连接状态(这是为方便描述假想的状态,实际不存在)

下图是TCP状态转换图(摘自:UNIX网络编程劵1:套接字网络API)

8. 参考


http://zheming.wang/blog/2013/08/18/4417B74D-F037-414A-A291-CEF7B3CD511D/
http://xstarcd.github.io/wiki/shell/tcpdump_TCP_three-way_handshake.html
http://www.cnblogs.com/lshs/p/6038458.html
http://blog.csdn.net/jnu_simba/article/details/9068059
《UNIX网络编程劵1:套接字网络API》
《TCP/IP详解 劵1:协议》
《计算机网络(谢希仁)》