美文网首页
Golang中TCP连接回收问题

Golang中TCP连接回收问题

作者: zippera | 来源:发表于2017-12-06 16:27 被阅读948次

最近同事上线了一个功能,涉及到 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"。还是要养成有开有关有借有还的好习惯。

相关文章

网友评论

      本文标题:Golang中TCP连接回收问题

      本文链接:https://www.haomeiwen.com/subject/coklixtx.html