在《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 進行讀與寫的操作時,實質上經過了一層 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 通信全過程。
我們和《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) ,抓包結果又會是如何?
最后,讀者朋友們在項目中,有使用過強制關閉方式嗎?
原文鏈接:https://mp.weixin.qq.com/s/Lbv4myGcvH6fIfNoQfyqQA