Go 中如何强制关闭 TCP 连接

363次阅读  |  发布于3年以前

在[《Go 网络编程和 TCP 抓包实操》] 一文中,我们编写了 Go 版本的 TCP 服务器与客户端代码,并通过 tcpdump 工具进行抓包获取分析。在该例中,客户端代码通过调用 Conn.Close() 方法发起了关闭 TCP 连接的请求,这是一种默认的关闭连接方式。

默认关闭需要四次挥手的确认过程,这是一种”商量“的方式,而 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 缓冲区,它分为发送缓冲区和接受缓冲区。

缓冲区信息可通过执行 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,否则客户端通过 <span style="font-size: 15px;">c.Read() 方法(记住,是阻塞式的)循环读取 TCP 连接上的内容。

tcpdump 抓包结果

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

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 -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 通信全过程。

我们和[《Go 网络编程和 TCP 抓包实操》] 一文中,客户端正常关闭的通信过程进行比较

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

总结

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

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

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

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

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

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

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

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8