当前使用go版本:1.21.4,看源代码应该21版本之前的都有这个问题。
最近使用golang的标准http库的客户端发起请求的时候,出现了内存泄漏,随着时间的增加,内存占用匀速增长。在排除了各种使用错误的情况后,发现是长连接的tls数据因为某些原因滞留在内存中,某些特殊情况可能会出现这个问题,所以记录一下排查的过程与对应的解决办法。
情况
监控面板的趋势:
pprof查看泄漏问题:
可以清楚问题出现在在http内部网络连接的缓存上,可能是连接没有成功释放导致。
-
首先介绍一下http客户端的连接池管理逻辑,知道了这个脉络才清楚问题的原因。(源代码过程可以见下文附录)
-
开启连接池后,每一次创建完连接会放入池(idleConn属性)中复用
-
如果有请求到达且查无对应该请求的空闲连接,会加入到idleConnWait形成等待队列,申请到TCP连接后将传输给对应请求使用。
-
请求与响应完成后则会将连接放回到连接池中完成复用------放回池中的动作会检查idleConnWait中的等待队列并传递空闲的连接,并且会维护idleConnWait的长度。(连接池通过idleLRU管理空闲链接的生命周期)
idleLRU通过MaxIdleConns维护连接的数量,并负责调用连接的close关闭多余的连接。
idleConn、idleConnWait中的key,是一个三元组,用来确定一条连接------ 代理、协议、目标IP+端口
-
-
业务上对http库的使用有些特殊。因为业务侧的qps很高,发起请求的时候为了减少网络带宽的占用,期望只读取响应的header,不再读取剩下的data数据。所以在代码层面就是:如果请求成功响应,直接close掉body。
golangresp, err := cli.Get(targetUrl) if resp != nil { //_, _ = io.ReadAll(resp.Body) _ = resp.Body.Close() println(resp.StatusCode) }
-
这个不读取bdoy内容反而提前关闭的行为,就导致了连接滞留在内存中,留在了idleConnWait里。
从http库源代码中可以看到,body的返回其实封装了一个结构体,如果没有读取内容就关闭body,会执行body的earlyCloseFn逻辑。这会导致连接没有回到连接池复用(没有执行到tryPutIdleConn方法,这个方法会清理idleConnWait的对象),没有清理idleConnWait中滞留的连接对象,让这条连接的tls数据滞留在内存中。
具体body的close过程与滞留情况代码过程可以看文后的附录章节。
-
正常情况下,使用连接的过程中如果出现了错误,会进入transport.IdleConnWait中,等待后续有对应的连接的话,将会发送消息。此时连接persistConn是nil。
-
如果成功请求,因为没有读取body后直接关闭,也会进入到transport.IdleConnWait中。
但是,此时连接persistConn不是nil,是一条已经成功握手的https连接。
-
-
业务侧qps很高的情况下,idleConnWait就滞留了大量连接对象,其中tls握手的数据占大部分,导致内存不断攀升。
解决与总结
解决方式
-
开启短连接 DisableKeepAlive = true。可以解决。
-
尝试设置连接池 MaxIdleHostPer = -1。
仍然出现了内存泄漏,因为MaxIdleHostPer是后置控制,在连接加入idleConnWait时候并没有发挥作用,只在准备将连接加入连接池的时候才发挥作用,阻止不了内存泄漏。
-
二次封装transport,采用多连接池的形式transport。可以解决。
-
完整读取body。可行。
-
升级最新的golang(大于1.22)。可以解决。
最新的里面修复别的问题而定义了新的结构(修改了wantConn),刚好可以解决这个问题。 go-review.googlesource.com/c/go/+/5220...
总结
这是因为使用姿势与http库自身导致的问题,如果不读取body内容而直接关闭,一方面是导致连接不可复用,在大流量请求的时候,会造成连接对象的内存泄漏。
在解决问题的过程中,看到了一些文章说body忘记关闭、等使用姿势会导致一些问题。经过验证,有的可信有的不可信,因为http库中的body是多次封装的,到了用户侧其实已经是比较高级封装的body了,使用姿势导致的问题都是在很极端的情况,正常使用的话,body关闭问题应该不需要很大的关注。
具体可以看小白哥这篇文章的验证过程: mp.weixin.qq.com/s/iB8RPOwdw...
参考
github.com/golang/go/i...
github.com/golang/go/i...
github.com/golang/go/i...
github.com/golang/go/i...
附录
标准http库客户端核心过程源码
golang
// go version go1.21.2
// http client.go
func (c *Client) Do(req *Request) (*Response, error) {
return c.do(req)
}
if resp, didTimeout, err = c.send(req, deadline); err != nil { // client的核心就是调用send方法
--> resp, didTimeout, err = send(req, c.transport(), deadline) // send函数传入连接池
--> resp, err = rt.RoundTrip(req) // 此时调用http transport.go 中的方法,如果没有设定,则使用代码中默认的DefaultTransport
// 到http transport.go中查看接下来的逻辑
func (t *Transport) roundTrip(req *Request) (*Response, error) {...
--> cm, err := t.connectMethodForRequest(treq) // 如果有网络代理,则会在这里构建代理请求
--> pconn, err := t.getConn(treq, cm) // 获取一条网络连接
--> if delivered := t.queueForIdleConn(w); delivered {... //检查是否有空闲可复用的连接
--> t.queueForDial(w) // 如果没有空闲链接,则创建新连接
--> go t.dialConnFor(w)
--> pc, err := t.dialConn(w.ctx, w.cm) // 创建链接(TLS握手是这个阶段)
--> conn, err := t.dial(ctx, "tcp", cm.addr())
--> pconn.br = bufio.NewReaderSize(pconn, t.readBufferSize());pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize())
--> go pconn.readLoop(); go pconn.writeLoop() // 启动两个协程负责连接数据的读与写
--> tryPutIdleConn := func(trace *httptrace.ClientTrace) bool { ...//链接用完尝试丢进池中复用
--> case <-w.ready: ...// 空闲连接放回连接池时,或者异步建立连接成功后,关闭管道w.ready,这里select就会触发,表示拿到一条就绪的连接
type persistConn struct {
//协程间通信用的管道(请求与响应)
reqch chan requestAndChan // written by roundTrip; read by readLoop
writech chan writeRequest // written by roundTrip; read by writeLoop
}
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
//通知准备发起HTTP请求(写数据)
pc.writech <- writeRequest{req, writeErrCh, continueCh}
//通知准备读取响应
pc.reqch <- requestAndChan{
}
for {
select {
//获取到响应了
case re := <-resc:
return re.res, nil
//超时,出错等等case处理(可能直接关闭该连接)
}
}
}
提前关闭body是否能阻断带宽占用
一般 http 协议解释器是要先解析 header,再解析 body。
go
// http.transport.go
func (pc *persistConn) readLoop() {
closeErr := errReadLoopExiting // default value, if not changed below
defer func() {
pc.close(closeErr) // 关闭连接
pc.t.removeIdleConn(pc) // 从连接池中删除
}()
...
alive := true
for alive {
...
rc := <-pc.reqch // 从管道中拿到请求,roundTrip 对该管道进行输入
trace := httptrace.ContextClientTrace(rc.req.Context())
var resp *Response
if err == nil {
resp, err = pc.readResponse(rc, trace) // 更多的是解析 header
} else {
err = transportReadFromServerError{err}
closeErr = err
}
...
}
如果不读取body直接关闭,可以关闭底下的body网络数据传输,socket文件也就不再读取内容。
body的提前关闭代码逻辑
返回响应的时候构造了bodyEOFSignal对象,如果提前关闭,会触发earlyCloseFn逻辑,通过channel传递一个false值,将readloop协程退出。
go
// http.transport.go
func (pc *persistConn) readLoop() {
...
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
},
}
...
select {
case bodyEOF := <-waitForBodyRead:
replaced := pc.t.replaceReqCanceler(rc.cancelKey, nil) // before pc might return to idle pool
alive = alive &&
bodyEOF && // 这里为false,不会再判断后续的了,整个循环结束掉
!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
}
...
}
不读取body然后直接关闭的时候:
go
// 关闭body时候执行的函数
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() // 没有读取body后关闭会直接进入这里
}
err := es.body.Close()
return es.condfn(err)
}
func (es *bodyEOFSignal) Read(p []byte) (n int, err error) {
es.mu.Lock()
closed, rerr := es.closed, es.rerr
es.mu.Unlock()
if closed {
return 0, errReadOnClosedResBody
}
if rerr != nil {
return 0, rerr
}
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
}
https连接滞留内存中,实际的tcp连接是否也滞留
实际的tcp连接会被系统回收,滞留的只是persistConn对象里的buffer数据。(也因为readloop里退出时会调用defer,关闭连接)
yaml
sudo tcpdump host 10.10.16.59
11:59:04.177962 IP 192.168.1.78.52366 > 10.10.16.59.dc: Flags [F.], seq 708, ack 6580, win 2048, options [nop,nop,TS val 1218717194 ecr 2719043116], length 0
11:59:04.182230 IP 10.10.16.59.dc > 192.168.1.78.52366: Flags [P.], seq 6580:6730, ack 677, win 506, options [nop,nop,TS val 2719043120 ecr 1218717194], length 150
11:59:04.182232 IP 10.10.16.59.dc > 192.168.1.78.52366: Flags [F.], seq 6730, ack 709, win 506, options [nop,nop,TS val 2719043120 ecr 1218717194], length 0
连接关闭
####当目标网站是http2协议的时候
演示代码
go
func TestHttpLeak(t *testing.T) {
targetUrl := "https://www.baidu.com"
cli := http.DefaultClient
tran := &http.Transport{
IdleConnTimeout: 2 * time.Second,
//DisableKeepAlives: true,
//MaxIdleConnsPerHost: -1,
}
var i int
tran.Proxy = func(r *http.Request) (*url.URL, error) {
if len(ip) > 0 {
uri := ""
u, err := url.Parse(uri)
if err == nil {
return u, nil
}
}
return nil, nil
}
cli.Transport = tran
for i = 0; i < 5; i++ {
resp, err := cli.Get(targetUrl)
if resp != nil {
//_, _ = io.ReadAll(resp.Body)
_ = resp.Body.Close()
println(resp.StatusCode)
}
if err != nil {
t.Errorf("get fail , err=%s", err)
}
}
}