以下文章來源于非常程序員 ,作者yoko
編寫健壯且高性能的網絡服務需要付出大量的努力。提高服務性能的方式有很多種,比如優化應用層的代碼,更進一步,還可以看看垃圾回收器,操作系統,網絡傳輸,以及部署我們服務的硬件是否有優化空間。
TCP/IP 協議棧中的一些算法會影響到服務性能。本文將簡單介紹其中的 Nagle 算法,與 Nagle 算法相關的 socket 選項TCP_NODELAY,以及在 Go 語言中如何使用它。
理論
大部分平臺上的 TCP 實現都提供了 socket 選項,用于控制連接生命周期,流量控制等算法。
其中一個會對網絡傳輸性能造成影響的算法是 Nagle 算法,它在 linux,macOS,windows 平臺默認都是打開的。
Nagle 算法的做法是:將要發送的小包合并,并延緩發送。延緩后的發送策略是,收到前一個發送出去的包的 ACK 確認包,或者一定時間后,收集了足夠數量的小數據包。
Nagle 算法的目的是減少發送小包的數量,從而減小帶寬,并提高網絡吞吐量,付出的代價是有時會增加服務的延時。(譯者 yoko 注:補充解釋一下為什么減少小包的數量可以減小帶寬。因為每個 TCP 包,除了包體中包含的應用層數據外,外層還要套上 TCP 包頭和 IP 包頭。由于應用層要發送的業務數據量是固定的,所以包數量越多,包頭占用的帶寬也越多)
引入的延時通常在毫秒級別,但是對于延遲敏感的服務來說,減少一些毫秒數的延遲也是值得的。
Nagle 算法所對應的 TCP socket 選項是TCP_NODELAY。開啟TCP_NODELAY可以禁用 Nagle 算法。禁用 Nagle 算法后,數據將盡可能快的被發送出去。
另外,我們也可以在應用層對數據進行緩存合并發送來達到 Nagle 算法的目的(譯者 yoko 注:在 Go 語言中即使用bufio.Writer。個人認為,使用bufio.Writer還有一個好處,就是減少了調用 write 系統調用的次數,但是相應的,增加了數據拷貝的開銷)。
在 Go 語言中,TCP_NODELAY默認是開啟的,并且標準庫提供了net.SetNodelay(bool)方法來控制它。
實驗
我們通過一個小實驗來觀察TCP_NODELAY打開和關閉時底層 TCP 包的變化。
代碼邏輯十分簡單,client 端連續調用 5 次conn.Write函數向 server 端發送相同的字符串GOPHER。
服務端代碼(server.go):
package mainimport ( "bufio" "fmt" "log" "net" "strings")func main() { port := ":" + "8000" // 創建監聽 l, err := net.Listen("tcp", port) if err != nil { log.Fatal(err) } defer l.Close() for { // 接收新的連接 c, err := l.Accept() if err != nil { log.Println(err) return } // 處理新的連接 go handleConnection(c) }}func handleConnection(c net.Conn) { fmt.Printf("Serving %sn", c.RemoteAddr().String()) for { // 讀取數據 netData, err := bufio.NewReader(c).ReadString('n') if err != nil { log.Println(err) return } cdata := strings.TrimSpace(netData) if cdata == "GOPHER" { c.Write([]byte("GopherAcademy Advent 2019!")) } if cdata == "EXIT" { break } } c.Close()}
客戶端代碼(client.go):
package mainimport ( "fmt" "log" "net")func main() { target := "localhost:8000" raddr, err := net.ResolveTCPAddr("tcp", target) if err != nil { log.Fatal(err) } // 和服務端建立連接 conn, err := net.DialTCP("tcp", nil, raddr) if err != nil { log.Fatal(err) } // conn.SetNoDelay(false) // 如果打開這行代碼,則禁用TCP_NODELAY,打開Nagle算法 fmt.Println("Sending Gophers down the pipe...") for i := 0; i < 5; i++ { // 發送數據 _, err = conn.Write([]byte("GOPHERn")) if err != nil { log.Fatal(err) } }}
為了觀察 TCP 包,首先開啟抓包程序 tcpdump。為了簡單,兩個程序都在本機運行。我環境的內網環路網卡為lo0,不同機器上可能不同:
$sudo tcpdump -X -i lo0 'port 8000'
然后,再打開兩個終端窗口,先執行服務端程序,再執行客戶端程序:
$go run server.go
$go run client.go
觀察抓包結果,我們會發現每次調用 write 函數發送"GOPHER",對應的都是一個獨立的 TCP 包被發送??偣灿?5 個 TCP 包。以下是抓包結果,為了簡單,我只貼出兩個包:
....14:03:11.057782 IP localhost.58030 > localhost.irdmi: Flags [P.], seq 15:22, ack 1, win 6379, options [nop,nop,TS val 744132314 ecr 744132314], length 7 0x0000: 4500 003b 0000 4000 4006 0000 7f00 0001 E..;..@.@....... 0x0010: 7f00 0001 e2ae 1f40 80c5 9759 6171 9822 [email protected]." 0x0020: 8018 18eb fe2f 0000 0101 080a 2c5a 8eda ...../......,Z.. 0x0030: 2c5a 8eda 474f 5048 4552 0a ,Z..GOPHER.14:03:11.057787 IP localhost.58030 > localhost.irdmi: Flags [P.], seq 22:29, ack 1, win 6379, options [nop,nop,TS val 744132314 ecr 744132314], length 7 0x0000: 4500 003b 0000 4000 4006 0000 7f00 0001 E..;..@.@....... 0x0010: 7f00 0001 e2ae 1f40 80c5 9760 6171 9822 .......@...`aq." 0x0020: 8018 18eb fe2f 0000 0101 080a 2c5a 8eda ...../......,Z.. 0x0030: 2c5a 8eda 474f 5048 4552 0a ,Z..GOPHER....
如果我們打開客戶端中被注釋掉的conn.SetNoDelay(false)這行代碼,也即禁用掉TCP_NODELAY,開啟 Nagle 算法,再次抓包,結果如下:
14:27:20.120673 IP localhost.64086 > localhost.irdmi: Flags [P.], seq 8:36, ack 1, win 6379, options [nop,nop,TS val 745574362 ecr 745574362], length 28 0x0000: 4500 0050 0000 4000 4006 0000 7f00 0001 E..P..@.@....... 0x0010: 7f00 0001 fa56 1f40 07c9 d46f a115 3444 [email protected] 0x0020: 8018 18eb fe44 0000 0101 080a 2c70 8fda .....D......,p.. 0x0030: 2c70 8fda 474f 5048 4552 0a47 4f50 4845 ,p..GOPHER.GOPHE 0x0040: 520a 474f 5048 4552 0a47 4f50 4845 520a R.GOPHER.GOPHER.
可以看到,有四個"GOPHER"被合并到了一個 TCP 包中。
結論
TCP_NODELAY并不是萬能的,有好處有壞處,需要根據實際業務場景決定打開還是關閉。但是,在使用具體語言編寫網絡服務時,我們需要知道它是否被默認開啟。
還有其他一些類似的 socket 選項,比如TCP_QUICKACK和TCP_CORK等。但是由于有些 socket 選項是平臺相關的,因此 Go 沒有提供和TCP_NODELAY相同的方式來控制這些 socket 選項。我們可以通過一些平臺相關的包來實現這一點。比如說,在類 unix 系統下,我們可以使用golang.org/x/sys/unix包中的SetsockoptInt方法。
舉例:
err = unix.SetsockoptInt(fd, unix.IPPROTO_TCP, unix.TCP_QUICKACK, 1)if err != nil { return os.NewSyscallError("setsockopt", err)}
最后,如果你想學習更多和 Nagle 算法相關的知識,可以看看這篇英文博客[1]
英文原文鏈接:Control packet flow with TCP_NODELAY in Go[2]
參考資料
[1]
英文博客: https://www.extrahop.com/company/blog/2016/tcp-nodelay-nagle-quickack-best-practices/
[2]
Control packet flow with TCP_NODELAY in Go: https://blog.gopheracademy.com/advent-2019/control-packetflow-tcp-nodelay/