一次内存泄漏的排查

业务背景

我们有一款产品跟海外某个国家的客户有业务合作,因此,我们在这个国家的服务器上单独部署了一整套的服务(大概有八九个微服务),这些服务的宿主机大概都集中在三、四台宿主机上。这些服务日复一日,年复一年的并肩作战着,直到有一天……

初见端倪

前阵子,我们在一个服务进行扩容、重启的时候,总是概率性的出现如下错误导致的 panic:

端口用尽panic

Google 的结果是说由于 Linux 分配的客户端连接端口用尽,无法建立 socket 连接导致的。

这时候还有点懵逼,单纯以为是容器的宿主机有问题,于是,我重新在另外一台机器进行了节点扩容,这次很幸运,服务启动成功了,于是乎以为问题已经顺利解决,可以美美的下班了。

渐露头角

过了一周,我们发现线上的另一个服务在疯狂报错,上去查看日志,发现:

内存泄漏排查-其他机器端口耗尽

同样的错误又出现了,而且这次是在另外一个服务上,这时候开始觉得哪里不对劲了,另一个服务怎么也出现这种错误?难道又是宿主机有问题?接着仔细一看,诶,这个机器 ip 怎么看着有点眼熟,这时候脑子里有了一个不好的想法……

于是,我把剩下的几个服务都上去看了一遍日志,果不其然,基本上每个服务都有这种错误存在。

难道线上流量有这么大?

到这里,感觉有必要弄清楚,到底端口号都哪去了,怎么就都被占了呢,不然再下去可能整套微服务都要挂了(人可能也要没了)。

于是,一次线上问题的排查战开始展开。。。

号角吹响

作为一个还没见过的错误,第一反应很自然就是谷歌,搜索结果显示,很多人都是因为请求完成后连接不是立即释放,而是处于 TIME_WAIT 状态导致的:

内存泄漏排查-TIME_WAIT1

内存泄漏排查-TIME_WAIT2

内存泄漏排查-TIME_WAIT2

解决的办法也很简单,只需要对 Linux 的相关参数进行优化,缩短 TIME_WAIT 的时间,增加可提供的端口范围即可:

 // 表示开启重用。允许将 TIME-WAIT sockets 重新用于新的 TCP 连接,默认为 0,表示关闭
net.ipv4.tcp_tw_reuse=1 
//修改系默认的 TIMEOUT 时间,默认为60s
net.ipv4.tcp_fin_timeout=15
// 表示用于向外连接的端口范围。设置为 1024 到 65535
net.ipv4.ip_local_port_range=1024 65535

看起来似乎这个问题很容易解决嘛,看来今天又可以美美的下班了。

出师未捷

在上手开始操作之前,我打算先看看现在的 TIME_WAIT 数量到底有多少,于是,Google 一条显示当前连接状态的命令(真的记不住。。。),熟练的按下 command+Ccommand+V

netstat -an | awk '/^tcp/ {++y[$NF]} END {for(w in y) print w, y[w]}'

LISTEN 54
ESTABLISHED 142640
SYN_SENT 2
TIME_WAIT 216

。。。

。。。

。。。

一般来说,处于网络连接中的状态主要有以下几个:

  • CLOSED:无连接是活动的或正在进行
  • LISTEN:服务器在等待进入呼叫
  • SYN_RECV:一个连接请求已经到达,等待确认
  • SYN_SENT:应用已经开始,打开一个连接
  • ESTABLISHED:正常数据传输状态
  • FIN_WAIT1:应用说它已经完成
  • FIN_WAIT2:另一边已同意释放
  • ITMED_WAIT:等待所有分组死掉
  • CLOSING:两边同时尝试关闭
  • TIME_WAIT:表示处理完毕,等待超时结束的请求数。
  • LAST_ACK:等待所有分组死掉

当看到打印出来的结果是,我整个人是有点懵的,这怎么跟书上说的好像不太一样啊,说好的是 TIME_WAIT 数量太多了呢?另外,ESTABLISHED 的数量未免也太多了,为什么机器上建立起了这么多的连接?

为了进一步分析问题,我又到其他的容器节点上查看网络连接情况,基本上每台机器的 ESTABLISHED 数量都高达十几万。

于是乎,Google 搜索框的内容变成了:

内存泄漏排查-ESTABLISHED

这时,组里的大佬提点了一句,一直这样查也查不出什么,不如先看看这些连接是从哪里来的。

确实,马克思主义教导我们,脱离实践的理论都是空洞的理论,一切还得从实际出发,那不如先来看看这些连接都是从哪里来的。

渐入佳境

于是我在容器上,用 netstat 命令统计了本机发起的链接,过滤出处于 ESTABLISHED 链接状态的连接,看看大部分是来自于本机哪个端口:

netstat -na | grep ESTABLISHED | awk '{print $4}' | sort | uniq -c|sort -n

内存泄漏排查-ip端口排查

结果很让我意外,某一个 ipport 的连接居然高达 10w+,更让我意外的是,这个地址,竟然是我们部署的某一个微服务!

到这里,问题的根源就呼之欲出了。。。

是它!就是它!内存泄漏!!!而且很有可能就是 http 请求导致的内存泄漏。

为了进一步验证是不是由于 http 的内存泄漏所导致的问题,决定用 pprof 神器进行进一步的分析:

内存泄漏排查-pprof 排查

果然,从打印的 goroutine 信息来看,无疑是发生了 http 的内存泄漏,那很可能就是代码里对于发送 http 请求后的 body 没有进行处理(读取 body 内容或者 close)导致的,定位我们自身的业务代码,果然找到了其中一段内容:

内存泄漏排查-内存泄漏代码

而在其调用的下游 http 服务中,存在返回 404 的场景:

内存泄漏排查-内存泄漏代码2

所以,当下游服务返回 404 时,上游服务直接进行了 return,既没有把 rsp body 的内容读取出来,也没有执行下面的 Close 操作,这就导致了连接即没有办法进行复用,也无法进行回收的尴尬处境,导致连接一直无法释放,造成内存泄漏。

而在我们的业务中,下游服务返回 404 在某些场景下是比较常见的,这就导致了大量的 goroutine 泄漏,出现了上面的问题。

于是,赶紧一顿输出,赶在节前把 bug 解决掉,美美下班回家过节。

知其然,知其所以然

书接上文提到的,在进行 http 请求后,如果没有把 rsp body 的内容读取出来,或者执行 body 的 Close 操作,就会导致连接无法被复用,也无法进行回收,那这里是为什么呢?

欲知此事如何,请听下文源码分析。(内容相对枯燥,谨慎入内。。。)

http.Get 方法为例:

es, err := client.Do(req)
func (c *Client) Do(req *Request) (*Response, error) {
    return c.do(req)
}

func (c *Client) do(req *Request) (retres *Response, reterr error) {
     //...
     if resp, didTimeout, err = c.send(req, deadline); err != nil {
       //...
}
     //...
}

func (c *Client) send(req *Request, deadline time.Time) (resp *Response, didTimeoutfunc() bool, err error) {
     //...
     // 这里关注下 c.transport 和 send 方法
     resp, didTimeout, err = send(req, c.transport(), deadline)
     //...
}

// 可以看到,http 的请求采用 DefaultTransport 进行连接管理
func (c *Client) transport() RoundTripper {
    if c.Transport != nil {
        return c.Transport
    }
    return DefaultTransport
}

func send(ireq *Request, rt RoundTripper, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
     //...
     resp, err = rt.RoundTrip(req)
     //...
}

// 这里开始进入 RoundTrip 逻辑
// src/net/http/roundtrip.go
func (t *Transport) RoundTrip(req *Request) (*Response, error) {
    return t.roundTrip(req)
}

func (t *Transport) roundTrip(req *Request) (*Response, error) {
     //...
     // 这里会获取一个空闲链接,该链接有可能是连接池里的,也有可能是新创建出来的
     pconn, err := t.getConn(treq, cm)
     //...
}

// 这个函数是重点,返回一个长连接
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
     //...
     // 这里尝试从空闲连接中获取一个连接
     if delivered := t.queueForIdleConn(w); delivered {
       //...
           return w.pc,nil
    }
     //...
     // 如果上面没有获取到连接,在这个函数里创建连接
    t.queueForDial(w)
     //...
}

// 使用空闲连接
func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {
     //...
     if list, ok := t.idleConn[w.key]; ok {
       //...
    }
     //...
}

// 创建连接
func (t *Transport) queueForDial(w *wantConn) {
     //...
     // 如果没有限制最大连接数,则直接创建新的连接
     if t.MaxConnsPerHost <= 0 {
        go t.dialConnFor(w)
    return
    }
     //...
     // 如果有最大连接数限制就判断是否已经超过了最大连接,没超过直接创建
     if n := t.connsPerHost[w.key]; n < t.MaxConnsPerHost {
        if t.connsPerHost == nil {
            t.connsPerHost = make(map[connectMethodKey]int)
        }
        t.connsPerHost[w.key] = n + 1
        go t.dialConnFor(w)
        return
    }
     //...
     // 超过了放到等待队列里
     q := t.connsPerHostWait[w.key]
     //...
}

// 创建新的连接都会走到这个方法
func (t *Transport) dialConnFor(w *wantConn) {
     //...
     // 在这里创建连接
     pc, err := t.dialConn(w.ctx, w.cm)
     //...
}

func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
     //...
     conn, err := t.dial(ctx, "tcp", cm.addr())
     //...
     // 创建两个协程,一个读一个写
     go pconn.readLoop()
    go pconn.writeLoop()
     //...
}

首先,Get 方法一路会走到 TransportgetConn 方法里(前面还有很长的流程,这里就忽略了),这个方法会优先尝试从没有释放的空闲连接中获取一个连接,如果没有则执行连接创建。

/ 这个函数是重点,返回一个长连接
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
     //...
     // 这里尝试从空闲连接中获取一个连接
     if delivered := t.queueForIdleConn(w); delivered {
       //...
           return w.pc,nil
    }
     //...
     // 如果上面没有获取到连接,在这个函数里创建连接
    t.queueForDial(w)
     //...
}

创建新连接的方法是 dialConn,这个方法也很长,但是这里的重点在方法结尾处的两行:

func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
    // ...
    go pconn.readLoop()
    go pconn.writeLoop()
    return pconn, nil
}

可以看到,一旦创建一个新连接,就会启动一个读 goroutine 和一个写 goroutine。这两个 goroutine 内部都是一个 for 循环,只有在满足特定的条件下,才会退出循环。

那么,这两个 goroutine 在什么时候释放呢?

先看看 writeLoop:

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))
            // ...
            if err != nil {
                pc.close(err)
                return
            }
        case <-pc.closech:
            return
        }
    }
}

writeLoop 退出循环的场景,一个是执行 request 的写入失败,另外一个就是当前连接被关闭。

readLoop 的代码则相对复杂一些:

func (pc *persistConn) readLoop() {
    //...
    alive := true
    for alive {
        //...
        if resp.Close || rc.req.Close || resp.StatusCode <= 199 || bodyWritable {
            // Don't do keep-alive on error if either party requested a close
            // or we get an unexpected informational (1xx) response.
            // StatusCode 100 is already handled above.
            alive = false
        }
        //...
        if !hasBody || bodyWritable {
            replaced := pc.t.replaceReqCanceler(rc.cancelKey, nil)

            // Put the idle conn back into the pool before we send the response
            // so if they process it quickly and make another request, they'll
            // get this same conn. But we use the unbuffered channel 'rc'
            // to guarantee that persistConn.roundTrip got out of its select
            // potentially waiting for this persistConn to close.

            // 这里判断如果请求的 body 没有内容并且 response 的 body 可写,会直接把连接放回空闲连接池
            // 在上面的注释中表示,golang 会避免一个连接在短时间内刚被放回连接池又被取出来使用,导致两次拿到同一个连接
            alive = alive &&
                !pc.sawEOF &&
                pc.wroteRequest() &&
                replaced && tryPutIdleConn(trace)
            //...
        }
        //...
        // Before looping back to the top of this function and peeking on
        // the bufio.Reader, wait for the caller goroutine to finish
        // reading the response body. (or for cancellation or death)
        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() &&
                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 函数同样也是一个 for 循环,而退出循环的条件是 alive 的值是 false ,那么 alive 什么时候会是 false 呢?

从上面的代码中可以看到,只有在以下几种场景中,存在 alivefalse ,使得 goroutine 退出的可能:

  • request 或者 respone 任何一方请求关闭连接,或者收到 1xx 的状态码。
  • body 被读取完毕或者 body 被关闭。
  • request 主动 cancel
  • requestcontext Done 状态为 true
  • 当前连接被关闭。

这里来分析一下第二种场景(body 被读取完毕或者 body 被关闭),其对应的代码段为:

case bodyEOF := <-waitForBodyRead:
    alive = alive &&
                bodyEOF &&
                !pc.sawEOF &&
                pc.wroteRequest() &&
                replaced && tryPutIdleConn(trace)

这里 alive 是否为 true 便取决于 bodyEOF 的值,bodyEOF 来源于一个通道 waitForBodyRead,这个通道的值则来自:

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
        },
    }

在上面的代码中,如果执行了 earlyCloseFn,那么 waitForBodyRead 就会输入 false,alive 也就会是 false,此时会退出 readLoop 这个循环。

如果执行了 fn,其中包括正常情况下 body 读完数据抛出 io.EOF 时的 case,此时 waitForBodyRead 通道输入时 truealive 也会是 true, 它会接着往下执行 tryPutIdleConn(trace),将 pconn 连接放回到空闲队列中等待新的请求,也就是该连接会被复用。

那接下来就是看看 earlyCloseFnfn 这两个方法分别是在什么场景下执行了。

还记得我们上面说的两种场景吗?再帮大家回忆一下,要使连接能够被回收复用,有两个方式是可以做到的:

  1. 把 rsp body 的内容全部读取出来;
  2. 执行 body 的 Close 操作。

首先来看看读取 rsp body 的场景:

body, err := ioutil.ReadAll(rsp.Body)

func ReadAll(r io.Reader) ([]byte, error) {
    return io.ReadAll(r)
}

func ReadAll(r Reader) ([]byte, error) {
    //...
    n, err := r.Read(b[len(b):cap(b)])
       //...
}

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
        }
        // 这里会有一个 io.EOF 的报错,因为 ReadAll 读取完了全部的数据
        err = es.condfn(err)
    }
    return
}

func (es *bodyEOFSignal) condfn(err error) error {
    if es.fn == nil {
        return err
    }
    // 这里就执行了 fn
    err = es.fn(err)
    es.fn = nil
    return err
}

可以看到,在最后读取完毕之后,会执行 fn ,而这里的入参 err 因为已经读取完了所有内容,所以是一个 io.EOF,所以此时 waitForBodyRead 输入 true,alive 继续会 true,此时 readLoop 继续存在,并不会退出,此时会执行 tryPutIdleConn(trace) 把连接放回池子里复用。

再看看 Close 的场景:

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)
}

可以看到,如果在读取到了非 io.EOF 错误时(也就是 rsp.Body 的内容没有被读取完毕的场景下)会执行会执行 earlyCloseFn,此时 waitForBodyRead 会输入 false,那么 alive 就会是 false 从而使得 readLoop 退出。

而如果 body 内容已经被读取完毕,则同样调用 fn ,此时连接不会退出,但是可以在下次有需要新连接时被复用。

到了这里,再回顾一下上面 raedLoop 退出的几个条件:

case bodyEOF := <-waitForBodyRead:
            replaced := pc.t.replaceReqCanceler(rc.cancelKey, nil) // before pc might return to idle pool
            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
        }
  • request 或者 respone 任何一方请求关闭连接,或者收到 1xx 的状态码。
  • body 被读取完毕或者 body 被关闭。
  • request 主动 cancel
  • requestcontext Done 状态为 true
  • 当前连接被关闭。

所以,经过上面花里胡哨的分析,可以知道,如果一个请求拿到响应后,既没有读取 body 的所有内容,也没有对 body 执行 Close 操作,将导致两个 goroutine 一直没法被回收,造成内存泄漏。

而如果读取了 body 的所有内容或者把 body close 掉,则可以使连接得以释放复用。

当然,这两种场景导致的结果也不相同。对于只读取 body 的场景,连接并不会被释放掉,而是作为空闲链接等待下一次的复用。而对于关闭 body 的场景,则分为两种情况,当 body 内容被读取完毕时,连接能够进行复用;而当 body 内容没有被读取完毕时,连接会被释放掉,下一次请求会重新建立连接。

理论实践相结合

前面逼逼赖赖说了那么多,但是事实到底是不是如此呢?

都说实践是检验真理的唯一标准,那么下面我们就来验证一下上面的观点。

正常读取和关闭

func main() {
    num := 10
    for index := 0; index < num; index++ {
        rsp, err := http.Get("https://www.baidu.com")
        if err != nil {
            continue
        }
        io.Copy(ioutil.Discard, rsp.Body)
        rsp.Body.Close()
    }
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
    time.Sleep(time.Second * 90) // 一段时间连接空闲,自动关闭,默认值是 90s
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
}

执行结果:

此时goroutine个数= 3
此时goroutine个数= 1

只读取不关闭

func main() {
    num := 10
    for index := 0; index < num; index++ {
        rsp, err := http.Get("https://www.baidu.com")
        if err != nil {
            continue
        }
        io.Copy(ioutil.Discard, rsp.Body)
        //rsp.Body.Close()
    }
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
    time.Sleep(time.Second * 10) // 一段时间连接空闲,看看连接是否释放
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
    time.Sleep(time.Second * 90) // 一段时间连接空闲,自动关闭,默认值是 90s
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
}

执行结果:

此时goroutine个数= 3
此时goroutine个数= 3
此时goroutine个数= 1

只关闭不读取

func main() {
    num := 10
    for index := 0; index < num; index++ {
        rsp, err := http.Get("https://www.baidu.com")
        if err != nil {
            continue
        }
        //io.Copy(ioutil.Discard, rsp.Body)
        rsp.Body.Close()
    }
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
    time.Sleep(time.Second * 10) // 一段时间连接空闲,看看连接是否关闭
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
    time.Sleep(time.Second * 90) // 一段时间连接空闲,自动关闭,默认值是 90s
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
}

执行结果:

此时goroutine个数= 3
此时goroutine个数= 1
此时goroutine个数= 1

既不关闭也不读取

func main() {
    num := 10
    for index := 0; index < num; index++ {
        _, err := http.Get("https://www.baidu.com")
        if err != nil {
            continue
        }
        //io.Copy(ioutil.Discard, rsp.Body)
        //rsp.Body.Close()
    }
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
    time.Sleep(time.Second * 10) // 一段时间连接空闲,看看连接是否关闭
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
    time.Sleep(time.Second * 90) // 一段时间连接空闲,自动关闭,默认值是 90s
    fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
}

执行结果:

此时goroutine个数= 21
此时goroutine个数= 21
此时goroutine个数= 21

可以看到,在最后一种场景下,会造成 goroutine 的泄漏。

综上,我们日常在写代码的过程中,对于需要执行 HTTP 请求的场景,一定记得 resp.Body.Close,这也是一个良好的代码习惯:

res, err := http.Get(url)
if err != nil {
    log.Printf("Error: %s\n", err)
    return
}
defer res.Body.Close()

多提一些题外话,在网上查看的时候,发现有很多博客上认为应该采用下面的写法:

res, err := http.Get(url)
defer func() {
   if res != nil && res.Body != nil {
      res.Body.Close()
   }
}()
if err != nil {
    log.Printf("Error: %s\n", err)
    return
}

上面的代码为什么不是在 err != nil 的场景下才执行 Close 呢?原因是在于存在一种特殊的场景:当请求得到一个重定向的错误时,此时返回的 body 内容并非空的。

初看上面的代码总感觉很奇怪,而且感觉这似乎不属于 Golang 的风格,于是去搜刮了一下 Golang 的官方仓库,发现之前确实存在这种问题,但是在后续的版本其实已经做了处理。

现有的版本下,如果出现重定向的错误,其返回的 body 虽然不为空,但是其实已经对其进行了 Close 操作,这一步主要在上面的 do 方法中执行:

func (c *Client) do(req *Request) (retres *Response, reterr error) {
      //...
  
            // Sentinel error to let users select the
            // previous response, without closing its
            // body. See Issue 10069.
            if err == ErrUseLastResponse {
                return resp, nil
            }

            // Close the previous response's body. But
            // read at least some of the body so if it's
            // small the underlying TCP connection will be
            // re-used. No need to check for errors: if it
            // fails, the Transport won't reuse it anyway.
            const maxBodySlurpSize = 2 << 10
            if resp.ContentLength == -1 || resp.ContentLength <= maxBodySlurpSize {
                io.CopyN(io.Discard, resp.Body, maxBodySlurpSize)
            }
            resp.Body.Close()

            if err != nil {
                // Special case for Go 1 compatibility: return both the response
                // and an error if the CheckRedirect function failed.
                // See https://golang.org/issue/3795
                // The resp.Body has already been closed.
                ue := uerr(err)
                ue.(*url.Error).URL = loc
                return resp, ue
            }
      // ...
}

有兴趣的可以看看下面的两个 Issue 和 commit 记录:

后记

通过这次内存泄漏的排查,也学到不少新技能,从发现问题,到问题分析,定位问题,再到源码了解,一步一步走过来,积累了很多知识,也很感谢组里大佬们的帮助,让自己从中学到了很多实用的经验。

以上分析内容如果有不正确的地方,欢迎各位大佬们批评指正!下次线上问题,再会!(希望没有下次了~)

给大家丢脸了,用了三年golang,我还是没答对这道内存泄漏题

i/o timeout , 希望你不要踩到这个net/http包的坑

go语言设计与实现