Go 網絡編程和 TCP 抓包實操

作爲一名軟件開發者,網絡編程是必備知識。本文通過 Go 語言實現 TCP 套接字編程,並結合 tcpdump 工具,展示它的三次握手、數據傳輸以及四次揮手的過程,幫助讀者更好地理解 TCP 協議與 Go 網絡編程。

Go 網絡編程模型

在實現 Go 的 TCP 代碼前,我們先了解一下 Go 的網絡編程模型。

網絡編程屬於 IO 的範疇,其發展可以簡單概括爲:多進程 -> 多線程 -> non-block + I/O 多路複用。

想必讀者在初學 IO 模型時,一定對阻塞和非阻塞、同步和異步感到頭疼,而 I/O 多路複用的回調更是讓人抓狂。Go 在設計網絡模型時,就考慮到需要幫助開發者簡化開發複雜度,降低心智負擔,同時滿足高性能要求。

Go 語言的網絡編程模型是同步網絡編程。它基於 協程 + I/O 多路複用 (linux 下 epoll,darwin 下 kqueue,windows 下 iocp,通過網絡輪詢器 netpoller 進行封裝),結合網絡輪詢器與調度器實現。

用戶層 goroutine 中的 block socket,實際上是通過 netpoller 模擬出來的。runtime 攔截了底層 socket 系統調用的錯誤碼,並通過 netpoller 和 goroutine 調度讓 goroutine 阻塞在用戶層得到的 socket fd 上。

Go 將網絡編程的複雜性隱藏於 runtime 中:開發者不用關注 socket 是否是 non-block 的,也不用處理回調,只需在每個連接對應的 goroutine 中以 block I/O 的方式對待 socket 即可。

例如:當用戶層針對某個 socket fd 發起 read 操作時,如果該 socket fd 中尚無數據,那麼 runtime 會將該 socket fd 加入到 netpoller 中監聽,同時對應的 goroutine 被掛起,直到 runtime 收到 socket fd 數據 ready 的通知,runtime 纔會重新喚醒等待在該 socket fd 上準備 read 的那個 goroutine。而這個過程從 goroutine 的視角來看,就像是 read 操作一直 block 在那個 socket fd 上似的。

一句話總結:Go 將複雜的網絡模型進行封裝,放在用戶面前的只是阻塞式 I/O 的 goroutine,這讓我們可以非常輕鬆地實現高性能網絡編程。

TCP server

在 Go 中,網絡編程非常容易。我們通過 Go 的 net 包,可以輕鬆實現一個 TCP 服務器。

package main

import (
 "log"
 "net"
)

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
    }
   }
  }()
 }
}

根據邏輯,我們將以上代碼分成三個部分。

第一部分:端口監聽。我們通過 net.Listen("tcp", ":8000")開啓在端口 8000 的 TCP 連接監聽。

第二部分:建立連接。在開啓監聽成功之後,調用 net.Listener.Accept()方法等待 TCP 連接。Accept 方法將以阻塞式地等待新的連接到達,並將該連接作爲 net.Conn 接口類型返回。

第三部分:數據傳輸。當連接建立成功後,我們將啓動一個新的 goroutine 來處理 c 連接上的讀取和寫入。本文服務器的數據處理邏輯是,客戶端寫入該 TCP 連接的所有內容,服務器將原封不動地寫回相同的內容。

TCP client

同樣,通過 net 包也能快速實現一個 TCP 客戶端。

package main

import (
 "log"
 "net"
 "time"
)

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: create a goroutine that closes TCP session after 10 seconds
 go func() {
  <-time.After(time.Duration(10) * time.Second)
  defer c.Close()
 }()

 // Part4: 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? 給服務器。

第三部分:關閉連接。啓動一個新的 goroutine,在 10s 後調用 c.Close() 方法關閉 TCP 連接。

第四部分:讀取數據。除非發生 error,否則客戶端通過 c.Read() 方法(記住,是阻塞式的)循環讀取 TCP 連接上的內容。

抓包分析

tcpdump 是一個非常好用的數據抓包工具,它可以幫助我們捕獲和查看網絡數據包。

現在,我們通過 tcpdump 來抓取上文 TCP 客戶端與服務器通信全過程數據。

tcpdump -S -nn -vvv -i lo0 port 8000

在本例中,通過使用 -i lo0 指定捕獲環回接口 localhost,使用 port 8000 將網絡捕獲過濾爲僅與端口 8000 通信或來自端口 8000 的流量,-vvv是爲了打印更多的詳細描述信息,-S 顯示序列號絕對值。

當運行 tcpdump 後,我們分別啓動服務端和客戶端代碼。

運行服務端代碼

$ go run main.go
2021/09/20 19:41:17 TCP session open
2021/09/20 19:41:17 reading data from client: Hi, gopher?
2021/09/20 19:41:27 Error reading TCP session: EOF

服務器和客戶端建立連接之後,從客戶端讀取到數據 Hi, gopher? 。在 10s 後,由於客戶端關閉了連接,服務端讀取到了 EOF 錯誤。

運行客戶端代碼

$ go run main.go
2021/09/20 19:41:17 TCP session open
2021/09/20 19:41:17 reading data from server: Hi, gopher?
2021/09/20 19:41:27 Error reading TCP session: read tcp 127.0.0.1:57596->127.0.0.1:8000: use of closed network connection

客戶端和服務器建立連接之後,發送數據給服務端,服務端返回相同的數據 Hi, gopher? 回來。在 10s 後,客戶端通過一個新的 goroutine 主動關閉了連接,因此阻塞在 c.Read 的客戶端代碼捕獲到了錯誤:use of closed network connection

那我們通過 tcpdump 抓取的本次通信過程如何呢?首先,我們先通過一張圖片回顧一下經典的 TCP 通信全過程。

以下是 tcpdump 抓取的結果

$ tcpdump -S -nn -vvv -i lo0 port 8000
tcpdump: listening on lo0, link-type NULL (BSD loopback), capture size 262144 bytes
19:41:17.109462 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 64, bad cksum 0 (->3cb6)!)
    127.0.0.1.57596 > 127.0.0.1.8000: Flags [S], cksum 0xfe34 (incorrect -> 0x18e6), seq 2046827845, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 678438397 ecr 0,sackOK,eol], length 0
19:41:17.109547 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.57596: Flags [S.], cksum 0xfe34 (incorrect -> 0x8b10), seq 1697569320, ack 2046827846, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 678438397 ecr 678438397,sackOK,eol], length 0
19:41:17.109558 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.57596 > 127.0.0.1.8000: Flags [.], cksum 0xfe28 (incorrect -> 0xec19), seq 2046827846, ack 1697569321, win 6379, options [nop,nop,TS val 678438397 ecr 678438397], length 0
19:41:17.109567 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.57596: Flags [.], cksum 0xfe28 (incorrect -> 0xec19), seq 1697569321, ack 2046827846, win 6379, options [nop,nop,TS val 678438397 ecr 678438397], length 0
19:41:17.109767 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 63, bad cksum 0 (->3cb7)!)
    127.0.0.1.57596 > 127.0.0.1.8000: Flags [P.], cksum 0xfe33 (incorrect -> 0xfb32), seq 2046827846:2046827857, ack 1697569321, win 6379, options [nop,nop,TS val 678438397 ecr 678438397], length 11
19:41:17.109781 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.57596: Flags [.], cksum 0xfe28 (incorrect -> 0xec0e), seq 1697569321, ack 2046827857, win 6379, options [nop,nop,TS val 678438397 ecr 678438397], length 0
19:41:17.109862 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.57596: Flags [P.], cksum 0xfe8c (incorrect -> 0xface), seq 1697569321:1697569421, ack 2046827857, win 6379, options [nop,nop,TS val 678438397 ecr 678438397], length 100
19:41:17.109872 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.57596 > 127.0.0.1.8000: Flags [.], cksum 0xfe28 (incorrect -> 0xebab), seq 2046827857, ack 1697569421, win 6378, options [nop,nop,TS val 678438397 ecr 678438397], length 0
19:41:27.113831 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.57596 > 127.0.0.1.8000: Flags [F.], cksum 0xfe28 (incorrect -> 0xc49f), seq 2046827857, ack 1697569421, win 6378, options [nop,nop,TS val 678448392 ecr 678438397], length 0
19:41:27.113910 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.57596: Flags [.], cksum 0xfe28 (incorrect -> 0x9d93), seq 1697569421, ack 2046827858, win 6379, options [nop,nop,TS val 678448392 ecr 678448392], length 0
19:41:27.114089 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.57596: Flags [F.], cksum 0xfe28 (incorrect -> 0x9d92), seq 1697569421, ack 2046827858, win 6379, options [nop,nop,TS val 678448392 ecr 678448392], length 0
19:41:27.114187 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
    127.0.0.1.57596 > 127.0.0.1.8000: Flags [.], cksum 0xfe28 (incorrect -> 0x9d93), seq 2046827858, ack 1697569422, win 6378, options [nop,nop,TS val 678448392 ecr 678448392], length 0

我們重點關注內容 Flags [],其中 [S] 代表 SYN 包,[F] 代表 FIN,[.] 代表對應的 ACK 包。例如 [S.] 代表 SYN-ACK,[F.] 代表 FIN-ACK。可以很明顯看出 TCP 通信的全過程如下圖所示。

總結

本文簡單介紹了 Go 同步編程模式的網絡模型。有了 runtime 中網絡輪訓器與調度器的參與,使用 Go 進行高性能網絡編程,高手與菜鳥開發者的差距被極大地縮小。

Go 原生的 net 庫對 socket 編程進行了很好地封裝,它提供的函數方法語義明朗,邏輯清晰。基於同步編程模式,每個人都可以很容易地進行 TCP 網絡編程。利用 tcpdump 工具,我們能夠進行網絡分析和問題排查,建議實操掌握。

參考

https://tonybai.com/2015/11/17/tcp-programming-in-golang/

https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-netpoller/

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