Golang中TCP連接回收問題

最近同事上線了一個功能,涉及到 thrift rpc。上完線后看代碼才發現 thrift client 用完之后忘記將 transport close 掉,擔心 socket 無法關閉會出大問題,趕緊去看監控,發現并沒有什么異常,socket 數沒有增多,句柄數也沒有增多,十分怪異。既然線上無影響,決定先線下分析一下。

寫了一份測試代碼,核心內容如下:

var (
    tSTimeout = 0 * time.Millisecond
    tSCost    = 1 * time.Second
    tCTimeout = 2 * time.Second
)

// service handler
type PriceServiceImpl struct{}

func (p *PriceServiceImpl) Price(req *dups_price.PriceReq) (r *dups_price.PriceRes, err error) {
    r = dups_price.NewPriceRes()
    r.ErrNo = 0
    r.PassengerDiscount = 0.4

    time.Sleep(tSCost)

    return r, nil
}

func NewPriceServiceImpl() *PriceServiceImpl {
    return &PriceServiceImpl{}
}

// server
func tServer() {
    processor := dups_price.NewPriceServiceProcessor(NewPriceServiceImpl())
    svrTransport, err := thrift.NewTServerSocketTimeout(
        ":9876",
        tSTimeout)

    tFactory := thrift.NewTFramedTransportFactory(thrift.NewTTransportFactory())

    pFactory := thrift.NewTBinaryProtocolFactoryDefault()

    svr := thrift.NewTSimpleServer4(processor, svrTransport, tFactory, pFactory)

    log.Println("serving...")
    svr.Serve()
}

// client
func tClient() error {

    t, err := thrift.NewTSocketTimeout("127.0.0.1:9876", tCTimeout)
    if err != nil {
        return err
    }
    if err := t.Open(); err != nil {
        return err
    }
    tFactory := thrift.NewTFramedTransportFactory(thrift.NewTTransportFactory())
    trans := tFactory.GetTransport(t)

    f := thrift.NewTBinaryProtocolFactoryDefault()

    cli := dups_price.NewPriceServiceClientFactory(trans, f)

    req := dups_price.NewPriceReq()
    req.Area = 1
    req.Trace = dups_price.NewTrace()
    req.Trace.TraceId = "s"
    req.Trace.Caller = "d"
    req.Trace.SpanId = "f"

    r, err := cli.Price(req)
    //defer cli.Transport.Close()

    log.Println(cli.Transport.IsOpen())
    if err != nil {
        return err
    }

    log.Printf("%+v\n", r)
    return nil
}

func testThrift() {
    go tServer()

    time.Sleep(1 * time.Second)

    tClient()

    log.Println(err)

    //time.Sleep(10 * time.Second)
    //runtime.GC()

    select {}
}

注意到上面代碼一開始的幾個時間變量,通過調整它們來測試不同場景下的連接的表現。

為方便起見,把我們的服務(rpc client)簡稱A,rpc server 簡稱 B。

檢查了下 client 端超時 20 ms,server 端沒有設置超時,所以有兩種情況,一是 client 端超時,二是兩端都無超時。這兩種情況下 client 和 server 會如何表現,需要結合上述代碼了解下 thrift 的實現。

client

client 主要有三步:創建 socket;建立連接(open);rpc 請求,其中又包括 send 和 recv 兩步:

func (p *PriceServiceClient) Price(req *PriceReq) (r *PriceRes, err error) {
    if err = p.sendPrice(req); err != nil {
        return
    }
    return p.recvPrice()
}

如果設置了超時,超時機制怎么起作用呢?以 framedtransport 的 send 為例,proto 層每次調用 write 的時候,實際上是寫到內存 buf 中,write 完成再調用oprot.Flush()的時候,才會真正調用 framedtransport 的 Flush,進而調用底層 socket 的 Write 和 Flush。

func (p *TSocket) Write(buf []byte) (int, error) {
    if !p.IsOpen() {
        return 0, NewTTransportException(NOT_OPEN, "Connection not open")
    }
    p.pushDeadline(false, true)
    return p.conn.Write(buf)
}

在真正走到 syscall.Write() 之前,首先會調用 p.pushDeadline 來設置 fd 的 timeout。如果 write 期間超時,那么 write 的時候就會直接返回超時錯誤,上層接住,rpc 調用cli.Price(req)返回;但是無論是否出錯,socket后面的命運是交給使用者來處理的,如果沒有任何處理,理論上 socket 會一直保持 Established 狀態。

server

server 的 AcceptLoop 在收到請求連接后,新開協程去處理

go func() {
        if err := p.processRequests(client); err != nil {
            log.Println("error processing request:", err)
        }
}()

在這里可以看到,返回錯誤后只會打印,不會做其他處理,繼續看該函數實現:

func (p *TSimpleServer) processRequests(client TTransport) error {
    
    ....
    
    if inputTransport != nil {
        defer inputTransport.Close()
    }
    if outputTransport != nil {
        defer outputTransport.Close()
    }
    for {
        ok, err := processor.Process(inputProtocol, outputProtocol)
        if err, ok := err.(TTransportException); ok && err.TypeId() == END_OF_FILE {
            return nil
        } else if err != nil {
            log.Printf("error processing request: %s", err)
            return err
        }
        if !ok {
            break
        }
    }
    return nil
}

其中,processor.Process() 表示一次讀請求,執行rpc,寫回結果。可以看到,無論這一步是否成功,只要最后能跳出循環,Transport.Close() 都能執行,也就可以關閉連接。如果server 端沒有設置超時,且 client 端沒有發起連接關閉,理論上 for 循環不會退出,連接會一直保持 Established 狀態。如果 server 端有超時,那么在一次處理完成后正常寫回數據,下次進入循環才會出現 read timeout 的錯誤,并在 server 端主動發起連接關閉,但 client 端收到 FIN 后沒有關閉連接,因此 server 端會一直處于 FIN_WAIT_2,client 端會一直處于 CLOSE_WAIT。

下面通過文章開始處的示例來測試一下。

case 1:client 超時,server 不超時

tSTimeout = 0 * time.Millisecond
tSCost    = 1 * time.Second
tCTimeout = 2 * time.Second

結果:兩端一直保持 ESTABLISHED

case 2: client 不超時, server 超時

tSTimeout = 100 * time.Millisecond
tSCost    = 1 * time.Second
tCTimeout = 2 * time.Second

結果:client 端正常接收到 rpc 返回,server 端 1s 后打印兩次 error processing request: read tcp 127.0.0.1:9876->127.0.0.1:64681: i/o timeout;之所以沒有100ms的時候就返回,原因在于上述的 for 循環機制,之所以打印兩次,也是那段代碼,有兩次打印。連接狀態,client 端 CLOSE_WAIT,server 端 FIN_WAIT_2

此外,client 不超時,server 不超時,結果同 case1;client 超時, server 超時,結果同 case2,除了 client rpc 失敗.

根據上面的解析和驗證可以得知,線上的情況,預期是會有大量的 socket 處于 established 狀態,然而實際并不是。現在沒招了。

飯后跟幾個同事閑聊,有同事懷疑是 gc 的問題,把沒有關閉的資源釋放掉了。我感覺未必是,因為 golang 中的資源基本上都需要用戶關心資源的釋放問題,doc 里到處提示要主動釋放,否則有 leak 的風險。不過只要有這個可能,還是值得研究一下的。先讓同事測試了一下文件 fd 在不引用后,是否會在 gc 的時候被清理,結果還真應驗了。通過網上一篇文章了解到,golang 中有個 runtime.SetFinalizer() 函數,可以給對象綁定一個 Finalizer,gc 的時候發現有設置,則先調用這個 Finalizer,這個概念類似 C++ 中的析構函數。扒拉一下 netFD 的代碼,發現:

func (fd *netFD) setAddr(laddr, raddr Addr) {
    fd.laddr = laddr
    fd.raddr = raddr
    runtime.SetFinalizer(fd, (*netFD).Close)
}

果真設置了 Finalizer,其函數內容就是關閉連接!

示例代碼中打開主動 GC,果然發現 client socket 回收了,client 端主動關閉,出現了 TIME_WAIT。回過頭來看線上服務 A,平均每秒鐘4次 gc,每秒鐘 60 個請求, netstat 看到有 5-10 個 established 狀態的 socket,且端口不斷刷新,看起來應該就是被 gc 掉了。

至此,文章開始提出的疑問就得到了解答。不小心忘了關 socket,線上無異常,是因為 netFD 恰好被設置了 Finalizer 從而被 golang 的 gc 給清理掉了。不過 這個玩意也是有坑的,用不好會出問題,doc 中一堆 "not guaranteed"。還是要養成有開有關有借有還的好習慣。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容