Go 中如何強制關閉 TCP 連接

《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,否則客戶端通過 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) ,抓包結果又會是如何?

最後,讀者朋友們在項目中,有使用過強制關閉方式嗎?歡迎留言交流。

機器鈴砍菜刀

加入 Golang 分享羣學習交流!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Lbv4myGcvH6fIfNoQfyqQA