編寫健壯且高性能的網絡服務需要付出大量的努力。提高服務性能的方式有很多種,比如優化應用層的代碼,更進一步,還可以看看垃圾回收器,操作系統,網絡傳輸,以及部署我們服務的硬件是否有優化空間。
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 main
import (
"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 %s\n", 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 main
import (
"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("GOPHER\n"))
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 .......@...Yaq."
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 .....V.@...o..4D
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/
推薦閱讀
架構系列:高并發架構的CDN知識介紹
Go語言中如何開啟 TCP keepalive?
喜歡本文的朋友,歡迎關注“Go語言中文網”:
Go語言中文網啟用微信學習交流群,歡迎加微信:274768166