源码分析
对 HTTP 服务器和客户端来说,超时处理是最容易犯错的问题之一。因为在网络连接到请求处理的多个阶段,都可能有相对应的超时时间。以 HTTP 请求为例,http.Client
有一个参数 Timeout 用于指定当前请求的总超时时间,它包括从连接、发送请求、到处理服务器响应的时间的总和。
1 | client := &http.Client{ |
标准库 client.Do
方法内部会将超时时间换算为Deadline并传递到下一层。setRequestCancel
函数内部则会调用 context.WithDeadline
,派生出一个子 Context 并赋值给 req 中的 Context
。
1 | // net/http/client.go |
在获取连接时,如果从闲置连接中找不到连接,则需要陷入 select 中去等待。如果连接时间超时,req.Context().Done()
通道会收到信号立即退出。在实际发送数据的 transport.roundTrip
函数中,也有很多通过在 select 语句中监听 Context 退出信号来实现超时控制的例子
1 | // net/http/transport.go |
获取 TCP 连接需要调用 sysDialer.dialSerial
方法,dialSerial
的功能是逐个遍历addrList中的addr ,如果与任一addr能成功建立连接则立即返回。代码如下
1 | // net/dial.go |
dialSerial
函数中有几个典型的 Context 用法
第16行代码遍历addr list时,判断
Context
是否已经退出,如果没有退出,会进入到 select 的 default 分支。如果通道已经退出了,则函数直接return第14行代码通过
ctx.Deadline()
判断是否传递进来的Context
有超时时间。如果有超时时间,我们需要协调好后面每一个连接的超时时间。partialDeadline
会计算每一个连接的新的到期时间,如果该到期时间小于总到期时间,将派生出一个子 Context 传递给dialSingle
函数,用于控制该连接的超时1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21func partialDeadline(now, deadline time.Time, addrsRemaining int) (time.Time, error) {
if deadline.IsZero() {
return deadline, nil
}
timeRemaining := deadline.Sub(now)
if timeRemaining <= 0 {
return time.Time{}, errTimeout
}
// 暂时为每个剩余的addr分配相等的时间
timeout := timeRemaining / time.Duration(addrsRemaining)
// 如果每个addr获得的时间太短(小于2s),则使用所有remaining time
const saneMinimum = 2 * time.Second
if timeout < saneMinimum {
if timeRemaining < saneMinimum {
timeout = timeRemaining
} else {
timeout = saneMinimum
}
}
return now.Add(timeout), nil
}dialSingle
函数中调用了ctx.Value
,用来获取一个特殊的接口nettrace.Trace
。nettrace.Trace
用于对网络包中一些特殊的地方进行 hook。dialSingle
函数作为网络连接的起点,如果上下文中注入了trace.ConnectStart
函数,则会在dialSingle
函数之前调用trace.ConnectStart
函数,如果上下文中注入了trace.ConnectDone
函数,则会在执行dialSingle
函数之后调用trace.ConnectDone
函数1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// net/dial.go
func (sd *sysDialer) dialSingle(ctx context.Context, ra Addr) (c Conn, err error) {
trace, _ := ctx.Value(nettrace.TraceKey{}).(*nettrace.Trace)
if trace != nil {
raStr := ra.String()
if trace.ConnectStart != nil {
trace.ConnectStart(sd.network, raStr)
}
if trace.ConnectDone != nil {
defer func() { trace.ConnectDone(sd.network, raStr, err) }()
}
}
la := sd.LocalAddr
switch ra := ra.(type) {
case *TCPAddr:
la, _ := la.(*TCPAddr)
// tcp连接
c, err = sd.dialTCP(ctx, la, ra)
...
}
一个例子
在这个例子中,我在本地起了一个服务监听5001端口,并使用自定义的dns resolver,对于www.example.io
这个domain,该dns resolver会返回两个地址:127.0.0.2 & 127.0.0.1,只有后一个addr可以建立连接。完整代码可以从这个仓库获取
1 | package main |
输出
1 | go run main.go |
可以看到,第一个addr过了2s还未连接成功,返回io timeout错误,第二个addr连接成功,Transport获取到conn,最终完成了一个http请求的发送