Go 中如何强制关闭 TCP 连接

一颗小胡椒2022-07-20 11:04:49

默认关闭需要四次挥手的确认过程,这是一种”商量“的方式,而 TCP 为我们提供了另外一种”强制“的关闭模式。

如何强制性关闭?具体在 Go 代码中应当怎样实现?这就是本文探讨的内容。

默认关闭

相信每个程序员都知道 TCP 断开连接的四次挥手过程,这是面试八股文中的股中股。我们在 Go 代码中调用默认的 Conn.Close() 方法,它就是典型的四次挥手。

以客户端主动关闭连接为例,当它调用 Close 函数后,就会向服务端发送 FIN 报文,如果服务器的本端 socket 接收缓存区里已经没有数据,那服务端的 read 将会得到一个 EOF 错误。

发起关闭方会经历 FIN_WAIT_1 -> FIN_WAIT_2 -> TIME_WAIT -> CLOSE 的状态变化,这些状态需要得到被关闭方的反馈而更新。

强制关闭

默认的关闭方式,不管是客户端还是服务端主动发起关闭,都要经过对方的应答,才能最终实现真正的关闭连接。那能不能在发起关闭时,不关心对方是否同意,就结束掉连接呢?

答案是肯定的。TCP 协议为我们提供了一个 RST 的标志位,当连接的一方认为该连接异常时,可以通过发送 RST 包并立即关闭该连接,而不用等待被关闭方的 ACK 确认。

SetLinger() 方法

在 Go 中,我们可以通过 net.TCPConn.SetLinger() 方法来实现。

// SetLinger sets the behavior of Close on a connection which still
// has data waiting to be sent or to be acknowledged.
//
// If sec < 0 (the default), the operating system finishes sending the
// data in the background.
//
// If sec == 0, the operating system discards any unsent or
// unacknowledged data.
//
// If sec > 0, the data is sent in the background as with sec < 0. On
// some operating systems after sec seconds have elapsed any remaining
// unsent data may be discarded.
func (c *TCPConn) SetLinger(sec int) error {}

函数的注释已经非常清晰,但是需要读者有 socket 缓冲区的概念。

  • socket 缓冲区

当应用层代码通过 socket 进行读与写的操作时,实质上经过了一层 socket 缓冲区,它分为发送缓冲区和接受缓冲区。

缓冲区信息可通过执行 netstat -nt 命令查看

$ netstat -nt
Active Internet connections
Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4       0      0  127.0.0.1.57721        127.0.0.1.49448        ESTABLISHED

其中,Recv-Q 代表的就是接收缓冲区,Send-Q 代表的是发送缓冲区。

默认关闭方式中,即 sec < 0 。操作系统会将缓冲区里未处理完的数据都完成处理,再关闭掉连接。

sec > 0 时,操作系统会以与默认关闭方式运行。但是当超过定义的时间 sec 后,如果还没处理完缓存区的数据,在某些操作系统下,缓冲区中未完成的流量可能就会被丢弃。

sec == 0 时,操作系统会直接丢弃掉缓冲区里的流量数据,这就是强制性关闭。

示例代码与抓包分析

我们通过示例代码来学习 SetLinger() 的使用,并以此来分析强制关闭的区别。

服务端代码

以服务端为主动关闭连接方示例

package main
import (
 "log"
 "net"
 "time"
)
func main() {
 // Part 1: create a listener
 l, err := net.Listen("tcp", ":8000")
 if err != nil {
  log.Fatalf("Error listener returned: %s", err)
 }
 defer l.Close()
 for {
  // Part 2: accept new connection
  c, err := l.Accept()
  if err != nil {
   log.Fatalf("Error to accept new connection: %s", err)
  }
  // Part 3: create a goroutine that reads and write back data
  go func() {
   log.Printf("TCP session open")
   defer c.Close()
   for {
    d := make([]byte, 100)
    // Read from TCP buffer
    _, err := c.Read(d)
    if err != nil {
     log.Printf("Error reading TCP session: %s", err)
     break
    }
    log.Printf("reading data from client: %s\n", string(d))
    // write back data to TCP client
    _, err = c.Write(d)
    if err != nil {
     log.Printf("Error writing TCP session: %s", err)
     break
    }
   }
  }()
  // Part 4: create a goroutine that closes TCP session after 10 seconds
  go func() {
   // SetLinger(0) to force close the connection
   err := c.(*net.TCPConn).SetLinger(0)
   if err != nil {
    log.Printf("Error when setting linger: %s", err)
   }
   <-time.After(time.Duration(10) * time.Second)
   defer c.Close()
  }()
 }
}

服务端代码根据逻辑分为四个部分

第一部分:端口监听。我们通过 net.Listen("tcp", ":8000")开启在端口 8000 的 TCP 连接监听。

第二部分:建立连接。在开启监听成功之后,调用 net.Listener.Accept()方法等待 TCP 连接。Accept 方法将以阻塞式地等待新的连接到达,并将该连接作为 net.Conn 接口类型返回。

第三部分:数据传输。当连接建立成功后,我们将启动一个新的 goroutine 来处理 c 连接上的读取和写入。本文服务器的数据处理逻辑是,客户端写入该 TCP 连接的所有内容,服务器将原封不动地写回相同的内容。

第四部分:强制关闭连接逻辑。启动一个新的 goroutine,通过 c.(*net.TCPConn).SetLinger(0) 设置强制关闭选项,并于 10 s 后关闭连接。

客户端代码

以客户端为被动关闭连接方示例

package main
import (
 "log"
 "net"
)
func main() {
 // Part 1: open a TCP session to server
 c, err := net.Dial("tcp", "localhost:8000")
 if err != nil {
  log.Fatalf("Error to open TCP connection: %s", err)
 }
 defer c.Close()
 // Part2: write some data to server
 log.Printf("TCP session open")
 b := []byte("Hi, gopher?")
 _, err = c.Write(b)
 if err != nil {
  log.Fatalf("Error writing TCP session: %s", err)
 }
 // Part3: read any responses until get an error
 for {
  d := make([]byte, 100)
  _, err := c.Read(d)
  if err != nil {
   log.Fatalf("Error reading TCP session: %s", err)
  }
  log.Printf("reading data from server: %s\n", string(d))
 }
}

客户端代码根据逻辑分为三个部分

第一部分:建立连接。我们通过 net.Dial("tcp", "localhost:8000")连接一个 TCP 连接到服务器正在监听的同一个 localhost:8000 地址。

第二部分:写入数据。当连接建立成功后,通过 c.Write() 方法写入数据 Hi, gopher? 给服务器。

第三部分:读取数据。除非发生 error,否则客户端通过 c.Read() 方法(记住,是阻塞式的)循环读取 TCP 连接上的内容。

tcpdump 抓包结果

tcpdump 是一个非常好用的数据抓包工具,在《Go 网络编程和 TCP 抓包实操》一文中已经简单介绍了它的命令选项,这里就不再赘述。

  • 开启 tcpdump 数据包监听
tcpdump -S -nn -vvv -i lo0 port 8000
  • 运行服务端代码
$ go run main.go
2021/09/25 20:21:44 TCP session open
2021/09/25 20:21:44 reading data from client: Hi, gopher?
2021/09/25 20:21:54 Error reading TCP session: read tcp 127.0.0.1:8000->127.0.0.1:59394: use of closed network connection

服务器和客户端建立连接之后,从客户端读取到数据 Hi, gopher? 。在 10s 后,服务端强制关闭了 TCP 连接,阻塞在 c.Read 的服务端代码返回了错误: use of closed network connection

  • 运行客户端代码
$ go run main.go
2021/09/25 20:21:44 TCP session open
2021/09/25 20:21:44 reading data from server: Hi, gopher?
2021/09/25 20:21:54 Error reading TCP session: read tcp 127.0.0.1:59394->127.0.0.1:8000: read: connection reset by peer

客户端和服务器建立连接之后,发送数据给服务端,服务端返回相同的数据 Hi, gopher? 回来。在 10s 后,由于服务器强制关闭了 TCP 连接,因此阻塞在 c.Read 的客户端代码捕获到了错误:connection reset by peer

  • tcpdump 的抓包结果
$ tcpdump -S -nn -vvv -i lo0 port 8000
tcpdump: listening on lo0, link-type NULL (BSD loopback), capture size 262144 bytes
20:21:44.682942 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 64, bad cksum 0 (->3cb6)!)
    127.0.0.1.59394 > 127.0.0.1.8000: Flags [S], cksum 0xfe34 (incorrect -> 0xfa62), seq 3783365585, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 725769370 ecr 0,sackOK,eol], length 0
20:21:44.683042 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 64, bad cksum 0 (->3cb6)!)
    127.0.0.1.8000 > 127.0.0.1.59394: Flags [S.], cksum 0xfe34 (incorrect -> 0x23d3), seq 1050611715, ack 3783365586, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 725769370 ecr 725769370,sackOK,eol], length 0
20:21:44.683050 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.59394 > 127.0.0.1.8000: Flags [.], cksum 0xfe28 (incorrect -> 0x84dc), seq 3783365586, ack 1050611716, win 6379, options [nop,nop,TS val 725769370 ecr 725769370], length 0
20:21:44.683055 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.8000 > 127.0.0.1.59394: Flags [.], cksum 0xfe28 (incorrect -> 0x84dc), seq 1050611716, ack 3783365586, win 6379, options [nop,nop,TS val 725769370 ecr 725769370], length 0
20:21:44.683302 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 63, bad cksum 0 (->3cb7)!)
    127.0.0.1.59394 > 127.0.0.1.8000: Flags [P.], cksum 0xfe33 (incorrect -> 0x93f5), seq 3783365586:3783365597, ack 1050611716, win 6379, options [nop,nop,TS val 725769370 ecr 725769370], length 11
20:21:44.683311 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.8000 > 127.0.0.1.59394: Flags [.], cksum 0xfe28 (incorrect -> 0x84d1), seq 1050611716, ack 3783365597, win 6379, options [nop,nop,TS val 725769370 ecr 725769370], length 0
20:21:44.683499 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 152, bad cksum 0 (->3c5e)!)
    127.0.0.1.8000 > 127.0.0.1.59394: Flags [P.], cksum 0xfe8c (incorrect -> 0x9391), seq 1050611716:1050611816, ack 3783365597, win 6379, options [nop,nop,TS val 725769370 ecr 725769370], length 100
20:21:44.683511 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.59394 > 127.0.0.1.8000: Flags [.], cksum 0xfe28 (incorrect -> 0x846e), seq 3783365597, ack 1050611816, win 6378, options [nop,nop,TS val 725769370 ecr 725769370], length 0
20:21:54.688350 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 40, bad cksum 0 (->3cce)!)
    127.0.0.1.8000 > 127.0.0.1.59394: Flags [R.], cksum 0xfe1c (incorrect -> 0xcd39), seq 1050611816, ack 3783365597, win 6379, length 0

我们重点关注内容 Flags [],其中 [S] 代表 SYN 包,用于建立连接;[P] 代表 PSH 包,表示有数据传输;[R]代表 RST 包,用于重置连接;[.] 代表对应的 ACK 包。例如 [S.] 代表 SYN-ACK。

搞懂了这几个 Flags 的含义,那我们就可以分析出本次服务端强制关闭的 TCP 通信全过程。

可以看到,当通过设定 SetLinger(0) 之后,主动关闭方调用 Close() 时,系统内核会直接发送 RST 包给被动关闭方。这个过程并不需要被动关闭方的回复,就已关闭了连接。主动关闭方也就没有了默认关闭模式下 FIN_WAIT_1 -> FIN_WAIT_2 -> TIME_WAIT -> CLOSE 的状态改变。

总结

本文我们介绍了 TCP 默认关闭与强制关闭两种方式(其实还有种折中的方式:SetLinger(sec > 0)),它们都源于 TCP 的协议设计。

在大多数的场景中,我们都应该选择使用默认关闭方式,因为这样才能确保数据的完整性(不会丢失 socket 缓冲区里的数据)。

当使用默认方式关闭时,每个连接都会经历一系列的连接状态转变,让其在操作系统上停留一段时间。尤其是服务器要主动关闭连接时(大多数应用场景,都应该是由客户端主动发起关闭操作),这会消耗服务器的资源。

如果短时间内有大量的或者恶意的连接涌入,我们或许需要采用强制关闭方式。因为使用强制关闭,能立即关闭这些连接,释放资源,保证服务器的可用与性能。

当然,我们还可以选择折中的方式,容忍一段时间的缓存区数据处理时间,再进行关闭操作。

这里给读者朋友留一个思考题。如果在本文示例中,我们将 SetLinger(0) 改为 SetLinger(1) ,抓包结果又会是如何?

最后,读者朋友们在项目中,有使用过强制关闭方式吗?欢迎留言交流。

tcptcp四次挥手
本作品采用《CC 协议》,转载必须注明作者和本文链接
而UDP是一个面向无连接的传输层协议。当意识到丢包了或者网络环境不佳,TCP 会根据具体情况调整自己的行为,控制自己的发送速度或者重发。由此证明男方拥有爱的能力。SYN 需要对端的确认, 而 ACK 并不需要,因此 SYN 消耗一个序列号而 ACK 不需要。
人如其名,要对数据的传输进行一个详细的 控制。FIN:通知对方,本端要关闭了,我们称携带FIN标识的为结束报文段,该位为 1 时,表示今后不会再有数据发送,希望断开连接。
在发送 TCP 数据段时,由发送端计算校验和,当到达目的地时又进行一次检验和计算。表示紧急数据的末尾在 TCP 数据部分中的位置。如果不能及时收到一个确认,将重发这个报文段。流量控制TCP 连接的每一方都有固定大小的缓冲空间,TCP 的接收端只允许发送端发送接收端缓存区能接纳的数据。TCP 使用的流量控制协议是可变大小的滑动窗口协议。拥塞控制当网络拥塞时,减少数据的发送。
TCP 和UDP协议的区别以及原理最近重新认知了一下 TCP 和 UDP 的原理以及区别,做一个简单的总结。这包数据称之为SYN 包,如果对端同意连接,则回复一包 SYN+ACK 包,客户端收到之后,发送一包 ACK 包,连接建立,因为这个过程中互相发送了三包数据,所以称之为三次握手。也是为了保证在不可靠的网络链路中进行可靠的连接断开确认。
数据链路层在不可靠的物理介质上提供可靠的传输。与IP协议配套使用实现其功能的还有地址解析协议ARP、逆地址解析协议RARP、因特网报文协议ICMP、因特网组管理协议IGMP。ARP 是即插即用的,一个ARP表是自动建立的,不需要系统管理员来配置。
数据链路层在不可靠的物理介质上提供可靠的传输。以太网协议详解MAC地址:每一个设备都拥有唯一的MAC地址,共48位,使用十六进制表示。与IP协议配套使用实现其功能的还有地址解析协议ARP、逆地址解析协议RARP、因特网报文协议ICMP、因特网组管理协议IGMP。ARP 是即插即用的,一个ARP表是自动建立的,不需要系统管理员来配置。
数据链路层在不可靠的物理介质上提供可靠的传输。以太网协议详解MAC地址:每一个设备都拥有唯一的MAC地址,共48位,使用十六进制表示。与IP协议配套使用实现其功能的还有地址解析协议ARP、逆地址解析协议RARP、因特网报文协议ICMP、因特网组管理协议IGMP。ARP 是即插即用的,一个ARP表是自动建立的,不需要系统管理员来配置。
数据链路层在不可靠的物理介质上提供可靠的传输。以太网协议详解MAC地址:每一个设备都拥有唯一的MAC地址,共48位,使用十六进制表示。网络层网络层的目的是实现两个端系统之间的数据透明传送,具体功能包括寻址和路由选择、连接的建立、保持和终止等。
数据链路层在不可靠的物理介质上提供可靠的传输。与IP协议配套使用实现其功能的还有地址解析协议ARP、逆地址解析协议RARP、因特网报文协议ICMP、因特网组管理协议IGMP。ARP 是即插即用的,一个ARP表是自动建立的,不需要系统管理员来配置。
函数的注释已经非常清晰,但是需要读者有 socket 缓冲区的概念。socket 缓冲区当应用层代码通过 socket 进行读与写的操作时,实质上经过了一层 socket 缓冲区,它分为发送缓冲区和接受缓冲区。操作系统会将缓冲区里未处理完的数据都完成处理,再关闭掉连接。当 sec > 0 时,操作系统会以与默认关闭方式运行。服务端代码根据逻辑分为四个部分第一部分:端口监听。
一颗小胡椒
暂无描述