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

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


问题

咱们来看一段日常代码。

package mainimport (    "bytes"    "encoding/json"    "fmt"    "io/ioutil"    "net"    "net/http"    "time")var tr *http.Transportfunc 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 mainimport (    "bytes"    "encoding/json"    "fmt"    "io/ioutil"    "net/http"    "time")var tr *http.Transportfunc 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: 16func (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:810func (t *Transport) getIdleConnCh(cm connectMethod) chan *persistConn {   // 返回放闲暇连贯的chan   ch, ok := t.idleConnCh[key]     // ...   return ch}
  • 没有闲暇连贯,就创立长连贯。
/src/net/http/tansport.go:1357func (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.gofunc (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_pollSetDeadlinefunc 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:1642func (pc *persistConn) readLoop() {    //...    for alive {        _, err := pc.br.Peek(1)  // 阻塞读取服务端返回的数据    //...}

而后就是始终跟代码。

src/bufio/bufio.go: 129func (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: 1517func (pc *persistConn) Read(p []byte) (n int, err error) {    // ...    n, err = pc.conn.Read(p)    // ...}// /src/net/net.go: 173func (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_pollWaitfunc 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: 428func 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小白成长记】