Go語言中如何開啟 TCP keepalive?

本篇文章首先簡單介紹了 TCP keepalive 的機制以及運用場景。接著介紹了 Go 語言中如何開啟與設置 TCP keepalive。但是由於 Go 語言最上層的接口不夠靈活,從而引出在 Go 語言中如何使用系統調用設置 TCP 連接的文件描述符屬性。接著原作者就掉坑裡了。。。最後介紹了在Go 1.11之後的版本如何使用新的接口設置 TCP 連接的文件描述符屬性。為了更適合中文閱讀,我對文章做了些增刪,並沒有逐字翻譯。原文地址:Notes on TCP keepalive in Go | TheNotExpert

[1]

我有一個供客戶端連接的 TCP 服務端程序。它十分簡單。但問題是,所有的客戶端都使用手機移動網絡並且網絡總是不穩定。經常丟失連接卻沒有通過FIN或者RST包通知服務端。服務端保持著這個虛連接並且認為這個客戶端仍然在線,而事實上卻不是。

我的首個解決方案是等待一小會;如果某個客戶端在給定的時間端沒有發送任何數據,則在服務端關閉這個連接(值得一提,SetDeadline[2]方法十分好用,當超時時它在conn.Read上返回i/o超時錯誤)。但是以下情況需要考慮:我不能把超時設置得過小,因為客戶端生成數據的速度可能很慢,而且也不能把超時設置得過大,因為這會使我誤判客戶端的在線狀態,而事實上我需要一定的精度。

我的想法是 ping 客戶端。但是我不想給客戶端發送它不需要的垃圾數據。而且,客戶端的代碼也不由我說了算,所以我也不確定如果我發送一些奇怪的數據給客戶端,客戶端會如何表現。

TCP-keepalive — 一個輕量級的 ping

TCP keepalive發送沒有(或者幾乎沒有)包體負載的 TCP 報文給對端,並且對端會回覆 keepalive ACK確認包。它不是 TCP 標準的一部分(儘管在RFC1122

[3]中有相關的描述),並且,它總是默認被禁用。儘管如此,大部分現代的 TCP 協議棧都支持這個特性。

在它的大部分實現中,簡單來說,有三個主要參數:

  • Idle time(空閒時間) - 接收一個包後,等待多長時間發出一個 ping 包。
  • Retry interval(重試間隔時間) - 如果發送了一個 ping,但是沒有收到對端回覆的ACK,在重試間隔時間之後重新發送 ping。
  • Ping amount(重試次數) - 重試次數(沒有收到對端ACK)達到多少次後,我們認為這個連接不存活了。

舉個例子,空閒時間是 30 秒,重試間隔時間是 5 秒,重試次數為 3。以下是它的工作方式:

服務端收到客戶端的一包應用層數據。然後客戶端不再發送任何數據。服務端等待 30 秒。然後發送一個 ping 給客戶端。如果服務端收到了ACK,則服務端等待另一個 30 秒,再次發送 ping;如果在這 30 秒內服務端收到了數據,則 30 秒的定時器被重置。

如果服務端沒有收到ACK,等待 5 秒後再次發送 ping。如果再過 5 秒還是沒有收到回覆?發送最後一個 ping 並等待最後一個 5 秒(是的,在最後一個 ping 也需要等待重試間隔時間)。然後我們認為這個連接超時了並且在服務端斷開它。

默認值

據說 Window 系統在發送 keepalive ping 之前默認等待 2 小時。Linux 下獲取默認值十分簡單,就像此處 3.1.1 節[4]描述的這樣。

# Idle time
cat /proc/sys/net/ipv4/tcp_keepalive_time
# Retry interval
cat /proc/sys/net/ipv4/tcp_keepalive_intvl
# Ping amount
cat /proc/sys/net/ipv4/tcp_keepalive_probes

在 Go 語言中如何設置?

由於我最近使用 Go 語言比較多,我需要在 Go 語言中運用 TCP keepalive。

討論開始之前需要說明,以下內容適用於 Linux。我不是百分百確定它是否適用於 OSX,但我幾乎可以肯定它不適用於 Windows。

連接的特殊類型

首先,我注意到我在服務端程序中只使用了net.Conn

[5]類型。但是它並不管用,它缺少我們需要的特定方法。我們需要TCPConn[6]類型。

這意味著,我們需要使用ListenTCP[7]和AcceptTCP[8]而不是Listen[9]和Accept[10](它們的調用方式有區別,ListenTCP使用結構體而不是字符串來表示地址。我們調用方式大概會像這樣:ListenTCP("tcp", &net.TCPAddr{Port: myClientPort})。如果你不特別指定的話,IP 的默認值為0.0.0.0)。之後它會返回我們需要的類型TCPConn。

Go 語言提供的方法

如果你翻看文檔可能會注意到這兩個相關的方法:SetKeepAlive[11]和SetKeepAlivePeriod[12]。func (c *TCPConn) SetKeepAlive(keepalive bool) error的調用方式十分簡單:傳入true從而打開 TCP keepalive 機制。

但是接下來的func (c *TCPConn) SetKeepAlivePeriod(d time.Duration) error就有些令人困惑了。我們用它究竟設置的是什麼?答案可以在這篇文章[13](好文章,推薦閱讀)中找到:它同時設置了空閒時間

重試間隔時間。而重試間隔次數則使用系統的默認值。所以如果我設置5 * time.Second。那麼它可能是等待 5 秒鐘,發送 ping 並等待另一個 5 秒。並且 8 次重試(取決於系統設置)。而我需要更大的靈活性,設置得更精準。

進入系統層面

可以通過直接操作 socket 參數來實現。我沒有關注裡面太多的細節,這純粹是我的個人解釋。以下是我們如何設置空閒時間為 30 秒(我們可以通過SetKeepAlivePeriod設置,因為其他參數我們再另外設置),重試時間間隔設置為 5 秒,重試次數設置為 3。我偷了(啊呸,是參考了)上面所引用的文章中的一些代碼,多謝。

conn.SetKeepAlive(true)
conn.SetKeepAlivePeriod(time.Second * 30)
// Getting the file handle of the socket
sockFile, sockErr := conn.File()
if sockErr == nil {
// got socket file handle. Getting descriptor.
fd := int(sockFile.Fd())
// Ping amount
err := syscall.SetsockoptInt(fd, syscall.IPPROTO_TCP, syscall.TCP_KEEPCNT, 3)
if err != nil {
Warning("on setting keepalive probe count", err.Error())
}
// Retry interval
err = syscall.SetsockoptInt(fd, syscall.IPPROTO_TCP, syscall.TCP_KEEPINTVL, 5)
if err != nil {
Warning("on setting keepalive retry interval", err.Error())

}
// don't forget to close the file. No worries, it will *not* cause the connection to close.
sockFile.Close()
} else {
Warning("on setting socket keepalive", sockErr.Error())
}

在這段代碼之後的某一行我會寫上dataLength, err := conn.Read(readBuf),這行代碼會阻塞直到收到數據或者發生錯誤。如果是 keepalive 引起的錯誤,err.Error()將會包含連接超時信息。

關於文件描述符的坑

上面的代碼只有在你不頻繁調用的前提下才運行良好。在寫完這篇文章之後,我以困難模式學習到了一個關於它的小問題。。。

問題就隱藏在Fd[14]函數調用。我們來看它的實現。

func (f *File) Fd() uintptr {
if f == nil {
return ^(uintptr(0))
}
// If we put the file descriptor into nonblocking mode,
// then set it to blocking mode before we return it,
// because historically we have always returned a descriptor
// opened in blocking mode. The File will continue to work,
// but any blocking operation will tie up a thread.
if f.nonblock {
f.pfd.SetBlocking()
}
return uintptr(f.pfd.Sysfd)
}

如果文件描述符處於非阻塞模式,會將它修改為阻塞模式。根據stackoverflow 的這個回答

[15],舉例來說,當 Go 增加一個阻塞的系統調用,運行時調度器將該系統調用所屬協程所屬系統線程從調度池中移出。如果調度池中的系統線程數小於GOMAXPROCS,則會創建新的系統線程。鑑於我的每一個連接都使用一個獨立協程,你可以想象一下這個爆炸速度。將很快到達 10000 線程的限制然後 panic。

將它放入獨立協程並不好使。

譯者yoko注,個人理解此處可做兩層解釋,如果是像原作者所描述的,每個連接都獨佔一個協程(直到連接關閉再退出協程),先使用系統調用設置文件描述符屬性,再收發數據,那麼系統線程會隨連接數線性增長。如果是在連接收發數據的協程之前,先弄一個協程處理完文件描述符屬性的設置,那麼系統調用完成後臨時協程結束,線程還是會回收的。但也畢竟不是一種好的模式。

但是有一個方法是可行的。注意,前提是 Go 版本高於 1.11。看以下代碼。

//Sets additional keepalive parameters.
//Uses new interfaces introduced in Go1.11, which let us get connection's file descriptor,
//without blocking, and therefore without uncontrolled spawning of threads (not goroutines, actual threads).
func setKeepaliveParameters(conn devconn) {

rawConn, err := conn.SyscallConn()
if err != nil {
Warning("on getting raw connection object for keepalive parameter setting", err.Error())
}
rawConn.Control(
func(fdPtr uintptr) {
// got socket file descriptor. Setting parameters.
fd := int(fdPtr)
//Number of probes.
err := syscall.SetsockoptInt(fd, syscall.IPPROTO_TCP, syscall.TCP_KEEPCNT, 3)
if err != nil {
Warning("on setting keepalive probe count", err.Error())
}
//Wait time after an unsuccessful probe.
err = syscall.SetsockoptInt(fd, syscall.IPPROTO_TCP, syscall.TCP_KEEPINTVL, 3)
if err != nil {
Warning("on setting keepalive retry interval", err.Error())
}
})
}
func deviceProcessor(conn devconn) {
//............
conn.SetKeepAlive(true)
conn.SetKeepAlivePeriod(time.Second * 30)
setKeepaliveParameters(conn)
//............
dataLen, err := conn.Read(readBuf)
//............
}

最新版本的 Go 提供了一些新接口,net.TCPConn實現了SyscallConn[16],它使得你可以獲取RawConn[17]對象從而設置參數。你所需要做的就是定義一個函數(就像上面例子中的匿名函數),它接收一個指向文件描述符的參數。這是操作連接中的文件描述符而不造成阻塞調用的方法,可避免出現瘋狂創建線程的情況。

總結

網絡編程是複雜的。並且時常是系統相關的。這個解決方法只在 Linux 下有用,但是這是一個好的開始。在其他操作系統中有類似的參數,它們只是調用方式不同。

本文原始地址:https://pengrl.com/p/62417/

文中鏈接

[1]

Notes on TCP keepalive in Go | TheNotExpert: https://thenotexpert.com/golang-tcp-keepalive/

[2]

SetDeadline: https://golang.org/pkg/net/#TCPConn.SetDeadline

[3]

RFC1122: https://tools.ietf.org/html/rfc1122#page-101

[4]

此處3.1.1節: http://tldp.org/HOWTO/TCP-Keepalive-HOWTO/usingkeepalive.html

[5]

net.Conn: https://golang.org/pkg/net/#Conn

[6]

TCPConn: https://golang.org/pkg/net/#TCPConn

[7]

ListenTCP: https://golang.org/pkg/net/#ListenTCP

[8]

AcceptTCP: https://golang.org/pkg/net/#TCPListener.AcceptTCP

[9]

Listen: https://golang.org/pkg/net/#Listen

[10]

Accept: https://golang.org/pkg/net/#TCPListener.Accept

[11]

SetKeepAlive: https://golang.org/pkg/net/#TCPConn.SetKeepAlive

[12]

SetKeepAlivePeriod: https://golang.org/pkg/net/#TCPConn.SetKeepAlivePeriod

[13]

這篇文章: https://felixge.de/2014/08/26/tcp-keepalive-with-golang.html

[14]

Fd: https://golang.org/pkg/os/#File.Fd

[15]

stackoverflow的這個回答: https://stackoverflow.com/a/27603427/2052138

[16]

SyscallConn: https://golang.org/pkg/syscall/#Conn

[17]

RawConn: https://golang.org/pkg/syscall/#RawConn

"


分享到:


相關文章: