net/http的Client超时机制
参考资料
前言
研究了一下Go http.Client 的超时机制。假设发起一个http请求(http响应需要10秒),但是我程序这边设置了请求的超时时间是3秒,运行结果是程序3秒返回了请求超时的错误提示,Go是如何实现这一点的呢?先写一个demo:
func main() {
client := http.Client{
Timeout: 3 * time.Second,
}
res, err := client.Get("http://httpbin.org/delay/10")
if err == nil {
println(res.Body)
} else {
println(err.Error())
}
}
跟踪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处理的地方):
- dns解析,需要发起udp网络请求
- 建立tcp连接
- 发送http请求,往tcp连接写数据
- 接收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
|-req.ctx, cancel = 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请求(DNS查询),那么这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中断来实现同步,最终保证了超时控制。复盘本次的demo,程序请求一个需要10秒响应的接口,客户端设置了3秒超时,具体流程如下:
- goroutine-1 main协程完成dns解析、建立tcp连接后就阻塞在
net/http.(*persistConn).roundTrip
,其中监听了req.Context().Done()
这个channel。 - req.Context()是在
net/http.setRequestCancel
设置的超时时间的,超时后启动了一个协程goroutine-51 created by context.WithDeadline.func2
来cancel掉req.Context()。 - goroutine-1 main协程监听到req.Context()被cancel,执行
pc.t.cancelRequest()
,最后调用net.(*netFD).Close
关闭tcp连接。 - goroutine-35 readLoop一直在等待接口响应,阻塞在
bufio.(*Reader).Peek
,由于连接close,收到一个连接关闭的错误,然后退出。 - goroutine-36 writeLoop自从发送完http请求头后,也是阻塞中,收到一个连接关闭的错误,然后退出。