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 请求头后,也是阻塞中,收到一个连接关闭的错误,然后退出。
推荐文章: