本篇文章首先簡單介紹了 TCP keepalive 的機(jī)制以及運(yùn)用場景。接著介紹了 Go 語言中如何開啟與設(shè)置 TCP keepalive。但是由于 Go 語言最上層的接口不夠靈活,從而引出在 Go 語言中如何使用系統(tǒng)調(diào)用設(shè)置 TCP 連接的文件描述符屬性。接著原作者就掉坑里了。。。最后介紹了在Go 1.11之后的版本如何使用新的接口設(shè)置 TCP 連接的文件描述符屬性。為了更適合中文閱讀,我對文章做了些增刪,并沒有逐字翻譯。原文地址:Notes on TCP keepalive in Go | TheNotExpert[1]。
我有一個(gè)供客戶端連接的 TCP 服務(wù)端程序。它十分簡單。但問題是,所有的客戶端都使用手機(jī)移動網(wǎng)絡(luò)并且網(wǎng)絡(luò)總是不穩(wěn)定。經(jīng)常丟失連接卻沒有通過FIN或者RST包通知服務(wù)端。服務(wù)端保持著這個(gè)虛連接并且認(rèn)為這個(gè)客戶端仍然在線,而事實(shí)上卻不是。
我的首個(gè)解決方案是等待一小會;如果某個(gè)客戶端在給定的時(shí)間端沒有發(fā)送任何數(shù)據(jù),則在服務(wù)端關(guān)閉這個(gè)連接(值得一提,SetDeadline[2]方法十分好用,當(dāng)超時(shí)時(shí)它在conn.Read上返回i/o超時(shí)錯(cuò)誤)。但是以下情況需要考慮:我不能把超時(shí)設(shè)置得過小,因?yàn)榭蛻舳松蓴?shù)據(jù)的速度可能很慢,而且也不能把超時(shí)設(shè)置得過大,因?yàn)檫@會使我誤判客戶端的在線狀態(tài),而事實(shí)上我需要一定的精度。
我的想法是 ping 客戶端。但是我不想給客戶端發(fā)送它不需要的垃圾數(shù)據(jù)。而且,客戶端的代碼也不由我說了算,所以我也不確定如果我發(fā)送一些奇怪的數(shù)據(jù)給客戶端,客戶端會如何表現(xiàn)。
TCP-keepalive — 一個(gè)輕量級的 ping
TCP keepalive發(fā)送沒有(或者幾乎沒有)包體負(fù)載的 TCP 報(bào)文給對端,并且對端會回復(fù) keepalive ACK確認(rèn)包。它不是 TCP 標(biāo)準(zhǔn)的一部分(盡管在RFC1122[3]中有相關(guān)的描述),并且,它總是默認(rèn)被禁用。盡管如此,大部分現(xiàn)代的 TCP 協(xié)議棧都支持這個(gè)特性。
在它的大部分實(shí)現(xiàn)中,簡單來說,有三個(gè)主要參數(shù):
- Idle time(空閑時(shí)間) - 接收一個(gè)包后,等待多長時(shí)間發(fā)出一個(gè) ping 包。
- Retry interval(重試間隔時(shí)間) - 如果發(fā)送了一個(gè) ping,但是沒有收到對端回復(fù)的ACK,在重試間隔時(shí)間之后重新發(fā)送 ping。
- Ping amount(重試次數(shù)) - 重試次數(shù)(沒有收到對端ACK)達(dá)到多少次后,我們認(rèn)為這個(gè)連接不存活了。
舉個(gè)例子,空閑時(shí)間是 30 秒,重試間隔時(shí)間是 5 秒,重試次數(shù)為 3。以下是它的工作方式:
服務(wù)端收到客戶端的一包應(yīng)用層數(shù)據(jù)。然后客戶端不再發(fā)送任何數(shù)據(jù)。服務(wù)端等待 30 秒。然后發(fā)送一個(gè) ping 給客戶端。如果服務(wù)端收到了ACK,則服務(wù)端等待另一個(gè) 30 秒,再次發(fā)送 ping;如果在這 30 秒內(nèi)服務(wù)端收到了數(shù)據(jù),則 30 秒的定時(shí)器被重置。
如果服務(wù)端沒有收到ACK,等待 5 秒后再次發(fā)送 ping。如果再過 5 秒還是沒有收到回復(fù)?發(fā)送最后一個(gè) ping 并等待最后一個(gè) 5 秒(是的,在最后一個(gè) ping 也需要等待重試間隔時(shí)間)。然后我們認(rèn)為這個(gè)連接超時(shí)了并且在服務(wù)端斷開它。
默認(rèn)值
據(jù)說 Window 系統(tǒng)在發(fā)送 keepalive ping 之前默認(rèn)等待 2 小時(shí)。linux 下獲取默認(rèn)值十分簡單,就像此處 3.1.1 節(jié)[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 語言中如何設(shè)置?
由于我最近使用 Go 語言比較多,我需要在 Go 語言中運(yùn)用 TCP keepalive。
討論開始之前需要說明,以下內(nèi)容適用于 Linux。我不是百分百確定它是否適用于 OSX,但我?guī)缀蹩梢钥隙ㄋ贿m用于 windows。
連接的特殊類型
首先,我注意到我在服務(wù)端程序中只使用了net.Conn[5]類型。但是它并不管用,它缺少我們需要的特定方法。我們需要TCPConn[6]類型。
這意味著,我們需要使用ListenTCP[7]和AcceptTCP[8]而不是Listen[9]和Accept[10](它們的調(diào)用方式有區(qū)別,ListenTCP使用結(jié)構(gòu)體而不是字符串來表示地址。我們調(diào)用方式大概會像這樣:ListenTCP("tcp", &net.TCPAddr{Port: myClientPort})。如果你不特別指定的話,IP 的默認(rèn)值為0.0.0.0)。之后它會返回我們需要的類型TCPConn。
Go 語言提供的方法
如果你翻看文檔可能會注意到這兩個(gè)相關(guān)的方法:SetKeepAlive[11]和SetKeepAlivePeriod[12]。func (c *TCPConn) SetKeepAlive(keepalive bool) error的調(diào)用方式十分簡單:傳入true從而打開 TCP keepalive 機(jī)制。
但是接下來的func (c *TCPConn) SetKeepAlivePeriod(d time.Duration) error就有些令人困惑了。我們用它究竟設(shè)置的是什么?答案可以在這篇文章[13](好文章,推薦閱讀)中找到:它同時(shí)設(shè)置了空閑時(shí)間和重試間隔時(shí)間。而重試間隔次數(shù)則使用系統(tǒng)的默認(rèn)值。所以如果我設(shè)置5 * time.Second。那么它可能是等待 5 秒鐘,發(fā)送 ping 并等待另一個(gè) 5 秒。并且 8 次重試(取決于系統(tǒng)設(shè)置)。而我需要更大的靈活性,設(shè)置得更精準(zhǔn)。
進(jìn)入系統(tǒng)層面
可以通過直接操作 socket 參數(shù)來實(shí)現(xiàn)。我沒有關(guān)注里面太多的細(xì)節(jié),這純粹是我的個(gè)人解釋。以下是我們?nèi)绾卧O(shè)置空閑時(shí)間為 30 秒(我們可以通過SetKeepAlivePeriod設(shè)置,因?yàn)槠渌麉?shù)我們再另外設(shè)置),重試時(shí)間間隔設(shè)置為 5 秒,重試次數(shù)設(shè)置為 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),這行代碼會阻塞直到收到數(shù)據(jù)或者發(fā)生錯(cuò)誤。如果是 keepalive 引起的錯(cuò)誤,err.Error()將會包含連接超時(shí)信息。
關(guān)于文件描述符的坑
上面的代碼只有在你不頻繁調(diào)用的前提下才運(yùn)行良好。在寫完這篇文章之后,我以困難模式學(xué)習(xí)到了一個(gè)關(guān)于它的小問題。。。
問題就隱藏在Fd[14]函數(shù)調(diào)用。我們來看它的實(shí)現(xiàn)。
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)
}
如果文件描述符處于非阻塞模式,會將它修改為阻塞模式。根據(jù)stackoverflow 的這個(gè)回答[15],舉例來說,當(dāng) Go 增加一個(gè)阻塞的系統(tǒng)調(diào)用,運(yùn)行時(shí)調(diào)度器將該系統(tǒng)調(diào)用所屬協(xié)程的所屬系統(tǒng)線程從調(diào)度池中移出。如果調(diào)度池中的系統(tǒng)線程數(shù)小于GOMAXPROCS,則會創(chuàng)建新的系統(tǒng)線程。鑒于我的每一個(gè)連接都使用一個(gè)獨(dú)立協(xié)程,你可以想象一下這個(gè)爆炸速度。將很快到達(dá) 10000 線程的限制然后 panic。
將它放入獨(dú)立協(xié)程并不好使。
譯者yoko注,個(gè)人理解此處可做兩層解釋,如果是像原作者所描述的,每個(gè)連接都獨(dú)占一個(gè)協(xié)程(直到連接關(guān)閉再退出協(xié)程),先使用系統(tǒng)調(diào)用設(shè)置文件描述符屬性,再收發(fā)數(shù)據(jù),那么系統(tǒng)線程會隨連接數(shù)線性增長。如果是在連接收發(fā)數(shù)據(jù)的協(xié)程之前,先弄一個(gè)協(xié)程處理完文件描述符屬性的設(shè)置,那么系統(tǒng)調(diào)用完成后臨時(shí)協(xié)程結(jié)束,線程還是會回收的。但也畢竟不是一種好的模式。
但是有一個(gè)方法是可行的。注意,前提是 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實(shí)現(xiàn)了SyscallConn[16],它使得你可以獲取RawConn[17]對象從而設(shè)置參數(shù)。你所需要做的就是定義一個(gè)函數(shù)(就像上面例子中的匿名函數(shù)),它接收一個(gè)指向文件描述符的參數(shù)。這是操作連接中的文件描述符而不造成阻塞調(diào)用的方法,可避免出現(xiàn)瘋狂創(chuàng)建線程的情況。
總結(jié)
網(wǎng)絡(luò)編程是復(fù)雜的。并且時(shí)常是系統(tǒng)相關(guān)的。這個(gè)解決方法只在 Linux 下有用,但是這是一個(gè)好的開始。在其他操作系統(tǒng)中有類似的參數(shù),它們只是調(diào)用方式不同。
感謝閱讀。再見。
本文原始地址: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節(jié): 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的這個(gè)回答: https://stackoverflow.com/a/27603427/2052138
[16]
SyscallConn: https://golang.org/pkg/syscall/#Conn
[17]
RawConn: https://golang.org/pkg/syscall/#RawConn






