使用golang的`http.Client`容易出现TIME_WAIT上涨的几种情况和解决方案

golang推广大使 · · 5538 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

在go语言开发中,使用net/http包的Client时我们容易遇到TIME_WAIT连接数过多的情况,本文列出这几种情况并提出解决方案。
本文代码基于go1.13.17,但是TIME_WAIT连接数过多的情况在多个go版本中都存在

出现大量TIME_WAIT的几种情形

情形1:由于忘记读取响应的body导致创建大量处于TIME_WAIT状态的连接

在linux下运行下面的代码:

package main

import (
  "fmt"
  "html"
  "log"
  "net"
  "net/http"
  "time"
)

func startWebserver() {

  http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
      fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
  })

  go http.ListenAndServe(":8080", nil)

}

func startLoadTest() {
  count := 0
  for {
      resp, err := http.Get("http://localhost:8080/")
      if err != nil {
          panic(fmt.Sprintf("Got error: %v", err))
      }
      resp.Body.Close()
      log.Printf("Finished GET request #%v", count)
      count += 1
  }

}

func main() {


  startWebserver()

  startLoadTest()

}

在程序运行时另外开一个终端运行下面的命令:

netstat -n | grep -i 8080 | grep -i time_wait | wc -l

你会看到TIME_WAIT数量在持续增长

root@myhost:/# netstat -n | grep -i 8080 | grep -i time_wait | wc -l
166
root@myhost:/# netstat -n | grep -i 8080 | grep -i time_wait | wc -l
231
root@myhost:/# netstat -n | grep -i 8080 | grep -i time_wait | wc -l
293
root@myhost:/# netstat -n | grep -i 8080 | grep -i time_wait | wc -l
349

解决办法: 读取响应的body
更改startLoadTest()函数,添加下面的代码:

func startLoadTest() {
  for {
          ...
      if err != nil {
          panic(fmt.Sprintf("Got error: %v", err))
      }
      io.Copy(ioutil.Discard, resp.Body)  // <-- add this line
      resp.Body.Close()
                ...
  }

}

现在再次运行netstat -n | grep -i 8080 | grep -i time_wait | wc -l,你会发现TIME_WAIT状态的连接数为0

情形2:连接的数量超过连接池的限制导致出现大量TIME_WAIT状态的连接

这种情况时由于持续超过连接池导致许多短链接被打开。
请看下面的代码:

package main

import (
  "fmt"
  "html"
  "io"
  "io/ioutil"
  "log"
  "net/http"
  "time"
)

func startWebserver() {

  http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {

      time.Sleep(time.Millisecond * 50)

      fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
  })

  go http.ListenAndServe(":8080", nil)

}

func startLoadTest() {
  count := 0
  for {
      resp, err := http.Get("http://localhost:8080/")
      if err != nil {
          panic(fmt.Sprintf("Got error: %v", err))
      }
      io.Copy(ioutil.Discard, resp.Body)
      resp.Body.Close()
      log.Printf("Finished GET request #%v", count)
      count += 1
  }

}

func main() {

  // start a webserver in a goroutine
  startWebserver()

  for i := 0; i < 100; i++ {
      go startLoadTest()
  }

  time.Sleep(time.Second * 2400)

}

在另外一个终端运行netstat,尽管响应已经被读取,TIME_WAIT的连接数还是持续增加

root@ myhost:/# netstat -n | grep -i 8080 | grep -i time_wait | wc -l
166
root@ myhost:/# netstat -n | grep -i 8080 | grep -i time_wait | wc -l
231
root@ myhost:/# netstat -n | grep -i 8080 | grep -i time_wait | wc -l
293
root@ myhost:/# netstat -n | grep -i 8080 | grep -i time_wait | wc -l
349

什么是TIME_WAIT状态呢?

就是当我们创建大量短链接时,linux内核的网络栈保持连接处于TIME_WAIT状态,以避免某些问题。
例如:避免来自一个关闭的连接延迟的包被后来的连接所接收。并发连接被用地址,端口,序列号等其他机制所隔离开。

为什么又这么多的TIME_WAIT端口?

默认情况下,Golang的http client会做连接池。他会在完成一个连接请求后把连接加到一个空闲的连接池中。如果你想在这个连接空闲超时前发起另外一个http请求,它会复用现有的连接。
这会把总socket连接书保持的低一些,直到连接池满。如果连接池满了,它会创建一个新的连接来发起http请求。
那这个连接池又多大呢?看看transport.go:

var DefaultTransport RoundTripper = &Transport{
        ... 
  MaxIdleConns:          100,
  IdleConnTimeout:       90 * time.Second,
        ... 
}

// DefaultMaxIdleConnsPerHost is the default value of Transport's
// MaxIdleConnsPerHost.
const DefaultMaxIdleConnsPerHost = 2
  • MaxIdleConns:100 设置连接池的大小为100个连接
  • IdleConnTimeOut被设置为90秒,意味着一个连接在连接池里最多保持90秒的空闲时间,超过这个时间将会被移除并关闭
  • DefaultMaxIdleConnsPerHost = 2 这个设置意思时尽管整个连接池是100个连接,但是每个host只有2个。

上面的例子中有100个gooutine尝试并发的对同一个主机发起http请求,但是连接池只能存放两个连接。所以,第一轮完成请求时,2个连接保持打开状态。但是剩下的98个连接将会被关闭并进入TIME_WAIT状态。

因为这在一个循环中出现,所以会很快就积累上成千上万的TIME_WAIT状态的连接。最终,会耗尽主机的所有可用端口,从而导致无法打开新的连接。

修复: 增加http client的连接池大小

import (
     .. 
)

var myClient *http.Client

func startWebserver() {
      ... same code as before

}

func startLoadTest() {
        ... 
  for {
      resp, err := myClient.Get("http://localhost:8080/")  // <-- use a custom client with custom *http.Transport
                ... everything else is the same
  }

}


func main() {

  // Customize the Transport to have larger connection pool
  defaultRoundTripper := http.DefaultTransport
  defaultTransportPointer, ok := defaultRoundTripper.(*http.Transport)
  if !ok {
      panic(fmt.Sprintf("defaultRoundTripper not an *http.Transport"))
  }
  defaultTransport := *defaultTransportPointer // dereference it to get a copy of the struct that the pointer points to
  defaultTransport.MaxIdleConns = 100
  defaultTransport.MaxIdleConnsPerHost = 100

  myClient = &http.Client{Transport: &defaultTransport}

  // start a webserver in a goroutine
  startWebserver()

  for i := 0; i < 100; i++ {
      go startLoadTest()
  }

  time.Sleep(time.Second * 2400)

}

当然,如果你的并发要求高,可以把连接池的数量改的更大些。
但是这样没有根本解决问题,因为go的http.Client在连接池被占满并且所有连接都在被使用的时候会创建一个新的连接。
具体可以看代码,http.Client处理请求的核心在用它的transport获取一个连接:

// roundTrip implements a RoundTripper over HTTP.
func (t *Transport) roundTrip(req *Request) (*Response, error) {
    //...省略部分代码

        // Get the cached or newly-created connection to either the
        // host (for http or https), the http proxy, or the http proxy
        // pre-CONNECTed to https server. In any case, we'll be ready
        // to send it requests.
        pconn, err := t.getConn(treq, cm)  //看这里
        if err != nil {
            t.setReqCanceler(req, nil)
            req.closeBody()
            return nil, err
        }

        var resp *Response
        if pconn.alt != nil {
            // HTTP/2 path.
            t.setReqCanceler(req, nil) // not cancelable with CancelRequest
            resp, err = pconn.alt.RoundTrip(req)
        } else {
            resp, err = pconn.roundTrip(treq)
        }
        if err == nil {
            return resp, nil
        }
    //...省略部分代码

}

getConn方法的实现核心如下:

// getConn dials and creates a new persistConn to the target as
// specified in the connectMethod. This includes doing a proxy CONNECT
// and/or setting up TLS.  If this doesn't return an error, the persistConn
// is ready to write requests to.
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
    req := treq.Request
    trace := treq.trace
    ctx := req.Context()
    if trace != nil && trace.GetConn != nil {
        trace.GetConn(cm.addr())
    }

    w := &wantConn{
        cm:         cm,
        key:        cm.key(),
        ctx:        ctx,
        ready:      make(chan struct{}, 1),
        beforeDial: testHookPrePendingDial,
        afterDial:  testHookPostPendingDial,
    }
    defer func() {
        if err != nil {
            w.cancel(t, err)
        }
    }()

    // Queue for idle connection.
    if delivered := t.queueForIdleConn(w); delivered { //注意这一行代码,看函数名意思是在Idle连接队列里等待,如果执行成功就拿到一个连接,如果拿不到连接就跳过下面这部分代码
        pc := w.pc
        // Trace only for HTTP/1.
        // HTTP/2 calls trace.GotConn itself.
        if pc.alt == nil && trace != nil && trace.GotConn != nil {
            trace.GotConn(pc.gotIdleConnTrace(pc.idleAt))
        }
        // set request canceler to some non-nil function so we
        // can detect whether it was cleared between now and when
        // we enter roundTrip
        t.setReqCanceler(req, func(error) {})
        return pc, nil
    }

    cancelc := make(chan error, 1)
    t.setReqCanceler(req, func(err error) { cancelc <- err })

    // Queue for permission to dial.
    t.queueForDial(w) /拿不到连接就放入等待拨号的队列
    //...省略部分代码
}

我们再看queueForDial方法的实现:

// queueForDial queues w to wait for permission to begin dialing.
// Once w receives permission to dial, it will do so in a separate goroutine.
func (t *Transport) queueForDial(w *wantConn) {
    w.beforeDial()
    if t.MaxConnsPerHost <= 0 { //看这里,如果这个值小于等于0,就直接创建连接了,我们之前没有设置这个选项导致的
        go t.dialConnFor(w)
        return
    }

    t.connsPerHostMu.Lock()
    defer t.connsPerHostMu.Unlock()

    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
    }

    if t.connsPerHostWait == nil {
        t.connsPerHostWait = make(map[connectMethodKey]wantConnQueue)
    }
    q := t.connsPerHostWait[w.key]
    q.cleanFront()
    q.pushBack(w)
    t.connsPerHostWait[w.key] = q
}

https://github.com/golang/go/issues/13801


有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:golang推广大使

查看原文:使用golang的`http.Client`容易出现TIME_WAIT上涨的几种情况和解决方案

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

5538 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传