一次goroutine 走漏排查事例

一同养成写作习惯!这是我参加「日新方案 4 月更文应战」的第2天,点击查看活动概况。

背景

这是一个比较经典的golang协程走漏事例。

背景是这样,今天看到监控大盘数据发现协程的数量监控很奇怪。出现上升趋势,然后骤降。尽管对协程数量做了报警机制,可是协程数量仍是没有达到报警阈值,所以没有报警发生。

一次goroutine 泄漏排查案例

不过有经历的开发应该应该能一眼看出,这个肯定是协程走漏了,因为协程数量一向在上涨,没有下降趋势,,中心下降的曲线其实是服务器重启形成的。

pprof剖析

为了直接确认是哪里导致的协程走漏,用golang的pprof东西去对协程数量比较多的堆栈进行排查,关于golang pprof的使用以及计算原理能够看我的这个系列golang pprof 的使用。

以下是采样到的goroutine的profile文件。

一次goroutine 泄漏排查案例

能够发现主要是transport.go这个文件里发生的协程没有被开释,transport.go这个文件是golang里用于发起http恳求的文件,并且定位到了详细的协程走漏代码方位 是writeloop 和readloop 函数。

熟悉golang的同学应该能立马想到,协程没有开释的原因极大可能是恳求的呼应体没有封闭。这也算是golang里边的一个坑了。

在剖析之前,仍是先说下结论,resp.Body在被完好读取时,即便不显现的进行封闭也不会形成协程走漏,只要读取部分resp.Body时,不显现封闭才会引发协程走漏问题

现在咱们仍是 详细剖析下为啥resp body不封闭,会形成协程走漏。

恳求发送与接纳流程

咱们先来看看golang里边是怎么发送以及接纳http恳求的。下面这张图完好的展现了一个恳求被发送以及其呼应被接纳的过程,咱们根据它然后结合代码剖析下。

一次goroutine 泄漏排查案例

如图所示,在咱们用http.Get 办法发送恳求时,底层追踪下去,会调用到roundtrip 函数进行恳求的发送与呼应的接纳。roundtrip方位在源码的方位如下,代码根据golang1.17版别进行剖析,

// src/net/http/transport.go:2528
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) 

在代码里,用persistConn这个结构体代表了一个http衔接,这个衔接能够从衔接池中获取,也能够被新建。

// src/net/http/transport.go:1869 reqch 和writech 都是衔接的属性
type persistConn struct {
.....
reqch     chan requestAndChan // written by roundTrip; read by readLoop
writech   chan writeRequest   // written by roundTrip; read by writeLoop
...
}

在roundtrip函数中,会往persistConn 的writech和reqch两个chan 通道内发送数据。代码如下:

// src/net/http/transport.go:2528
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
    ....
    // src/net/http/transport.go:2594
    	pc.writech <- writeRequest{req, writeErrCh, continueCh}
    ...
    // src/net/http/transport.go:2598
    pc.reqch <- requestAndChan{
		req:        req.Request,
		cancelKey:  req.cancelKey,
		ch:         resc,
		addedGzip:  requestedGzip,
		continueCh: continueCh,
		callerGone: gone,
	}
}

恳求发送过程

writech 通道和恳求的发送有关,通道里的恳求真实发送到网卡则是由persistConn的writeloop办法完结的。

persistConn的writeloop 办法是衔接被dialConn办法创立的时候,就会用一个协程去调度履行的办法。代码如下:

// src/net/http/transport.go:1560
func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
    .... 省掉了部分代码
   // src/net/http/transport.go:1747 
   go pconn.readLoop()
	go pconn.writeLoop()
	return pconn, nil
}

在pconn.writeLoop里,会不断的轮询persistConn的writech通道里的音讯,然后经过wr.req.Request.write发送到互联网中。

// src/net/http/transport.go:2383 
func (pc *persistConn) writeLoop() {
	defer close(pc.writeLoopDone)
	for {
		select {
		case wr := <-pc.writech:
			startBytesWritten := pc.nwrite
			err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))
			.... 省掉部分代码
}

知道恳求时怎么发送出去的了,那么衔接persistConn是怎么接纳恳求的呼应呢?

呼应接纳的流程

咱们再回到roundtrip函数逻辑里,除了赋值persistConn的writech属性值,roundtrip函数还会为persistConn的reqch属性赋值,persistConn在被创立时,相同会发动一个协程去调度履行一个叫做readloop的办法。代码其实现已在上面展现过了,不过为了方便看,我在此处再列举一次,

// src/net/http/transport.go:2528
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
    .... 省掉部分代码
    // src/net/http/transport.go:2598
    pc.reqch <- requestAndChan{
		req:        req.Request,
		cancelKey:  req.cancelKey,
		ch:         resc,
		addedGzip:  requestedGzip,
		continueCh: continueCh,
		callerGone: gone,
	}
	    .... 省掉部分代码
}
// src/net/http/transport.go:1560
func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
    .... 省掉了部分代码
   // src/net/http/transport.go:1747 
   go pconn.readLoop()
	go pconn.writeLoop()
	return pconn, nil
}

readloop 办法会 读取persistConn 读缓冲区中的数据,读到后就将呼应信息放到reqch通道里,终究reqch通道里的呼应信息就能被roundtrip函数获取到然后回来给应用层代码了。

readloop读取缓冲区数据大致流程如下:

// src/net/http/transport.go:2052
func (pc *persistConn) readLoop() {
    .... 省掉部分代码
    for alive {
		... 省掉部分代码
		rc := <-pc.reqch
		trace := httptrace.ContextClientTrace(rc.req.Context())
		var resp *Response
		if err == nil {
		   // 读取呼应
			resp, err = pc.readResponse(rc, trace)
		} else {
			err = transportReadFromServerError{err}
			closeErr = err
		}
		...... 
		waitForBodyRead := make(chan bool, 2)
		body := &bodyEOFSignal{
			body: resp.Body,
			earlyCloseFn: func() error {
				waitForBodyRead <- false
				<-eofc // will be closed by deferred call at the end of the function
				return nil
			},
			fn: func(err error) error {
				isEOF := err == io.EOF
				waitForBodyRead <- isEOF
				if isEOF {
					<-eofc // see comment above eofc declaration
				} else if err != nil {
					if cerr := pc.canceled(); cerr != nil {
						return cerr
					}
				}
				return err
			},
		}
		resp.Body = body
		.......
       select {
      //  rc 是pc.reqch的引用,这儿将呼应结果传递给了这个通道
		case rc.ch <- responseAndError{res: resp}:
		case <-rc.callerGone:
			return
		} 
      // 堵塞等候呼应信息被读取结束或许应用层封闭resp.Body 
		select {
		case bodyEOF := <-waitForBodyRead:
			replaced := pc.t.replaceReqCanceler(rc.cancelKey, nil) 			alive = alive &&
				bodyEOF &&
				!pc.sawEOF &&
				pc.wroteRequest() &&
				replaced && tryPutIdleConn(trace)
			if bodyEOF {
				eofc <- struct{}{}
			}
		case <-rc.req.Cancel:
			alive = false
			pc.t.CancelRequest(rc.req)
		case <-rc.req.Context().Done():
			alive = false
			pc.t.cancelRequest(rc.cancelKey, rc.req.Context().Err())
		case <-pc.closech:
			alive = false
		}
	}
}

readloop 经过pc.readResponse 读取一次http呼应后,会将呼应体发送到pc.reqch ,roundtrip函数堵塞等候pc.reqch里有数据抵达后,则将pc.reqch里的呼应体取出来回来给应用层代码。

留意readloop函数在读取一次呼应后,会堵塞等候呼应体被读取结束,或许呼应体被Close掉后,才会将persistConn从头放回衔接池,然后等候读下一个http的呼应体。 应用层会调用resp.Body的Close办法,从readloop源码能够看出,resp.body实际是个bodyEOFSignal类型,bodyEOFSignal的Close办法如下:

func (es *bodyEOFSignal) Read(p []byte) (n int, err error) {
	....省掉部分代码 
	n, err = es.body.Read(p)
	if err != nil {
		es.mu.Lock()
		defer es.mu.Unlock()
		if es.rerr == nil {
			es.rerr = err
		}
		err = es.condfn(err)
	}
	return
}
func (es *bodyEOFSignal) Close() error {
	es.mu.Lock()
	defer es.mu.Unlock()
	if es.closed {
		return nil
	}
	es.closed = true
	if es.earlyCloseFn != nil && es.rerr != io.EOF {
		return es.earlyCloseFn()
	}
	err := es.body.Close()
	return es.condfn(err)
}
// caller must hold es.mu.
func (es *bodyEOFSignal) condfn(err error) error {
	if es.fn == nil {
		return err
	}
	err = es.fn(err)
	es.fn = nil
	return err
}

调用bodyEOFSignal.Close办法终究会调到bodyEOFSignal的fn办法或许earlyCloseFn办法,earlyCloseFn在Close呼应体的时候,发现呼应体还没有被完全读取时会被调用。

调用bodyEOFSignal.Read办法时,当read读取结束后err将会是 io.EOF,此刻err不为空将会调用condfn 办法对fn办法进行调用。

fn,earlyCloseFn函数是在哪里声明的呢?还记住readloop源码里bodyEOFSignal的声明吗,我这儿再展现一下上述的源码部分:

// src/net/http/transport.go:2166
body := &bodyEOFSignal{
			body: resp.Body,
			earlyCloseFn: func() error {
				waitForBodyRead <- false
				<-eofc // will be closed by deferred call at the end of the function
				return nil
			},
			fn: func(err error) error {
				isEOF := err == io.EOF
				waitForBodyRead <- isEOF
				if isEOF {
					<-eofc // see comment above eofc declaration
				} else if err != nil {
					if cerr := pc.canceled(); cerr != nil {
						return cerr
					}
				}
				return err
			},
		}

声明呼应体body的时候就定义好了者两个函数,这两个函数都是往waitForBodyRead通道发送音讯,readloop会堵塞等候waitForBodyRead的音讯抵达。音讯抵达后阐明resp.Body 被读取结束或许主动封闭了,然后调用tryPutIdleConn将衔接从头放回衔接池中 完好的代码仍是在上述readloop的源码片段里,我这儿只展现下readloop部分代码。

// src/net/http/transport.go:2207
select {
		case bodyEOF := <-waitForBodyRead:
			replaced := pc.t.replaceReqCanceler(rc.cancelKey, nil) // before pc might return to idle pool
			alive = alive &&
				bodyEOF &&
				!pc.sawEOF &&
				pc.wroteRequest() &&
				// tryPutIdeConn 将衔接从头放入衔接池
				replaced && tryPutIdleConn(trace)
			if bodyEOF {
				eofc <- struct{}{}
			}

现在再来看咱们go协程走漏的代码在那里,是在readloop和writelooop函数中,走漏的原因就在于读取呼应体后没有对呼应体将进行显现的封闭或许没有把呼应体的内容读取结束,导致没有向waitForBodyRead通道发送音讯,而履行的readloop函数的协程一向堵塞等候waitForBodyRead音讯的抵达,后续的恳求又新建了衔接,从而新起了readloop协程,writeloop协程,相同因为呼应体未封闭也堵塞在这儿,导致协程数量越来越多,从而有协程走漏的现象

一般情况下,咱们都会完好的读取完resp.Body,所以即便不显现的封闭body,也不会有走漏问题发生,但咱们的程序刚好有段逻辑需要只需要读取body的前10字节,代码如下:

_, err = ioutil.ReadAll(io.LimitReader(resp.Body, 10))
	if err != nil && err != io.EOF {
		t.Fatal(err)
	}

读取完后也没有封闭resp.Body 并且相似的恳求越来越多,导致咱们的协程数量越来越多了。

修复这个bug也很简单,即对resp body封闭即可。

resp.body.Close()

反思

golang resp body 仍是一定要记住封闭,否则就会引发协程走漏问题,这次因为同事对此类问题没有过多重视,导致了这个问题,好在有监控大盘,及时发现,否则后果不堪设想。