关于golang:golang-io-timeout-希望你不要踩到这个nethttp包的坑

4次阅读

共计 9467 个字符,预计需要花费 24 分钟才能阅读完成。

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

文章继续更新,能够微信搜一搜「golang 小白成长记」第一工夫浏览,回复【教程】获 golang 收费视频教程。本文曾经收录在 GitHub https://github.com/xiaobaiTec… , 有大厂面试残缺考点和成长路线,欢送 Star。

问题

咱们来看一段日常代码。

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net"
    "net/http"
    "time"
)

var tr *http.Transport

func init() {
    tr = &http.Transport{
        MaxIdleConns: 100,
        Dial: func(netw, addr string) (net.Conn, error) {conn, err := net.DialTimeout(netw, addr, time.Second*2) // 设置建设连贯超时
            if err != nil {return nil, err}
            err = conn.SetDeadline(time.Now().Add(time.Second * 3)) // 设置发送承受数据超时
            if err != nil {return nil, err}
            return conn, nil
        },
    }
}

func main() {
    for {_, err := Get("http://www.baidu.com/")
        if err != nil {fmt.Println(err)
            break
        }
    }
}


func Get(url string) ([]byte, error) {m := make(map[string]interface{})
    data, err := json.Marshal(m)
    if err != nil {return nil, err}
    body := bytes.NewReader(data)
    req, _ := http.NewRequest("Get", url, body)
    req.Header.Add("content-type", "application/json")

    client := &http.Client{Transport: tr,}
    res, err := client.Do(req)
    if res != nil {defer res.Body.Close()
    }
    if err != nil {return nil, err}
    resBody, err := ioutil.ReadAll(res.Body)
    if err != nil {return nil, err}
    return resBody, nil
}

做的事件,比较简单,就是循环去申请 http://www.baidu.com/ , 而后期待响应。

看上去貌似没啥问题吧。

代码跑起来,也 的确能失常收发音讯

然而这段代码跑 一段时间,就会呈现 i/o timeout 的报错。

这其实是最近排查了的一个问题,发现这个坑可能比拟容易踩上,我这边对代码做了简化。

理论生产中产生的 景象 是,golang服务在发动 http 调用时,尽管 http.Transport 设置了 3s 超时,会偶发呈现 i/o timeout 的报错。

然而查看上游服务的时候,发现上游服务其实 100ms 就曾经返回了。

排查

就很奇怪了,明明服务端显示解决耗时才100ms,且客户端超时设的是3s, 怎么就呈现超时报错 i/o timeout 呢?

这里揣测有两个可能。

  • 因为服务端打印的日志其实只是 服务端应用层 打印的日志。但客户端应用层收回数据后,两头还通过 客户端的传输层,网络层,数据链路层和物理层 ,再通过 服务端的物理层,数据链路层,网络层,传输层到服务端的应用层 。服务端应用层处耗时100ms,再原路返回。那剩下的3s-100ms 可能是 耗在了整个流程里的各个层上。比方网络不好的状况下,传输层 TCP 使劲丢包重传之类的起因。
  • 网络没问题,客户端到服务端链路整个收发流程大略耗时就是 100ms 左右。客户端解决逻辑问题导致超时。

个别遇到问题,大部分状况下都不会是底层网络的问题,大胆狐疑是本人的问题就对了,不死心就抓个包看下。

剖析下,从刚开始三次握手(画了红框的中央)。

到最初呈现超时报错 i/o timeout画了蓝框的中央)。

time 那一列从 710,的确距离 3s。而且看 右下角 的蓝框,是 51169 端口发到 80 端口的一次 Reset 连贯。

80端口是服务端的端口。换句话说就是客户端 3s 超时 被动 断开链接的。

然而再认真看下 第一行 三次握手到 最初 客户端超时被动断开连接的两头,其实有 十分屡次 HTTP 申请

回去看代码设置超时的形式。

    tr = &http.Transport{
        MaxIdleConns: 100,
        Dial: func(netw, addr string) (net.Conn, error) {conn, err := net.DialTimeout(netw, addr, time.Second*2) // 设置建设连贯超时
            if err != nil {return nil, err}
            err = conn.SetDeadline(time.Now().Add(time.Second * 3)) // 设置发送承受数据超时
            if err != nil {return nil, err}
            return conn, nil
        },
    }

也就是说,这里的 3s 超时,其实是在 建设连贯之后 开始算的,而不是 单次调用开始算的 超时。

看正文里写的是

SetDeadline sets the read and write deadlines associated with the connection.

超时起因

大家晓得 HTTP 是应用层协定,传输层用的是 TCP 协定。

HTTP 协定从 1.0 以前,默认用的是 短连贯,每次发动申请都会建设 TCP 连贯。收发数据。而后断开连接。

TCP 连贯每次都是三次握手。每次断开都要四次挥手。

其实没必要每次都建设新连贯,建设的连接不断开就好了,每次发送数据都复用就好了。

于是乎,HTTP 协定从 1.1 之后就默认应用 长连贯。具体相干信息能够看之前的 这篇文章。

那么 golang 规范库 里也兼容这种实现。

通过建设一个连接池,针对 每个域名 建设一个 TCP 长连贯,比方 http://baidu.comhttp://golang.com 就是两个不同的域名。

第一次拜访 http://baidu.com 域名的时候会建设一个连贯,用完之后放到闲暇连接池里,下次再要拜访http://baidu.com 的时候会从新从连接池里把这个连贯捞进去 复用

插个题外话:这也解释了之前这篇文章里最初的疑难,为什么要强调是同一个域名:一个域名会建设一个连贯,一个连贯对应 一个读 goroutine 和一个写 goroutine。正因为是同一个域名,所以最初才会透露 3 个 goroutine,如果不同域名的话,那就会透露 1+2*N 个协程,N就是域名数。

假如第一次申请要 100ms,每次申请完http://baidu.com 后都 放入 连接池中,下次持续复用,反复 29 次,耗时2900ms

30 次申请的时候,连贯从建设开始到服务返回前就曾经用了 3000ms,刚好到设置的3s 超时阈值,那么此时客户端就会报超时 i/o timeout

尽管这时候服务端其实才花了 100ms,但耐不住后面29 次 加起来的耗时曾经很长。

也就是说只有通过 http.Transport 设置了 err = conn.SetDeadline(time.Now().Add(time.Second * 3)) ,并且你用了 长连贯,哪怕服务端解决再快,客户端设置的超时再长,总有一刻,你的程序会报超时谬误。

正确姿态

本来预期是给每次调用设置一个超时,而不是给整个连贯设置超时。

另外,下面呈现问题的起因是给长连贯设置了超时,且长连贯会复用。

基于这两点,改一下代码。

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "time"
)

var tr *http.Transport

func init() {
    tr = &http.Transport{
        MaxIdleConns: 100,
        // 上面的代码被干掉了
        //Dial: func(netw, addr string) (net.Conn, error) {//    conn, err := net.DialTimeout(netw, addr, time.Second*2) // 设置建设连贯超时
        //    if err != nil {
        //        return nil, err
        //    }
        //    err = conn.SetDeadline(time.Now().Add(time.Second * 3)) // 设置发送承受数据超时
        //    if err != nil {
        //        return nil, err
        //    }
        //    return conn, nil
        //},
    }
}


func Get(url string) ([]byte, error) {m := make(map[string]interface{})
    data, err := json.Marshal(m)
    if err != nil {return nil, err}
    body := bytes.NewReader(data)
    req, _ := http.NewRequest("Get", url, body)
    req.Header.Add("content-type", "application/json")

    client := &http.Client{
        Transport: tr,
        Timeout: 3*time.Second,  // 超时加在这里,是每次调用的超时
    }
    res, err := client.Do(req) 
    if res != nil {defer res.Body.Close()
    }
    if err != nil {return nil, err}
    resBody, err := ioutil.ReadAll(res.Body)
    if err != nil {return nil, err}
    return resBody, nil
}

func main() {
    for {_, err := Get("http://www.baidu.com/")
        if err != nil {fmt.Println(err)
            break
        }
    }
}

看正文会发现,改变的点有两个

  • http.Transport里的建设连贯时的一些超时设置干掉了。
  • 在发动 http 申请的时候会场景http.Client,此时退出超时设置,这里的超时就能够了解为单次申请的超时了。同样能够看下正文

    Timeout specifies a time limit for requests made by this Client.

到这里,代码就改好了,理论生产中问题也就解决了。

实例代码里,如果拿去跑的话,其实还会上面的错

Get http://www.baidu.com/: EOF

这个是因为调用得太猛了,http://www.baidu.com 那边被动断开的连贯,能够了解为一个限流措施,目标是为了爱护服务器,毕竟每个人都像这么搞,服务器是会炸的。。。

解决方案很简略,每次 HTTP 调用两头加个 sleep 间隔时间就好。

到这里,其实问题曾经解决了,上面会在源码层面剖析呈现问题的起因。对读源码不感兴趣的敌人们能够不必接着往下看,间接拉到文章底部 右下角 ,做点正能量的事件( 点两下 )反对一下。( 疯狂暗示,托付托付,这对我真的很重要!

源码剖析

用的 go 版本是 1.12.7

从发动一个网络申请开始跟。

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

func (c *Client) do(req *Request) {
    // ...
    if resp, didTimeout, err = c.send(req, deadline); err != nil {// ...}
    // ...  
}  
func send(ireq *Request, rt RoundTripper, deadline time.Time) {
    // ...    
    resp, err = rt.RoundTrip(req)
     // ...  
} 

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

func (t *Transport) roundTrip(req *Request) (*Response, error) {
    // 尝试去获取一个闲暇连贯,用于发动 http 连贯
  pconn, err := t.getConn(treq, cm)
  // ...
}

// 重点关注这个函数,返回是一个长连贯
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (*persistConn, error) {
  // 省略了大量逻辑,只关注上面两点
    // 有闲暇连贯就返回
    pc := <-t.getIdleConnCh(cm)

  // 没有创立连贯
  pc, err := t.dialConn(ctx, cm)
  
}

这里下面很多代码,其实只是为了展现这部分代码是怎么跟踪下来的,不便大家去看源码的时候去跟一下。

最初一个下面的代码里有个 getConn 办法。在发动网络申请的时候,会先取一个网络连接,取连贯有两个起源。

  • 如果有闲暇连贯,就拿闲暇连贯

    /src/net/http/tansport.go:810
    func (t *Transport) getIdleConnCh(cm connectMethod) chan *persistConn {
       // 返回放闲暇连贯的 chan
       ch, ok := t.idleConnCh[key]
         // ...
       return ch
    }
  • 没有闲暇连贯,就创立长连贯。
/src/net/http/tansport.go:1357
func (t *Transport) dialConn() {
  //...
  conn, err := t.dial(ctx, "tcp", cm.addr())
  // ...
  go pconn.readLoop()
  go pconn.writeLoop()
  // ...
}

第一次 发动一个 http 申请时,这时候必定没有闲暇连贯,会建设一个新连贯。同时会创立一个 读 goroutine 和一个写 goroutine

留神下面代码里的 t.dial(ctx, "tcp", cm.addr()),如果像文章结尾那样设置了 http.Transport

Dial: func(netw, addr string) (net.Conn, error) {conn, err := net.DialTimeout(netw, addr, time.Second*2) // 设置建设连贯超时
   if err != nil {return nil, err}
   err = conn.SetDeadline(time.Now().Add(time.Second * 3)) // 设置发送承受数据超时
   if err != nil {return nil, err}
   return conn, nil
},

那么这里就会在上面的 dial 里被执行到

func (t *Transport) dial(ctx context.Context, network, addr string) (net.Conn, error) {
   // ...
  c, err := t.Dial(network, addr)
  // ...
}

这外面调用的设置超时,会执行到

/src/net/net.go
func (c *conn) SetDeadline(t time.Time) error {
    //...
    c.fd.SetDeadline(t)
    //...
}

//...

func setDeadlineImpl(fd *FD, t time.Time, mode int) error {
    // ...
    runtime_pollSetDeadline(fd.pd.runtimeCtx, d, mode)
    return nil
}


//go:linkname poll_runtime_pollSetDeadline internal/poll.runtime_pollSetDeadline
func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) {
    // ...
  // 设置一个定时器事件
  rtf = netpollDeadline
    // 并将事件注册到定时器里
  modtimer(&pd.rt, pd.rd, 0, rtf, pd, pd.rseq)
}  

下面的源码,简略来说就是,当第一次调用申请的,会建设个连贯,这时候还会注册一个 定时器事件 ,假如工夫设了3s,那么这个事件会在3s 后产生,而后执行注册事件的逻辑。而这个注册事件就是 netpollDeadline 留神这个netpollDeadline,待会会提到。

设置了超时事件,且超时事件是 3s 后之后,产生。再次期间失常收发数据。所有如常。

直到 3s 过后,这时候看 读 goroutine,会期待网络数据返回。

/src/net/http/tansport.go:1642
func (pc *persistConn) readLoop() {
    //...
    for alive {_, err := pc.br.Peek(1)  // 阻塞读取服务端返回的数据
    //...
}

而后就是始终跟代码。

src/bufio/bufio.go: 129
func (b *Reader) Peek(n int) ([]byte, error) {
   // ...
   b.fill() 
   // ...   
}

func (b *Reader) fill() {
    // ...
    n, err := b.rd.Read(b.buf[b.w:])
    // ...
}

/src/net/http/transport.go: 1517
func (pc *persistConn) Read(p []byte) (n int, err error) {
    // ...
    n, err = pc.conn.Read(p)
    // ...
}

// /src/net/net.go: 173
func (c *conn) Read(b []byte) (int, error) {
    // ...
    n, err := c.fd.Read(b)
    // ...
}

func (fd *netFD) Read(p []byte) (n int, err error) {n, err = fd.pfd.Read(p)
    // ...
}

/src/internal/poll/fd_unix.go: 
func (fd *FD) Read(p []byte) (int, error) {
    //...
  if err = fd.pd.waitRead(fd.isFile); err == nil {continue}
    // ...
}

func (pd *pollDesc) waitRead(isFile bool) error {return pd.wait('r', isFile)
}

func (pd *pollDesc) wait(mode int, isFile bool) error {
    // ...
  res := runtime_pollWait(pd.runtimeCtx, mode)
    return convertErr(res, isFile)
}

直到跟到 runtime_pollWait,这个能够简略认为是 期待服务端数据返回

//go:linkname poll_runtime_pollWait internal/poll.runtime_pollWait
func poll_runtime_pollWait(pd *pollDesc, mode int) int {
    
    // 1. 如果网络失常返回数据就跳出
  for !netpollblock(pd, int32(mode), false) {
    // 2. 如果有出错状况也跳出
        err = netpollcheckerr(pd, int32(mode))
        if err != 0 {return err}
    }
    return 0
}

整条链路跟下来,就是会始终期待数据,期待的后果只有两个

  • 有能够读的数据
  • 呈现报错

这外面的 报错,又有那么两种

  • 连贯敞开
  • 超时
func netpollcheckerr(pd *pollDesc, mode int32) int {
    if pd.closing {return 1 // errClosing}
    if (mode == 'r' && pd.rd < 0) || (mode == 'w' && pd.wd < 0) {return 2 // errTimeout}
    return 0
}

其中提到的 超时 ,就是指这外面返回的 数字 2 ,会通过上面的函数,转化为 ErrTimeout,而 ErrTimeout.Error() 其实就是 i/o timeout

func convertErr(res int, isFile bool) error {
    switch res {
    case 0:
        return nil
    case 1:
        return errClosing(isFile)
    case 2:
        return ErrTimeout // ErrTimeout.Error() 就是 "i/o timeout"}
    println("unreachable:", res)
    panic("unreachable")
}

那么问题来了。下面返回的超时谬误,也就是 返回 2 的时候的条件是怎么满足的

    if (mode == 'r' && pd.rd < 0) || (mode == 'w' && pd.wd < 0) {return 2 // errTimeout}

还记得刚刚提到的 netpollDeadline吗?

这外面放了定时器 3s 到点时执行的逻辑。

func timerproc(tb *timersBucket) {
    // 计时器到设定工夫点了,触发之前注册函数
    f(arg, seq) // 之前注册的是 netpollDeadline
}

func netpollDeadline(arg interface{}, seq uintptr) {netpolldeadlineimpl(arg.(*pollDesc), seq, true, true)
}

/src/runtime/netpoll.go: 428
func netpolldeadlineimpl(pd *pollDesc, seq uintptr, read, write bool) {
    //...
    if read {
        pd.rd = -1
        rg = netpollunblock(pd, 'r', false)
    }
    //...
}

这里会设置 pd.rd=-1,是指 poller descriptor.read deadline,含意 网络轮询器文件描述符 读超时工夫,咱们晓得在 linux 里万物皆文件,这里的文件其实是指这次网络通讯中应用到的socket

这时候再回去看 产生超时的条件 就是if (mode == 'r' && pd.rd < 0)

至此。咱们的代码里就收到了 io timeout 的报错。

总结

  • 不要在 http.Transport中设置超时,那是连贯的超时,不是申请的超时。否则可能会呈现莫名 io timeout报错。
  • 申请的超时在创立 client 里设置。

如果文章对你有帮忙,看下文章底部右下角,做点正能量的事件(点两下 )反对一下。( 疯狂暗示,托付托付,这对我真的很重要!

我是小白,咱们下期见。

文章举荐:

  • 妙啊! 程序猿的第一本互联网黑话指南
  • 程序员防猝死指南
  • 我感觉,我可能要拿图灵奖了。。。
  • 给大家争脸了,用了三年 golang,我还是没答对这道内存透露题
  • 硬核!漫画图解 HTTP 知识点 + 面试题
  • TCP 粘包 数据包:我只是犯了每个数据包都会犯的错 | 硬核图解
  • 硬核图解!30 张图带你搞懂!路由器,集线器,交换机,网桥,光猫有啥区别?
别说了,一起在常识的陆地里呛水吧

关注公众号:【golang 小白成长记】

正文完
 0