[譯] Go語言使用TCP keepalive

歡迎訪問我的個人網(wǎng)站獲取更佳閱讀體驗: [譯] Go語言使用TCP keepalive | yoko blog (https://pengrl.com/p/62417/)

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

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

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

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

TCP keepalive —— 一個輕量級的ping

TCP keepalive發(fā)送沒有(或者幾乎沒有)包體負載的TCP報文給對端,并且對端會回復(fù)keepalive ACK確認包。它不是TCP標準的一部分(盡管在RFC1122中有相關(guān)的描述),并且,它總是默認被禁用。盡管如此,大部分現(xiàn)代的TCP協(xié)議棧都支持這個特性。

在它的大部分實現(xiàn)中,簡單來說,有三個主要參數(shù):

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

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

服務(wù)端收到客戶端的一包應(yīng)用層數(shù)據(jù)。然后客戶端不再發(fā)送任何數(shù)據(jù)。服務(wù)端等待30秒。然后發(fā)送一個ping給客戶端。如果服務(wù)端收到了ACK,則服務(wù)端等待另一個30秒,再次發(fā)送ping;如果在這30秒內(nèi)服務(wù)端收到了數(shù)據(jù),則30秒的定時器被重置。

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

默認值

據(jù)說Window系統(tǒng)在發(fā)送keepalive ping之前默認等待2小時。Linux下獲取默認值十分簡單,就像此處3.1.1節(jié)描述的這樣。

# 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語言中運用TCP keepalive。

討論開始之前需要說明,以下內(nèi)容適用于Linux。我不是百分百確定它是否適用于OSX,但我?guī)缀蹩梢钥隙ㄋ贿m用于Windows。

連接的特殊類型

首先,我注意到我在服務(wù)端程序中只使用了net.Conn類型。但是它并不管用,它缺少我們需要的特定方法。我們需要TCPConn類型。

這意味著,我們需要使用ListenTCPAcceptTCP而不是ListenAccept(它們的調(diào)用方式有區(qū)別,ListenTCP使用結(jié)構(gòu)體而不是字符串來表示地址。我們調(diào)用方式大概會像這樣:ListenTCP("tcp", &net.TCPAddr{Port: myClientPort})。如果你不特別指定的話,IP的默認值為0.0.0.0)。之后它會返回我們需要的類型TCPConn

Go語言提供的方法

如果你翻看文檔可能會注意到這兩個相關(guān)的方法:SetKeepAliveSetKeepAlivePeriod。
func (c *TCPConn) SetKeepAlive(keepalive bool) error的調(diào)用方式十分簡單:傳入true從而打開TCP keepalive機制。

但是接下來的func (c *TCPConn) SetKeepAlivePeriod(d time.Duration) error就有些令人困惑了。我們用它究竟設(shè)置的是什么?答案可以在這篇文章(好文章,推薦閱讀)中找到:它同時設(shè)置了空閑時間重試間隔時間。而重試間隔次數(shù)則使用系統(tǒng)的默認值。所以如果我設(shè)置5 * time.Second。那么它可能是等待5秒鐘,發(fā)送ping并等待另一個5秒。并且8次重試(取決于系統(tǒng)設(shè)置)。而我需要更大的靈活性,設(shè)置得更精準。

進入系統(tǒng)層面

可以通過直接操作socket參數(shù)來實現(xiàn)。我沒有關(guān)注里面太多的細節(jié),這純粹是我的個人解釋。以下是我們?nèi)绾卧O(shè)置空閑時間為30秒(我們可以通過SetKeepAlivePeriod設(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ā)生錯誤。如果是keepalive引起的錯誤,err.Error()將會包含連接超時信息。

關(guān)于文件描述符的坑

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

問題就隱藏在Fd函數(shù)調(diào)用。我們來看它的實現(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的這個回答,舉例來說,當Go增加一個阻塞的系統(tǒng)調(diào)用,運行時調(diào)度器將該系統(tǒng)調(diào)用所屬協(xié)程所屬系統(tǒng)線程從調(diào)度池中移出。如果調(diào)度池中的系統(tǒng)線程數(shù)小于GOMAXPROCS,則會創(chuàng)建新的系統(tǒng)線程。鑒于我的每一個連接都使用一個獨立協(xié)程,你可以想象一下這個爆炸速度。將很快到達10000線程的限制然后panic。

將它放入獨立協(xié)程并不好使。

譯者yoko注,個人理解此處可做兩層解釋,如果是像原作者所描述的,每個連接都獨占一個協(xié)程(直到連接關(guān)閉再退出協(xié)程),先使用系統(tǒng)調(diào)用設(shè)置文件描述符屬性,再收發(fā)數(shù)據(jù),那么系統(tǒng)線程會隨連接數(shù)線性增長。如果是在連接收發(fā)數(shù)據(jù)的協(xié)程之前,先弄一個協(xié)程處理完文件描述符屬性的設(shè)置,那么系統(tǒng)調(diào)用完成后臨時協(xié)程結(jié)束,線程還是會回收的。但也畢竟不是一種好的模式。

但是有一個方法是可行的。注意,前提是Go版本高于1.11??匆韵麓a。

//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實現(xiàn)了SyscallConn,它使得你可以獲取RawConn對象從而設(shè)置參數(shù)。你所需要做的就是定義一個函數(shù)(就像上面例子中的匿名函數(shù)),它接收一個指向文件描述符的參數(shù)。這是操作連接中的文件描述符而不造成阻塞調(diào)用的方法,可避免出現(xiàn)瘋狂創(chuàng)建線程的情況。

總結(jié)

網(wǎng)絡(luò)編程是復(fù)雜的。并且時常是系統(tǒng)相關(guān)的。這個解決方法只在Linux下有用,但是這是一個好的開始。在其他操作系統(tǒng)中有類似的參數(shù),它們只是調(diào)用方式不同。

感謝閱讀。再見。

本文作者: yoko
本文鏈接: http://www.pengrl.com/p/62417/
版權(quán)聲明: 本博客所有文章除特別聲明外,均采用 CC BY-NC-SA 3.0 許可協(xié)議。轉(zhuǎn)載請注明出處!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容