最近同事上線了一個功能,涉及到 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"。還是要養成有開有關有借有還的好習慣。