TCP Half-Close以及KeepAlive分析

AI 摘要: 本文介绍了基于Go的net包对TCP的半关闭状态和Keepalive进行了透析。通过调试背景和模拟复现的实例,讲解了TCP连接状态以及可能出现的问题和解决方法。文章提醒在网络服务进程异常关闭时,需要完整地完成TCP连接的断开,否则会导致TCP连接资源异常。此外,还提到了在处理过程中,要考虑到子Goroutine的连接情况,以避免出现连接未关闭的情况。阅读本文可以进一步了解TCP连接的状态变迁和处理方法。

基于Go的net包,对TCP的半关闭状态以及Keepalive的进一步透析

1. 调试背景

  1. 基于Go的net包进行编写C/S模式网络服务模拟,代码:https://github.com/tkstorm/go-example/tree/master/net/dial
  2. Server端中断退出,没有Close客户端的网络连接,只做了Server->Clent的TCP半关闭
  3. Client不知情,一直挂着等待,直至Keepalive超时
  4. 基于netstat查看到主动断开一方一直处于FIN_WAIT_2,被动断开一方一直处于CLOSE_WAIT
  5. 直至最终超时,或者客户端重新发送包检测到连接已断开(不做操作,则4中状态一直持续)

2. 模拟复现

2.1. C/S程序运行

2.1.1. listener.go - 服务端

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ go run listener.go
network:tcp, listen on:[::]:2000...
2019/06/27 15:21:10 client 127.0.0.1:57389 coming..
2019/06/27 15:22:45 Client say:"hey,man!\n"
2019/06/27 15:22:45 server write to conn size: 23 bytes
2019/06/27 15:22:51 Client say:"1\n"
2019/06/27 15:22:51 server write to conn size: 16 bytes
2019/06/27 15:22:53 Client say:"2\n"
2019/06/27 15:22:53 server write to conn size: 16 bytes
^Csignal: interrupt

2.1.2. dial.go - 客户端

1
2
3
4
5
6
7
8
$ go run dial.go
using network tcp, local address 127.0.0.1:57389, connect to 127.0.0.1:2000...
hey,man!
2019/06/27 15:22:45 client write to conn total size: 9
1
2019/06/27 15:22:51 client write to conn total size: 2
2
2019/06/27 15:22:53 client write to conn total size: 2

2.2. 抓包分析

2.2.1. 客户端创建连接

1
2
3
$ sudo tcpdump -i lo0 -nn -S port 2000
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on lo0, link-type NULL (BSD loopback), capture size 262144 bytes

3次握手:[S]、[S.]、[.],点.代表ACK标志

1
2
3
4
15:21:10.317146 IP 127.0.0.1.57389 > 127.0.0.1.2000: Flags [S], seq 3503385, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 1429491716 ecr 0,sackOK,eol], length 0
15:21:10.317204 IP 127.0.0.1.2000 > 127.0.0.1.57389: Flags [S.], seq 2869623410, ack 3503386, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 1429491716 ecr 1429491716,sackOK,eol], length 0
15:21:10.317217 IP 127.0.0.1.57389 > 127.0.0.1.2000: Flags [.], ack 2869623411, win 6379, options [nop,nop,TS val 1429491716 ecr 1429491716], length 0
15:21:10.317227 IP 127.0.0.1.2000 > 127.0.0.1.57389: Flags [.], ack 3503386, win 6379, options [nop,nop,TS val 1429491716 ecr 1429491716], length 0

2.2.2. Keepalive分析-1

客服端和服务端在未发送数据时候,Keepalive针对TCP连接进行检测,发现客户端(127.0.0.1.57569)会每间隔15s发送一个ACK空包给到服务端(127.0.0.1.2000),该时间涉及到net.Dialer包的源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
15:34:00.301844 IP 127.0.0.1.57569 > 127.0.0.1.2000: Flags [.], ack 1245687546, win 6379, length 0
15:34:00.301895 IP 127.0.0.1.2000 > 127.0.0.1.57569: Flags [.], ack 2176488825, win 6379, options [nop,nop,TS val 1430259791 ecr 1430213787], length 0
15:34:15.435192 IP 127.0.0.1.57569 > 127.0.0.1.2000: Flags [.], ack 1245687546, win 6379, length 0
15:34:15.435239 IP 127.0.0.1.2000 > 127.0.0.1.57569: Flags [.], ack 2176488825, win 6379, options [nop,nop,TS val 1430274886 ecr 1430213787], length 0
15:34:30.481987 IP 127.0.0.1.57569 > 127.0.0.1.2000: Flags [.], ack 1245687546, win 6379, length 0
15:34:30.482035 IP 127.0.0.1.2000 > 127.0.0.1.57569: Flags [.], ack 2176488825, win 6379, options [nop,nop,TS val 1430289886 ecr 1430213787], length 0
15:34:45.645724 IP 127.0.0.1.57569 > 127.0.0.1.2000: Flags [.], ack 1245687546, win 6379, length 0
15:34:45.645765 IP 127.0.0.1.2000 > 127.0.0.1.57569: Flags [.], ack 2176488825, win 6379, options [nop,nop,TS val 1430305023 ecr 1430213787], length 0
15:35:00.676002 IP 127.0.0.1.57569 > 127.0.0.1.2000: Flags [.], ack 1245687546, win 6379, length 0
15:35:00.676053 IP 127.0.0.1.2000 > 127.0.0.1.57569: Flags [.], ack 2176488825, win 6379, options [nop,nop,TS val 1430320023 ecr 1430213787], length 0
15:35:15.760528 IP 127.0.0.1.57569 > 127.0.0.1.2000: Flags [.], ack 1245687546, win 6379, length 0
15:35:15.760588 IP 127.0.0.1.2000 > 127.0.0.1.57569: Flags [.], ack 2176488825, win 6379, options [nop,nop,TS val 1430335055 ecr 1430213787], length 0

2.2.3. Keepalive分析-2: net.Dialer的Keepalive设置

这块可以发现,默认情况,Dialer.KeepAlive=0,因此被设置成15s了!

 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
func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) {
...

    if tc, ok := c.(*TCPConn); ok && d.KeepAlive >= 0 {
        setKeepAlive(tc.fd, true)
        ka := d.KeepAlive
        if d.KeepAlive == 0 {
            ka = 15 * time.Second
        }
        setKeepAlivePeriod(tc.fd, ka)
        testHookSetKeepAlive(ka)
    }
...
}

func setKeepAlivePeriod(fd *netFD, d time.Duration) error {
    // The kernel expects seconds so round to next highest second.
    d += (time.Second - time.Nanosecond)
    secs := int(d.Seconds())
    if err := fd.pfd.SetsockoptInt(syscall.IPPROTO_TCP, sysTCP_KEEPINTVL, secs); err != nil {
        return wrapSyscallError("setsockopt", err)
    }
    err := fd.pfd.SetsockoptInt(syscall.IPPROTO_TCP, syscall.TCP_KEEPALIVE, secs)
    runtime.KeepAlive(fd)
    return wrapSyscallError("setsockopt", err)
}

2.2.4. 客户端发送消息,发送PSH标志包

继续分析,客户端发送hey,man!字符串到服务端,以及12两个字符串,实际传输中还会附带一个\n回车字符!

可以看到ack包号:934698622934698624934698626

1
2
3
4
5
6
7
8
15:16:22.224881 IP 127.0.0.1.57312 > 127.0.0.1.2000: Flags [P.], seq 934698613:934698622, ack 713123462, win 6379, options [nop,nop,TS val 1429204284 ecr 1429204120], length 9
15:16:22.224914 IP 127.0.0.1.2000 > 127.0.0.1.57312: Flags [.], ack 934698622, win 6379, options [nop,nop,TS val 1429204284 ecr 1429204284], length 0
15:16:27.548259 IP 127.0.0.1.57312 > 127.0.0.1.2000: Flags [P.], seq 934698622:934698624, ack 713123462, win 6379, options [nop,nop,TS val 1429209599 ecr 1429204284], length 2
15:16:27.548328 IP 127.0.0.1.2000 > 127.0.0.1.57312: Flags [.], ack 934698624, win 6379, options [nop,nop,TS val 1429209599 ecr 1429209599], length 0
15:16:28.010360 IP 127.0.0.1.57312 > 127.0.0.1.2000: Flags [P.], seq 934698624:934698626, ack 713123462, win 6379, options [nop,nop,TS val 1429210060 ecr 1429209599], length 2
15:16:28.010392 IP 127.0.0.1.2000 > 127.0.0.1.57312: Flags [.], ack 934698626, win 6379, options [nop,nop,TS val 1429210060 ecr 1429210060], length 0
15:16:30.384172 IP 127.0.0.1.57312 > 127.0.0.1.2000: Flags [P.], seq 934698626:934698628, ack 713123462, win 6379, options [nop,nop,TS val 1429212430 ecr 1429210060], length 2
15:16:30.384213 IP 127.0.0.1.2000 > 127.0.0.1.57312: Flags [.], ack 934698628, win 6379, options [nop,nop,TS val 1429212430 ecr 1429212430], length 0

2.2.5. 服务端异常中断

在服务端基于Ctrl+C发送中断信号给客户端进程后,服务端发送FIN标志包给到客户端,进程(signal: interrupt)中断退出,至此,服务端引发了一个TCP Conn连接的半关闭(仅服务端=>客户端这半边关闭了):

1
2
15:23:00.063428 IP 127.0.0.1.2000 > 127.0.0.1.57389: Flags [F.], seq 2869623411, ack 3503399, win 6379, options [nop,nop,TS val 1429601142 ecr 1429594758], length 0
15:23:00.063479 IP 127.0.0.1.57389 > 127.0.0.1.2000: Flags [.], ack 2869623412, win 6379, options [nop,nop,TS val 1429601142 ecr 1429601142], length 0

2.2.6. 客户端对已关闭的连接发送信息

若挂起的客户端再发送任意一个字节,则会触发服务端的RST标志 - RESET重置包(这个包是由OS处理的,网卡接到该数据包,触发网络IO,引发OS的IO中断处理例程,交给网络层处理,网络层继续给到传输层,才知道对应TCP报文中的端口没有打开,则自动生成RST重置包给到请求主机)

1
2
15:23:06.283001 IP 127.0.0.1.57389 > 127.0.0.1.2000: Flags [P.], seq 3503399:3503401, ack 2869623412, win 6379, options [nop,nop,TS val 1429607350 ecr 1429601142], length 2
15:23:06.283044 IP 127.0.0.1.2000 > 127.0.0.1.57389: Flags [R], seq 2869623412, win 0, length 0

2.3. TCP连接状态

2.3.1. 客户端-服务端连接确定

连接建立后,Client和Server都处于ESTABLISHED状态,另外Server端还有2000端口处理监听状态LISTEN

1
2
3
4
$ netstat -nap tcp|grep 2000
tcp4       0      0  127.0.0.1.2000         127.0.0.1.57054        ESTABLISHED
tcp4       0      0  127.0.0.1.57054        127.0.0.1.2000         ESTABLISHED
tcp46      0      0  *.2000                 *.*                    LISTEN

2.3.2. 服务端异常退出

查看状态,会发现:

  1. 客户端(127.0.0.1.57054=>127.0.0.1.2000)一直处于CLOSE_WAIT状态(被动断开一方)
  2. 服务端(127.0.0.1.2000=>127.0.0.1.57054)一直处理FIN_WAIT_2状态(主动断开一方)
1
2
3
$ netstat -nap tcp|grep 2000
tcp4       0      0  127.0.0.1.2000         127.0.0.1.57054        FIN_WAIT_2
tcp4       0      0  127.0.0.1.57054        127.0.0.1.2000         CLOSE_WAIT

2.3.3. 若TCP半关闭状态一致持续,将持续多久?

1
2
3
4
5
6
$ sysctl net.inet.tcp | grep keep
net.inet.tcp.keepidle: 7200000  // 单位为ms,因此是2小时
net.inet.tcp.keepintvl: 75000   // 75s
net.inet.tcp.keepinit: 75000    // 75s
net.inet.tcp.keepcnt: 8
net.inet.tcp.always_keepalive: 0
  • net.inet.tcp.keepidle : 发送keepalive探测(如果已启用)之前(TCP)连接必须处于空闲状态的时间(以毫秒为单位)。
  • net.inet.tcp.keepintvl : 发送到远程计算机的keepalive探测之间的间隔(以毫秒为单位),发送TCPTV_KEEPCNT(默认为8)探测器后,如果没有响应,则删除(TCP)连接。(在GO中被设置成了15s)
  • net.inet.tcp.always_keepalive: 假设在所有TCP连接上设置了SO_KEEPALIVE ,内核将定期向远程主机发送数据包以验证连接是否仍在运行。(GO的net.Dialer已开启)

这些参数都可以被

keepalive参数参考:1

2.4. TCP的状态机图示

更多TCP连接可以参见 2

相关说明:

  • 粗的实线箭头表示正常的客户端状态变迁,用粗的虚线箭头表示正常的服务器状态变迁。
  • 两个导致进入ESTABLISHED状态的变迁对应打开一个连接,而两个导致从ESTABLISHED状态离开的变迁主动打开对应关闭一个连接,ESTABLISHED状态是连接双方能够进行双向数据传递的状态。
  • 左下角4个状态放在一个虚线框内,并标为“主动关闭”
  • 右下两个状态(CLOSE_WAIT和LAST_ACK)也用虚线框住,并标为“被动关闭”
  • CLOSED状态不是一个真正的状态,而是这个状态图的假想起点和终点,11个状态的名称(CLOSED,LISTEN,SYN_SENT等)是有意与netstat命令显示的状态名称一致。
  • 从LISTEN到SYN_SENT的变迁是正确的,但伯克利版的TCP软件并不支持它。
  • 当SYN_RCVD状态是从LISTEN状态(正常情况),而不是从SYN_SENT状态(同时打开)进入时,从SYN_RCVD回到LISTEN的状态变迁才是有效的。这意味着如果我们执行被动关闭(进入LISTEN),收到一个SYN,发送一个带ACK的SYN(进入SYN_RCVD),然后收到一个RST,而不是一个ACK,便又回到LISTEN状态并等待另一个连接请求的到来。

2.5. 结论

  1. 到此应该知道为何会通过netstat查看到那两个状态一直持续了(服务端主动断开后,只关闭了一半连接,另一半连接没有关闭);
  2. 网络服务进程异常关闭引起的TCP连接断开(conn.Close()),需要在异常情况下,也需要完整地完成TCP连接的断开,否则容易导致TCP连接资源异常,不会被释放;
  3. 主Groutine在处理过程中,中断的消息没有广播给到当前还在处理的子Goroutine,仅仅做了主Goroutine的退出,没有考虑到子Goroutine的连接情况,可以通过将消息广播给连接池中所有的子Goroutine将其Accept接收到的连接关闭(主Goroutine退出之前,必须先做好善后工作)

进一步阅读学习

  1. TCP连接状态RFC793([Page 22]): https://www.ietf.org/rfc/rfc793.txt