net-http-transport 引发的句柄数(协程)泄漏问题

Reference

最近在工作中遇到一个句柄数泄漏的问题,在排查的过程中学习了很多关于 net/http 源码库的一些使用和原理

问题描述

背景:一个负责告警规则判断的服务,vmAlert,主要流程是根据用户配置的规则,查询对应的指标是否满足告警的阈值条件,从而进行告警

现象:vmAlert,在执行一段时间后,提示panic掉了,提示" too many open file " 相关的错误

进程数:

分析来看,vmalert 在频繁地与 某个服务 建立 TCP 连接,并且频繁地打开和关闭 socket 套接字,由于建立连接的操作与关闭连接的操作频率不 一致,从而使得连接数持续增加,最后超过 ulimit -Sn 和 ulimit -Hn 65535 的限制 [[Linux 的最大TCP连接数]];大胆猜测可能是任意一个HTTP请求的问题

排查过程

从现象看本质,HTTP 请求本质上是建立一个TCP连接,每建立一个TCP连接,则需要打开对应的文件,而 fd 则是控制这些文件的一个数据结构,fd 泄漏了,则说明几个可能性:

  • 某个地方连接未被释放
  • 打开fd的速度远远快于释放的速度

从这个思路排查,找一下服务里面调用外部的HTTP请求

  1. 检查 vm 的相关请求的连接和代码 使用的自定义的连接池,并且连接都正常建立连接,看起来没啥问题
  2. 检查心跳接口相关的连接和代码

在 vmalert 运行了一段时间后,通过 lsof -p [进程号] 查看其打开的文件连接,发现 vmselect 的TCP连接数较为稳定,连接数与 vmalert 的在执行的 组数基本是相同的 检查发现存在大量的 sock 连接,HTTP请求会建立TCP连接,从而打开socket进行读写,从这个角度去排查;

检视 心跳相关的代码,每30s执行一次,使用timer.ticker 进行定时调用,错误也处理了,一个简单的post心跳上报请求; resp 也不读取,直接 _ 进行忽略即可,也不用close调,看起来没啥问题

在 lsof -p [进程号] 中偶然发现与一个后端服务Backend存在 CLOSE_WAIT 的连接,并且 src port 也不一样,也就是意味着心跳接口的 TCP 连接在重建?

大胆猜测,http.Post 这个调用没有使用到长连接? 减少interval时间为1s,执行10m,再次 lsof 看下,发现存在大量的 sock 连接,整体打开的fd数上涨到了 711

并且出现了大量的 close_wait 连接

基本就能定位到是这个段代码的问题了,线上增长缓慢是因为interval为30s,fd会慢增加; 查看容器配置的最大可打开文件数为 65535,超过就会进行报错;

回看心跳的代码,只是使用了http.Post 发送一个简单的上报心跳请求,为什么会导致句柄泄漏的问题呢?难道每个HTTP请求建立了新的TCP连接吗?看下 net/http 的代码 [[net-http-transport]]

go 复制代码
// DefaultClient is the default [Client] and is used by [Get], [Head], and [Post].
var DefaultClient = &Client{}

func Post(url, contentType string, body io.Reader) (resp *Response, err error) { 
   return DefaultClient.Post(url, contentType, body)  
}

func (c *Client) Post(url, contentType string, body io.Reader) (resp *Response, err error) {  
   req, err := NewRequest("POST", url, body)  
   if err != nil {  
      return nil, err  
   }  
   req.Header.Set("Content-Type", contentType)  
   return c.Do(req)  
}

func (c *Client) do(req *Request) (retres *Response, reterr error) {
 // ... 省略不关键的部分
 if resp, didTimeout, err = c.send(req, deadline); err != nil {  
   // c.send() always closes req.Body  
   reqBodyClosed = true  
   if !deadline.IsZero() && didTimeout() {  
      err = &timeoutError{err.Error() + " (Client.Timeout exceeded while awaiting headers)"}  
   }  
   return nil, uerr(err)  
 }
}

// didTimeout is non-nil only if err != nil.  
func (c *Client) send(req *Request, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {  
   if c.Jar != nil {  
      for _, cookie := range c.Jar.Cookies(req.URL) {  
         req.AddCookie(cookie)  
      }  
   }  
   resp, didTimeout, err = send(req, c.transport(), deadline)  
   if err != nil {  
      return nil, didTimeout, err  
   }  
   if c.Jar != nil {  
      if rc := resp.Cookies(); len(rc) > 0 {  
         c.Jar.SetCookies(req.URL, rc)  
      }  
   }  
   return resp, nil, nil  
}

func (c *Client) transport() RoundTripper {  
   if c.Transport != nil {  
      return c.Transport  
   }  
   return DefaultTransport  
}

// DefaultTransport is the default implementation of [Transport] and is// used by [DefaultClient]. It establishes network connections as needed  
// and caches them for reuse by subsequent calls. It uses HTTP proxies  
// as directed by the environment variables HTTP_PROXY, HTTPS_PROXY  
// and NO_PROXY (or the lowercase versions thereof).  
var DefaultTransport RoundTripper = &Transport{  
   Proxy: ProxyFromEnvironment,  
   DialContext: defaultTransportDialContext(&net.Dialer{  
      Timeout:   30 * time.Second,  
      KeepAlive: 30 * time.Second,  
   }),  
   ForceAttemptHTTP2:     true,  
   MaxIdleConns:          100,  
   IdleConnTimeout:       90 * time.Second,  
   TLSHandshakeTimeout:   10 * time.Second,  
   ExpectContinueTimeout: 1 * time.Second,  
}

可以看到,http.Post的方法,使用了默认的连接池,也就是会有TCP连接被 reuse 的,但从实际的情况(大量的close_wait)来看,又没复用连接,为什 么呢? 现在能大概猜测是没复用到长连接的问题,所以修改一下代码,使用短连接 connection为close看一下效果;

改完跑一段时间, lsof -p [进程号] 发现很正常,都是与 vmselect 建立的 ESTABLISH 连接,并且连接数固定在 50 左右;

实在是没什么思路了,范围就能确定是长连接没复用,导致不断建立新的TCP连接,导致socket被不断打开的问题,TCP 连接建立的速度比关闭的速度要快导致的;把心跳的代码丢到gpt问一下,一下子发现了新世界,重点: "未关闭响应体可能导致fd泄漏"

?!没有使用到 resp 进行 io.Read 也需要关闭吗?这个时候就得 google 一下了,果然,找到了大佬们的一些解答

TIL: Go Response Body be MUST closed, even if you don't read it

https://manishrjain.com/must-close-golang-http-response

文章中提到了几个点:

  • net/http 源码包中的 response.body 的描述,如果上一个body没有被读取完毕并且close掉,则这个 TCP 连接,不会被 reuse
  • 永远不要使用 HTTP.GET 以及 HTTP.DefaultClient,而应该使用自己创建的 httpclient
  • 无论是否需要(处理Response),都必须始终读Body并将其关闭

http.Response.Body的注释:

The http Client and Transport guarantee that Body is always non-nil, even on responses without a body or responses with a zero-length body. It is the caller's responsibility to close Body. The default HTTP client's Transport may not reuse HTTP/1.x "keep-alive" TCP connections if the Body is not read to completion and closed.

上述三点的一些个人理解:

  • 第一点官方已经给出说明了,如果你不读取完body并且关闭,则下一次请求过来,tcp 会认为上一个请求还未结束,则不会resue 这个连接,更详细的可以继续往下看根因分析
  • 第二点则是因为,使用这些方法时,defaultHttpClient 初始化未 var DefaultClient = &Client{},默认的 timeout 为 zero,即没有过期时间,这样客户端就不会主动去关闭这个连接,完全交给服务端决定是否断开,这样只要故障服务器决定等待,它们就会继续挂起。由于 API 调用是为了满足用户请求,这会导致满足用户请求的 goroutine 也挂起。一旦有足够多的调用,则应用程序就会崩溃;
  • 第三点,则是一个建议,但实际上有一定的隐患,可以参考这篇文章;关于 Golang 中 http.Response.Body 未读取导致连接复用问题的一点研究,主要提到了当网络出现阻塞时,读取body也会被阻塞,需要看实际的使用场景,如果你不需要resp的内容,不建议读,
  • 额外提一嘴
    • 如果你只进行 defer close,而不读取 resp,则tcp连接也不会复用
    • 如果你只进行读取 resp,而不 close 连接,则 tcp 连接可以复用(具体原因,可以看后续根因分析)

找到原因后,修改代码进行测试,发现 src port一直不变,说明复用的是同一个tcp连接

按照实际场景测试,30s触发一次心跳,发现tcp连接又不复用了,调整为5s又复用了,再次调整为 10s,发现有时候复用有时候不复用? 猜测是跟客户端的连接时间有关,检查一下客户端的初始化代码。检查了 transport 的idleconnTime 为 90s,也没啥问题,只能抓包看是哪边把连接关了,发现服务端 Backend 给 vmalert 发送了一个 FIN 关闭连接的报文,证明:是服务端主动关闭的连接

检查服务端的代码,发现设置了http的read和write的timeout为 10s, 破案

深挖根因

大量 fd 的根因

到底是net-http中哪一段代码导致的fd泄漏呢?这个问题还是不知道,我们继续深挖一下

在复现大量 close_wait 请求后,我们通过 pprof 看下服务的一些信息,主要看 top,trace,list

bash 复制代码
[root@vmalert-84cf77d57c-dwqzv nocalhost-dev]# go tool pprof http://0.0.0.0:xxxx/debug/pprof/goroutine
File: vmalert
Type: goroutine
Time: Jan 6, 2025 at 12:01pm (UTC)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 5474, 99.91% of 5479 total
Dropped 107 nodes (cum <= 27)
Showing top 10 nodes out of 29
      flat  flat%   sum%        cum   cum%
      5474 99.91% 99.91%       5474 99.91%  runtime.gopark
         0     0% 99.91%         77  1.41%  bufio.(*Reader).Peek
         0     0% 99.91%         77  1.41%  bufio.(*Reader).fill
         0     0% 99.91%         80  1.46%  internal/poll.(*FD).Read
         0     0% 99.91%         82  1.50%  internal/poll.(*pollDesc).wait
         0     0% 99.91%         82  1.50%  internal/poll.(*pollDesc).waitRead (inline)
         0     0% 99.91%         82  1.50%  internal/poll.runtime_pollWait
         0     0% 99.91%         79  1.44%  main.(*AlertingRule).Exec
         0     0% 99.91%        170  3.10%  main.(*Group).start
         0     0% 99.91%         79  1.44%  main.(*Group).start.func2


(pprof) traces
File: vmalert
Type: goroutine
Time: Jan 6, 2025 at 11:52am (UTC)
-----------+-------------------------------------------------------
      2144   runtime.gopark
             runtime.selectgo
             net/http.(*persistConn).writeLoop
-----------+-------------------------------------------------------
      2060   runtime.gopark
             runtime.selectgo
             net/http.(*persistConn).readLoop

(pprof) list writeLoop
Total: 4486
ROUTINE ======================== net/http.(*persistConn).writeLoop in /usr/local/go/src/net/http/transport.go
         0       2144 (flat, cum) 47.79% of Total
         .          .   2516:func (pc *persistConn) writeLoop() {
         .          .   2517:   defer close(pc.writeLoopDone)
         .          .   2518:   for {
         .       2144   2519:           select {
         .          .   2520:           case wr := <-pc.writech:
         .          .   2521:                   startBytesWritten := pc.nwrite
         .          .   2522:                   err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))
         .          .   2523:                   if bre, ok := err.(requestBodyReadError); ok {
         .          .   2524:                           err = bre.error
         
(pprof) list readLoop
Total: 5479
         .          .   2322:           // Before looping back to the top of this function and peeking on
         .          .   2323:           // the bufio.Reader, wait for the caller goroutine to finish
         .          .   2324:           // reading the response body. (or for cancellation or death)
         .       2562   2325:           select {
         .          .   2326:           case bodyEOF := <-waitForBodyRead:
         .          .   2327:                   alive = alive &&
         .          .   2328:                           bodyEOF &&
         .          .   2329:                           !pc.sawEOF &&
         .          .   2330:                           pc.wroteRequest() &&
         .          .   2331:                           tryPutIdleConn(rc.treq)
         .          .   2332:                   if bodyEOF {
         .          .   2333:                           eofc <- struct{}{}
         .          .   2334:                   }
         .          1   2335:           case <-rc.treq.ctx.Done():
         .          .   2336:                   alive = false
         .          .   2337:                   pc.cancelRequest(context.Cause(rc.treq.ctx))
         .          .   2338:           case <-pc.closech:
         .          .   2339:                   alive = false
         .          .   2340:           }
... # 后面省略了

可以得到以下信息:

top命令

  • 总计:1450 个 goroutine,占总 goroutine 数量的 99.86%
  • 主要函数:
    • runtime.gopark:1450 次,几乎占据了所有的 goroutine
    • 其他函数如 bufio.(*Reader).Peek、internal/poll.(*FD).Read 等也有一定的调用次数,但相对较少

分析:

  • runtime.gopark:这个函数用于将当前 goroutine 暂停,直到某个条件满足。它通常在等待某些事件(如 I/O 操作、锁等)时被调用
  • bufio.(*Reader).Peek 和 bufio.(*Reader).fill:这些函数与缓冲 I/O 操作有关,表明有 goroutine 正在等待读取数据
  • internal/poll:这些函数与网络 I/O 操作有关,表明有 goroutine 正在等待网络连接的读操作

trace 命令

  • net/http.(*persistConn).writeLoop 和 net/http.(*persistConn).readLoop
  • 在协程暂停之前,最多调用就是这两个函数

list 命令

  • 可以看到具体是在哪个地方阻塞住了

那么 writeLoop 和 readLoop 到底是干嘛的呢?有兴趣详细了解可以参考这篇文章 [[net-http-transport]],下面就简单说一下,附带一个流程图

  • http 请求本质还是去发起一个 tcp 连接,net-http库通过transport来实现http请求的过程,通过一个请求池来控制空闲连接数量,transport 会尝试去申请一个 persistConn 持久连接,persistConn 负责控制读写过程
  • 如果存在空闲TCP连接则直接复用,否则通过 net-dial 重新请求一个连接;每个 persistConn 都会启动两个协程,也就是writeLoop 和 readLoop
    • persistConn 发送 request 信号,writeLoop 监听 writech 读取 request 并通过 bufio.Writer 写入 bw,也就是真正发送请求,服务端处理完,会把结果写回到 br
    • persistConn 与 readLoop 是通过 reqch 传递 request,readLoop 不断从br.Peek 检查是否有数据,有则读取然后写入到 respAndErr.ch ,persistConn 则进行最后的返回 resp

按照我们上面的 list 分析,我们可以知道具体是哪行代码阻塞了

readLoop 阻塞根因

先看下readLoop,可以发现其阻塞在了 waitForBodyRead;

go的select用法,在没有default的case情况下,会一直阻塞,直到满足某个case;

通过其注释我们能知道,readLoop退出的几个条件:

  • body 读取完毕
  • request 主动 cancel
  • request context Done 状态 true
  • 当前的 persistConn 关闭
go 复制代码
// 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 {
         .          .   2326:           case bodyEOF := <-waitForBodyRead:
 }

继续看下 waitForBodyRead 是哪里传输的,发现是body中的一个回调函数 earlyCloseFn,这个函数又是bodyEOFSignal 这个结构体的变量,从注释可知, bodyEOFSignal作用是确保在读取到响应体的末尾EOF之前,不会继续在连接上进行其他读取操作 我们继续溯源

go 复制代码
waitForBodyRead := make(chan bool, 2)

// bodyEOFSignal is used by the HTTP/1 transport when reading response
// bodies to make sure we see the end of a response body before
// proceeding and reading on the connection again.
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
    },
}
...


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

Close 方法存在太多引用了,无法通过idea直接查询,但是我们发现 resp 会被 bodyEOFSingal 包裹一层,这个Close是 resp.body 的,也就是调用 resp.body.Close() 肯定能触发这段逻辑;详细看下 earlyCloseFn 这个函数用来干嘛的,从注释知道,这个函数就是在未读到EOF之前,也可通过这个channel信号,提早关闭conn,结合 readLoop 的阻塞逻辑,可以分析出,这里就是 resp.body.Close 关闭操作前的处理,通知 readLoop ,我读取完了,你继续执行吧

go 复制代码
earlyCloseFn // optional alt Close func used if io.EOF not seen

// readLoop 
2326:           case bodyEOF := <-waitForBodyRead:

并且我们还看到另外一个channel变量,eofc,注释中介绍,这个变量用于阻塞caller协程(也就是close函数),读取到EOF结束符时,进行阻塞,知道连接放回到空闲连接池中,这也是httpClient维护连接池的一些处理逻辑;

go 复制代码
// eofc is used to block caller goroutines reading from Response.Body
// at EOF until this goroutines has (potentially) added the connection
// back to the idle pool.
eofc := make(chan struct{})
defer close(eofc) // unblock reader on errors

// close 回调函数
earlyCloseFn: func() error {
    waitForBodyRead <- false
    <-eofc // will be closed by deferred call at the end of the function
    return nil
}

// readLopp 循环逻辑
select {
    case bodyEOF := <-waitForBodyRead:
        alive = alive &&
                bodyEOF &&
                !pc.sawEOF &&
                pc.wroteRequest() &&
                tryPutIdleConn(rc.treq)
        if bodyEOF {
            eofc <- struct{}{}
        }
    case <-rc.treq.ctx.Done():
    	alive = false
    	pc.cancelRequest(context.Cause(rc.treq.ctx))
    case <-pc.closech:
    	alive = false
}

rc.treq.cancel(errRequestDone)

所以,resp.body.close 会给watiForBodyRead 发送 false 值,readLoop 接收到后alive 的判断就为false,从而跳出了 readLoop 的 for alive {} 循环,这个函数退出前会进行defer ,pc.Close 则最终会调用 net.conn.close 将 fd 打开的 fd 给关掉

go 复制代码
defer func() {
    pc.close(closeErr)
    pc.t.removeIdleConn(pc)
}()

// Close closes the connection.
func (c *conn) Close() error {
	if !c.ok() {
		return syscall.EINVAL
	}
	err := c.fd.Close()
	if err != nil {
		err = &OpError{Op: "close", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
	}
	return err
}

所以,当我们没有调用 resp.body.Close 时,readLoop 就会一直阻塞住,那为什么不会走到 rc.treq.ctx.Done或者closech呢,因为 rc.treq.cancel(errRequestDone) 在select 之后,另外一个调用的地方则是其他情况,不做讨论;而 closech 则是 conn关闭时才会走到;因此,我们得出结论:

如果不主动调用 resp.body.Close() 则 readLoop 会阻塞在 waitForBodyRead

总结:

  • 我们通过分析 readLoop 的各个退出条件,分析出 resp.body.Close 会回调 earlyCloseFn 函数,从而发送 false 给 waitForBodyRead channel,使得 alive 为 false 退出循环,结束协程

OK,这样我们解决一个协程泄漏的问题了,接下来看第二个 writeLoop

writeLoop 阻塞根因

其实很简单,看下代码,会发现writeLoop的退出条件基本都满足,要么写错误退出,要么等待pconn连接关闭,所以大量的pconn就存在大量的协程泄漏

go 复制代码
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 bre, ok := err.(requestBodyReadError); ok {  
            err = bre.error  
            // Errors reading from the user's  
            // Request.Body are high priority.            // Set it here before sending on the            // channels below or calling            // pc.close() which tears down            // connections and causes other            // errors.            wr.req.setError(err)  
         }  
         if err == nil {  
            err = pc.bw.Flush()  
         }  
         if err != nil {  
            if pc.nwrite == startBytesWritten {  
               err = nothingWrittenError{err}  
            }  
         }  
         pc.writeErrCh <- err // to the body reader, which might recycle us  
         wr.ch <- err         // to the roundTrip function  
         if err != nil {  
            pc.close(err)  
            return  
         }  
      case <-pc.closech:  
         return  
      }  
   }  
}

连接为什么无法复用?

前面我们有提到过,为什么我们不读取 body,连接就无法复用?我们看下 io.copy 干了啥,通过debug 调用下去,看下调用链路;

go 复制代码
// Copy copies from src to dst until either EOF is reached
// on src or an error occurs. It returns the number of bytes
// copied and the first error encountered while copying, if any.
_, _ = io.Copy(ioutil.Discard, resp.Body)

发现就是把 resp.body 读取到某个地方;过程汇总调用到了 net.http.body 这个结构体的 readLocked

go 复制代码
// Must hold b.mu.
func (b *body) readLocked(p []byte) (n int, err error) {
	if b.sawEOF {
		return 0, io.EOF
	}
	n, err = b.src.Read(p)

	if err == io.EOF {
		// ... 省略,这个是正常读取到结束符的情况
	}

	// If we can return an EOF here along with the read data, do
	// so. This is optional per the io.Reader contract, but doing
	// so helps the HTTP transport code recycle its connection
	// earlier (since it will see this EOF itself), even if the
	// client doesn't do future reads or Close.
	if err == nil && n > 0 {
		if lr, ok := b.src.(*io.LimitedReader); ok && lr.N == 0 {
            // 最终会执行到这一步,提前结束
			err = io.EOF
			b.sawEOF = true
		}
	}

	if b.sawEOF && b.onHitEOF != nil {
		b.onHitEOF()
	}

	return n, err
}

根据这个注释,我们可以知道这个 sawEOF 就是提早返回 EOF 以及读取的数据的标志,并且返回了err为io.EOF;按照这个错误,回溯到 bodyEOSignal 的 Read函数,可以发现如果错误了,会调用 condfn,这个fn就是之前出现过的,readLoop中的resp.body的初始化的fn

go 复制代码
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
}

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

这个回调函数,还是给 waitForBodyRead 赋值,但这次是 true,因为 err 等于 io.EOF

go 复制代码
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
},

ok 又回到 readLoop 的select,可以发现通过 io.Copy 读取完,bodyEOF 的值为 true,这样alive 就会继续往下执行,直到调用 tryPutIdleConn

go 复制代码
// readLopp 循环逻辑
select {
    case bodyEOF := <-waitForBodyRead:
        alive = alive &&
                bodyEOF &&
                !pc.sawEOF &&
                pc.wroteRequest() &&
                tryPutIdleConn(rc.treq)
        if bodyEOF {
            eofc <- struct{}{}
        }
    case <-rc.treq.ctx.Done():
    	alive = false
    	pc.cancelRequest(context.Cause(rc.treq.ctx))
    case <-pc.closech:
    	alive = false
}

tryPutIdleConn 就是尝试把连接 persistConn 放回 Idle 队列中,让下一个 http 请求过来能直接拿到现有的persistConn,具体是怎么做的,这里不细讲了;

总结:

  • io.Copy 将 resp.body 读取出来,然后它发现能提前返回结果,则进行返回,或者读取完发现确实是EOF,结束了,也返回 err 为 io.EOF
  • 对于返回的io.EOF,bodyEOFSignal 这个结构体识别到了则开始回调fn(正常结束的回调函数),而前面介绍过 earlyClosefn,则是提前结束的回调函数(通过resp.body.close调用触发的)
  • io.EOF 的回调函数,则是通过 waitForBodyRead 这个channel 与 readLoop 交互,让其触发 tryPutIdleConn 操作,最终将 persistConn 放回 空闲连接队列中,达到下一个http请求能直接复用的效果

这就是为什么我们不调用 io.Copy 或者 io.ReadAll 将 resp.body 读取完时,连接无法被复用的根因,因为根因就没触发 tryPutIdleConn 呀~

随带提一下,resp.body.Close 也会调用 io.Copy 消费 body后,才去关闭连接

go 复制代码
_, err = io.Copy(io.Discard, bodyLocked{b})
相关推荐
网安情报局1 小时前
除了 CDN,DDoS 攻击还有哪些更有效的防护方式?
网络
Promise微笑2 小时前
2026年国产替代油介损测试仪:油介损全场景解决方案与技术演进
大数据·网络·人工智能
会编程的土豆2 小时前
洛谷题单入门1 顺序结构
数据结构·算法·golang
AnalogElectronic4 小时前
linux 测试网络和端口是否连通的命令详解
linux·网络·php
米高梅狮子5 小时前
08.CronJob和Service
云原生·容器·架构·kubernetes·自动化
Rust研习社5 小时前
使用 Axum 构建高性能异步 Web 服务
开发语言·前端·网络·后端·http·rust
灰子学技术5 小时前
Envoy HTTP 流量层面的 Metric 指标分析
网络·网络协议·http
上海云盾-小余5 小时前
海外恶意 UDP 攻击溯源:分层封禁策略与业务兼容平衡方案
网络·网络协议·udp
智慧光迅AINOPOL5 小时前
校园全光网建设指南:从架构到调优,打造稳定高体验校园网络
网络·全光网解决方案·全光网·酒店全光解决方案·泛住宿全光网解决方案
被摘下的星星6 小时前
Internet 的域名系统:从“名字”到“地址”的翻译官
网络