golang长连接的误用

误用一:忘记读取响应的body

由于忘记读取响应的body导致创建大量处于TIME_WAIT状态的连接(同时产生大量处于transport.go的readLoop和writeLoop的协程)

在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

误用二:空闲连接最大数量设置太小,实际连接数量超过连接池的限制

连接的数量超过连接池的限制导致出现大量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
}

误用三:没有重用同一个长连接的http.Transport对象

我们的一个服务是用Go写的,在测试的时候发现几个小时之后它就会core掉,而且core的时候没有打出任何堆栈信息,简单分析后发现该服务中的几个HTTP服务的连接数不断增长,而我们的开发机的fd limit只有1024,当该服务所属进程的连接数增长到系统的fd limit的时候,它被操作系统杀掉了。。。

    HTTP Connection中连接未被释放的问题在https://groups.google.com/forum/#!topic/golang-nuts/wliZf2_LUag和https://groups.google.com/forum/#!topic/golang-nuts/tACF6RxZ4GQ都有提到。

    这个服务中,我们会定期向一个HTTP服务器发起POST请求,因为请求非常不频繁,所以想采用短连接的方式去做。请求代码大概长这样:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

func dialTimeout(network, addr string) (net.Conn, error) {

    return net.DialTimeout(network, addr, time.Second*POST_REMOTE_TIMEOUT)

}

 

func DoRequest(URL string) xx, error {

       transport := http.Transport{

                Dial:              dialTimeout,

        }

 

        client := http.Client{

                Transport: &transport,

        }

 

        content := RequestContent{}

        // fill content here

 

        postStr, err := json.Marshal(content)

        if err != nil {

                return nil, err

        }

 

        resp, err := client.Post(URL, "application/json", bytes.NewBuffer(postStr))

        if err != nil {

                return nil, err

        }

 

        defer resp.Body.Close()

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

        if err != nil {

                return nil, err

        }

 

        // receive body, handle it

}

  运行这段代码一段时间后会发现,该进程下面有一堆ESTABLISHED状态的连接(用lsof -p pid查看某进程下的所有fd),因为每次DoRequest函数被调用后,都会新建一个TCP连接,如果对端不先关闭该连接(对端发FIN包)的话,我们这边即便是调用了resp.Body.Close()函数仍然不会改变这些处于ESTABLISHED状态的连接。为什么会这样呢?只有去源代码一探究竟了。

      Golang的net包中client.go, transport.go, response.go和request.go这几个文件中实现了HTTP Client。当应用层调用client.Do()函数后,transport层会首先找与该请求相关的已经缓存的连接(这个缓存是一个map,map的key是请求方法、请求地址和proxy地址,value是一个叫persistConn的连接描述结构),如果已经有可以复用的旧连接,就会在这个旧连接上发送和接受该HTTP请求,否则会新建一个TCP连接,然后在这个连接上读写数据。当client接受到整个响应后,如果应用层没有
调用response.Body.Close()函数,刚刚传输数据的persistConn就不会被加入到连接缓存中,这样如果您在下次发起HTTP请求的时候,就会重新建立TCP连接,重新分配persistConn结构,这是不调用response.Body.Close()的一个副作用。
      如果不调用response.Body.Close()还存在一个问题。如果请求完成后,对端关闭了连接(对端的HTTP服务器向我发送了FIN),如果这边不调用response.Body.Close(),那么可以看到与这个请求相关的TCP连接的状态一直处于CLOSE_WAIT状态(还记得么?CLOSE_WAIT是连接的半开半闭状态,它是收到对方的FIN并且我们也发送了ACK,但是本端还没有发送FIN到对端,如果本段不调用close关闭连接,那么连接将一直处于
CLOSE_WAIT状态,不会被系统回收)。

      调用了response.Body.Close()就万无一失了么?上面代码中也调用了body.Close()为什么还会有很多ESTABLISHED状态的连接呢?因为在函数DoRequest()的每次调用中,我们都会新创建transport和client结构,当HTTP请求完成并且接收到响应后,如果对端的HTTP服务器没有关闭连接,那么这个连接会一直处于ESTABLISHED状态。如何解呢?
有两个方法:
      第一个方法是用一个全局的client,函数DoRequest()中每次都只在这个全局client上发送数据。但是如果我就想用短连接呢?用方法二。
      第二个方法是在transport分配时将它的DisableKeepAlives参数置为true,此时发送的请求头里会包含Connection: close,像下面这样:

1

2

3

4

5

6

7

8

9

10

// ...

transport := http.Transport{

        Dial:              dialTimeout,

        DisableKeepAlives: true,

}

 

client := http.Client{

        Transport: &transport,

}

// ...

  从transport.go:L908可以看到,当应用层调用resp.Body.Close()时,如果DisableKeepAlives被开启,那么transport自动关闭本端连接。而不将它加入到连接缓存中。

    补充一下,在dialTimeout函数中disable tcp连接的keepalive选项是不可行的,它只是设置TCP连接的选项,不会影响到transport中对连接的控制。

1

2

3

4

5

6

7

8

9

10

11

func dialTimeout(network, addr string) (net.Conn, error) {

        conn, err := net.DialTimeout(network, addr, time.Second*POST_REMOTE_TIMEOUT)

    if err != nil {

        return conn, err

    }

 

    tcp_conn := conn.(*net.TCPConn)                                                                                                 

    tcp_conn.SetKeepAlive(false)                                           

    return tcp_conn, err

}

--end--

 

  • 17
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值