Go http.Client 的超时机制

参考资料

前言

这里通过一个基于go 1.18.10简单的demo来研究Go http.Client 的超时机制。demo发起http请求(http响应需要10秒),但是我程序这边设置了请求的超时时间是3秒,运行结果是程序3秒返回了请求超时的错误提示,Go是如何实现这一点的呢?

跟踪demo的调用链路,可以得到如下:

goroutine-1 runtime.main
|-main.main
  |-net/http.(*Client).Get
    |-net/http.(*Client).do

关于http.(*Client).do的处理逻辑可以参考这篇文章,大致意思是如果请求的地址返回301跳转,就继续请求301指向的新地址。这里我们假定请求的地址没有返回301,继续研究。

首先程序在发起http请求时会创建若干个协程,如何根据根据定义好的3秒超时时间来控制这些协程的退出,以避免协程泄漏等问题呢?golang使用的是context的超时机制,在本例中程序在执行http.(*Client).do时,调用了net/http.(*Client).send(req, deadline)方法,其中第二个参数就是demo程序初始化的3秒超时时间。而随后在如下的调用链中,把它封装到了req.ctx中了。再随后这个context将贯穿整个调用过程,保证超时控制:

goroutine-1 runtime.main
|-main.main
  |-net/http.(*Client).Get
    |-net/http.(*Client).do
      |-deadline = c.deadline() // c.deadline()即是c.Timeout+time.Now()
      |-net/http.(*Client).send(req, deadline)
        |-net/http.send(req, rt, deadline)
          |-net/http.setRequestCancel(req, rt, deadline)
            |-req.ctx, cancelCtx = context.WithDeadline(oldCtx, deadline)

接着我们整理一下在发起client.Get请求的时候经历的四个步骤(需要io处理的地方):

  1. dns解析,需要发起udp网络请求
  2. 建立tcp连接
  3. 发送http请求,往tcp连接写数据
  4. 接收http请求,往tcp连接读数据

上述步骤是有先后顺序的,而我们设置了超时时间是3秒,也就是当超时时,程序可以在上述任何一步中退出。

协程的调用链路

跟踪demo程序执行的调用链路,一共有9个协程,它们的调用链路分别如下:

goroutine-1 runtime.main
|-main.main
  |-net/http.(*Client).Get
    |-net/http.(*Client).do
      |-net/http.(*Client).send
        |-net/http.send
          |-net/http.setRequestCancel
            |-context.WithDeadline
              |-time.AfterFunc
                |-goroutine-51   // 超时后其他监听ctx.Done的协程都会收到信号
          |-net/http.(*Transport).RoundTrip
            |-net/http.(*Transport).roundTrip
              |-net/http.(*Transport).getConn
                |-net/http.(*Transport).queueForDial
                  |-goroutine-21 // 新建一个tcp连接
              |-net/http.(*persistConn).roundTrip // 正式发起http请求

goroutine-21 created by net/http.(*Transport).queueForDial.func1
|-net/http.(*Transport).dialConnFor
  |-net/http.(*Transport).dialConn
    |-net/http.(*Transport).dial
    | |-net.(*Dialer).DialContext
    |   |-net.(*Resolver).resolveAddrList
    |   | |-net.(*Resolver).internetAddrList
    |   |   |-net.(*Resolver).lookupIPAddr
    |   |     |-internal/singleflight.(*Group).DoChan
    |   |       |-goroutine-34
    |   |-net.(*sysDialer).dialSerial
    |     |-net.(*sysDialer).dialTCP
    |       |-net.(*sysDialer).doDialTCP
    |         |-net.internetSocket
    |           |-net.socket
    |             |-net.(*netFD).dial
    |               |-net.(*netFD).connect
    |                 |-goroutine-50              
    |-goroutine-35
    |-goroutine-36

goroutine-34 created by internal/singleflight.(*Group).DoChan.func1
|-internal/singleflight.(*Group).doCall
  |-net.(*Resolver).lookupIPAddr.func1
    |-net.(*Resolver).lookupIP-fm
      |-net.(*Resolver).lookupIP
        |-net.(*Resolver).goLookupIPCNAMEOrder
          |-net.(*Resolver).goLookupIPCNAMEOrder.func3
            |-goroutine-22
          |-net.(*Resolver).goLookupIPCNAMEOrder.func3
            |-goroutine-23
          |-net.sortByRFC6724
            |-net.srcAddrs
              |-net.DialUDP
              |-net.DialUDP

goroutine-22 created by net.(*Resolver).goLookupIPCNAMEOrder.func3.2
goroutine-23 created by net.(*Resolver).goLookupIPCNAMEOrder.func3.2
goroutine-50 created by net.(*netFD).connect.func2
goroutine-36 created by net/http.(*Transport).dialConn.func6
goroutine-35 created by net/http.(*Transport).dialConn.func5
goroutine-51 created by context.WithDeadline.func2

由此可以对应到上面提到的四个步骤主要在前三个协程里面完成的,接下来依次分析这四个步骤的流程。

步骤一:dns解析

这个是在goroutine-34完成的:

goroutine-34 created by internal/singleflight.(*Group).DoChan.func1
|-internal/singleflight.(*Group).doCall
  |-net.(*Resolver).lookupIPAddr.func1
    |-net.(*Resolver).lookupIP-fm
      |-net.(*Resolver).lookupIP
        |-net.(*Resolver).goLookupIPCNAMEOrder
          |-net.(*Resolver).goLookupIPCNAMEOrder.func3
            |-goroutine-22    // 向dns服务器查询httpbin.org的ipv4地址
          |-net.(*Resolver).goLookupIPCNAMEOrder.func3
            |-goroutine-23    // 向dns服务器查询httpbin.org的ipv6地址
          |-net.sortByRFC6724 // 此时dns服务器已经返回两个ipv4的地址
            |-net.srcAddrs
              |-net.DialUDP   // 检查第一个ipv4地址是否路由可达(不会发网络包)
              |-net.DialUDP   // 检查第二个ipv4地址是否路由可达(不会发网络包)

这里我们可以看到此协程中并发启了2个协程来发起UDP请求,那么这2个UDP的超时控制是怎么实现的呢?以goroutine-22为例,它的调用链路如下:

goroutine-22 created by net.(*Resolver).goLookupIPCNAMEOrder.func3.2
|-net.(*Resolver).tryOneName(ctx, conf, fqdn, qtype)
  |-net.(*Resolver).exchange(ctx, server, q, cfg.timeout, cfg.useTCP)
    |-net.(*Resolver).dial       // 建立udp连接(没有网络io)
    |-d, ok := ctx.Deadline()    // 取出ctx剩余时间
    |-net.(*conn).SetDeadline(d) // 设置udp连接的超时时间
    |-net.dnsPacketRoundTrip     // 发起udp请求(协程休眠,直到被netpoll唤醒)

可以看到udp建立连接后是有控制连接的超时时间的,go的网络请求(包括网络超时)都是由netpoll来管理的,上面的net.(*conn).SetDeadline(d)使得go使用了一个定时器,在超时后会调用netpolldeadlineimpl唤醒还在等待网络io的协程,进而结束协程,具体技术细节可以参考Go 语言设计与实现-网络轮询器

步骤二 建立tcp连接

这个是在goroutine-21完成的:

goroutine-21 created by net/http.(*Transport).queueForDial.func1
|-net/http.(*Transport).dialConnFor
  |-net/http.(*Transport).dialConn
    |-net/http.(*Transport).dial
     |-net.(*Dialer).DialContext
       |-net.(*Resolver).resolveAddrList  // 完成dns解析
       |-net.(*sysDialer).dialSerial
         |-net.(*sysDialer).dialTCP
           |-net.(*sysDialer).doDialTCP
             |-net.internetSocket
               |-net.socket
                 |-net.(*netFD).dial
                   |-net.(*netFD).connect // 发起tcp连接
                     |-goroutine-50       // 监听ctx.Done()事件并终止协程

goroutine-50的代码就在net.(*netFD).connect中,我们看一下它的源码:

func (fd *netFD) connect(ctx context.Context, la, ra syscall.Sockaddr) (rsa syscall.Sockaddr, ret error) {
    // 非阻塞的connect系统调用,会立即返回EINPROGRESS,需要用epoll_wait等待EPOLLOUT事件以确定tcp连接成功
    switch err := connectFunc(fd.pfd.Sysfd, ra); err { 
    case syscall.EINPROGRESS, syscall.EALREADY, syscall.EINTR:
    case nil, syscall.EISCONN:
        // 省略...
    default:
        return nil, os.NewSyscallError("connect", err)
    }
    if err := fd.pfd.Init(fd.net, true); err != nil {
        return nil, err
    }
    if deadline, hasDeadline := ctx.Deadline(); hasDeadline {
        // 因为是建立连接阶段,所以监听写超时就可以了
        fd.pfd.SetWriteDeadline(deadline)
        defer fd.pfd.SetWriteDeadline(noDeadline)
    }

    ctxDone := ctx.Done()
    if ctxDone != nil {
        done := make(chan struct{})
        interruptRes := make(chan error)
        // 表示当前函数结束就会关闭goroutine-50
        defer func() {
            close(done)
            if ctxErr := <-interruptRes; ctxErr != nil && ret == nil {
                ret = mapErr(ctxErr)
                fd.Close()
            }
        }()
        // 这里会启动goroutine-50,在ctx超时后用netpool唤醒协程
        // (个人认为用一个协程来监听ctx.Done有点重了,如果能够把监听channel和下面的fd.pfd.WaitWrite()放一起是最好的了,只是对低层的改动会比较大)
        go func() {
            select {
            case <-ctxDone:
                fd.pfd.SetWriteDeadline(aLongTimeAgo)
                testHookCanceledDial()
                interruptRes <- ctx.Err()
            case <-done:
                interruptRes <- nil
            }
        }()
    }

    for {
        // 协程在这里用epoll_wait阻塞等待EPOLLOUT事件,当然也有可能是被超时机制唤醒
        if err := fd.pfd.WaitWrite(); err != nil {
            select {
            case <-ctxDone:
                return nil, mapErr(ctx.Err())
            default:
            }
            return nil, err
        }
        // 省略...
    }
}

步骤三 往 tcp 写数据

    // goroutine-36的主要任务就是写数据,channel是pc.writech
    func (pc *persistConn) writeLoop() {
        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 {
                    wr.req.setError(bre.error)
                }
                if err == nil {
                    err = pc.bw.Flush()
                }
                pc.writeErrCh <- err
                wr.ch <- err
                if err != nil {
                    pc.close(err)
                    return
                }
            case <-pc.closech:
                return
            }
        }
    }

步骤四 往 tcp 读数据

    // goroutine-35的主要任务就是读数据,pc.reqch是一个唤醒channel
    func (pc *persistConn) readLoop() {
        alive := true
        for alive {
            rc := <-pc.reqch
            resp, err = pc.readResponse(rc, trace)
            if err != nil {
                select {
                case rc.ch <- responseAndError{err: err}:
                case <-rc.callerGone:
                    return
                }
                return
            }
            select {
            case rc.ch <- responseAndError{res: resp}:
            case <-rc.callerGone:
                return
            }
            select {
            case bodyEOF := <-waitForBodyRead:          // caller协程已读完  
                alive = alive &&
                    bodyEOF &&
                    !pc.sawEOF &&
                    pc.wroteRequest() &&
                    replaced && tryPutIdleConn(trace)
                if bodyEOF {
                    eofc <- struct{}{}
                }
            case <-rc.req.Cancel:                       // Deprecated,其它业务可以通过此channel终止请求
                alive = false
                pc.t.CancelRequest(rc.req)
            case <-rc.req.Context().Done():             // 通过context来终止请求 
                alive = false
                pc.t.cancelRequest(rc.cancelKey, err)
            case <-pc.closech:                          // 连接关闭
                alive = false
            }
        }
    }

总结

发起 client.Get 请求的时候经历的四个步骤,虽然有io,但是在linux下借助epoll机制都转变成了非阻塞的了,因而需要借助go官方提供的timer、context超时控制、netpoll中断来实现同步,最终保证了超时控制。

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!