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 進行讀與寫的操作時,實質上經過了一層 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)
,抓包結果又會是如何?
最後,讀者朋友們在項目中,有使用過強制關閉方式嗎?歡迎留言交流。
機器鈴砍菜刀
加入 Golang 分享羣學習交流!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Lbv4myGcvH6fIfNoQfyqQA