[译]关于Go net/http 超时完全指南

翻译自:The complete guide to Go net/http timeouts
地址:https://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/

原文发表于 2016 年,主要基于 Go 1.6/1.7。下文保留当时的接口和示例;Request.Cancel、TLS 超时及流式响应的处理,请一并看文中的译者注。

使用Go编写HTTP服务器或客户端时,超时是最容易出错、也最容易被忽视的东西:一个错误可能在很长一段时间内没有任何影响,直到网络出现故障并挂起该进程为止。

HTTP是一个复杂的多阶段协议,因此没有一个适合所有超时的解决方案。考虑一下流端点、JSON API和Comet端点。实际上,默认设置通常不是您想要的。

在本文中,我将介绍在服务器端和客户端都可能会导致应用超时的各个阶段,并探讨解决超时的不同方法。

SetDeadline

首先,您需要了解Go用于实现超时的网络原语:Deadlines。

Deadlines 通过net.Conn的 Set[Read|Write]Deadline(time.Time) 方法暴露,Deadlines是绝对时间,一旦到时,所有I/O操作都会因超时错误而失败。

Deadlines不是超时。一旦设置,它们就会永久生效(直到下一次调用SetDeadline),不管在此期间是否使用连接以及如何使用连接。因此,要使用SetDeadline建立超时,您必须在每次读/写操作之前调用它。

实际开发中,你通常不需要直接调用SetDeadline,而是在标准库net/http中使用更高层次的超时设置。这里讨论的网络读写超时基于 Deadline,并不会在每次发送或接收数据时自动重置;Context 和 Client.Timeout 还会通过计时器、取消信号控制请求生命周期。

Server Timeouts

“So you want to expose Go on the Internet”一文提供了有关服务器超时的更多信息,尤其是关于 HTTP/2 和 Go 1.7 bug 的内容。

服务器超时

对于暴露于Internet的HTTP服务器来说,设置客户端连接超时,是至关重要的。否则,非常缓慢或消失的客户端可能会泄漏文件描述符,最终导致以下情况:

1
http: Accept error: accept tcp [::]:80: accept4: too many open files; retrying in 5ms

原文讨论了http.Server的两个超时字段:ReadTimeout和WriteTimeout。你可以显式地设置它们:

1
2
3
4
5
srv := &http.Server{
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Println(srv.ListenAndServe())

ReadTimeout的时间计算是从连接被接受(accept)到request body完全被读取(如果你确实读取了 body;否则到 header 结束为止)。net/http的内部实现是在Accept之后立即调用SetReadDeadline。

WriteTimeout 通常覆盖从request header的读取结束开始,到response write结束为止(也就是ServeHTTP的生命周期), 它是通过在readRequest方法结束的时候调用SetWriteDeadline实现的。

但是,当连接为HTTPS时,会在Accept之后立即调用SetWriteDeadline,所以它的时间计算也包括 TLS握手时的写的时间。令人讨厌的是,这意味着(仅在这种情况下)WriteTimeout最终将包括Header和读取body第一个字节这段时间。

当你面对不可信的客户端和/或网络时,你应该同时设置读写超时,这样客户端就不会因为读慢或者写慢长久的持有这个连接了。

最后,还有http.TimeoutHandler。它不是一个Server参数,而是一个Handler包装函数,限制ServeHTTP调用的最大持续时间。它会缓冲响应,超时后返回503 Service Unavailable(原文误写为504)。原文还提到,这个功能在Go 1.6中有问题,在1.6.2中修复了。

http.ListenAndServe is doing it wrong

顺便说一句,这意味着那些绕过 http.Server 的包级便捷函数(如 http.ListenAndServe、http.ListenAndServeTLS、http.Serve)不适合用于公共 Internet 服务器。

因为这些函数默认关闭了超时设置,也无法手动设置。使用这些函数,将很快泄露连接,然后耗尽文件描述符。对于这点,这样的错误我至少犯过六次。

对此,你应该使用http.Server。在创建http.Server实例时,设置ReadTimeout(读取超时时间)和WriteTimeout(写超时时间)字段,可以参考前文的示例。

About streaming

非常烦人的是,无法从ServeHTTP访问底层net.Conn,因此打算流式传输响应的服务器被迫取消WriteTimeout的设置(这也可能是默认情况下它们为0的原因)。这是因为没有net.Conn访问,就无法在每次Write之前调用SetWriteDeadline来实现适当的空闲(不是绝对)超时。

同样,也没有办法取消被阻塞的 ResponseWriter.Write,因为 ResponseWriter.Close(可以通过接口断言访问到)没有文档保证它能解除并发的 Write。所以也无法用 Timer 手动构建超时。

可惜,这意味着流式服务器无法有效防御读取缓慢的客户端

我提交了一个问题和一些建议,期待反馈。

译者注:HTTP/1.x 下可以通过 Hijack 接管底层连接,但接管后就不能继续按普通 ResponseWriter 的方式处理响应,HTTP/2 也不支持 Hijack,因此它不是这里的直接替代方案。Go 1.20 起提供了 http.ResponseController,可在支持的 ResponseWriter 上设置读写 Deadline;不支持时会返回错误。流式响应可以据此设置逐次写入的空闲超时。现代 Server 还提供 ReadHeaderTimeout、IdleTimeout,TLS 握手的超时处理也应以实际版本为准。

Hijack获取conn

Client Timeouts

客户端超时

客户端超时,可以很简单,也可以很复杂,取决于你怎么用。但同样重要的是:要防止资源泄漏和阻塞。

最简单的使用超时的方式是http.Client。它涵盖整个交互过程,从发起连接(如果未重用连接)到读完响应体(body)。

1
2
3
4
c := &http.Client{
Timeout: 15 * time.Second,
}
resp, err := c.Get("https://jiankunking.com")

与服务端情况类似,程序包级别的功能(例如http.Get)可以使用没有超时的客户端,因此在开放的Internet上使用非常危险。

还有其它一些方法,可以让你进行更精细的超时控制:

  • net.Dialer.Timeout 限制创建一个TCP连接使用的时间(如果需要一个新的连接)
  • http.Transport.TLSHandshakeTimeout 限制TLS握手使用的时间
  • http.Transport.ResponseHeaderTimeout 限制读取响应报文头使用的时间
  • http.Transport.ExpectContinueTimeout 限制客户端在发送一个包含 Expect: 100-continue 的请求头后,等待收到一个go-ahead响应报文所用的时间。注意:在1.6中设置此项会禁用HTTP/2;从1.6.2起,对DefaultTransport做了特例处理。
1
2
3
4
5
6
7
8
9
10
11
c := &http.Client{
Transport: &http.Transport{
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).Dial,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
}

据原文作者了解,当时没有专门限制发送请求所用时间的机制。客户端方法返回后,读取响应体(response body)的时间可以用time.Timer手工控制(参见下面的“如何取消请求”)。原文此处误写为request body;客户端返回后读取的是resp.Body。

最后,在新的1.7版本中,提供了http.Transport.IdleConnTimeout。它并不控制客户端请求的某个阻塞阶段,而是控制闲置连接在连接池中保留多久。

请注意,客户端将使用默认的重定向机制。http.Client.Timeout 包含了跟随重定向的所有耗时;而更精细的超时是针对每个请求单独生效的,因为 http.Transport 是没有重定向概念的更底层机制。

Cancel and Context

net/http提供了两种方式取消一个client的请求: Request.Cancel以及Go 1.7新加的Context。

Request.Cancel是一个可选channel。设置这个channel并关闭它,就会使请求中断,效果类似于http.Client.Timeout到期(原文误写为Request.Timeout,http.Request并没有这个字段)。它们在当时使用相同的取消机制;原文作者还发现了Go 1.7中的一个bug,取消操作会被当作超时错误返回。

我们可以使用Request.Cancel和time.Timer来构建一个细粒度的超时控制,以允许流传输,每次成功从Body读取一些数据时,都将截止日期推迟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
"io"
"log"
"net/http"
"time"
)

func main() {
c := make(chan struct{})
timer := time.AfterFunc(5*time.Second, func() {
close(c)
})

// Serve 256 bytes every second.
req, err := http.NewRequest("GET", "http://httpbin.org/range/2048?duration=8&chunk_size=256", nil)
if err != nil {
log.Fatal(err)
}
req.Cancel = c

log.Println("Sending request...")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()

log.Println("Reading body...")
for {
timer.Reset(2 * time.Second)
// Try instead: timer.Reset(50 * time.Millisecond)
_, err = io.CopyN(io.Discard, resp.Body, 256)
if err == io.EOF {
break
} else if err != nil {
log.Fatal(err)
}
}
}

在上面这个例子中,我们在请求阶段设置了5秒超时。读取响应体时,每次调用io.CopyN前把计时器重置为2秒,因此整个响应可以花8秒甚至更长时间读取,但单次读取不能一直阻塞。若在2秒内没有读完这一批256字节,计时器会取消请求,读取可能返回net/http: request canceled等错误。

译者注:这里用Go 1.16新增的io.Discard修正了原代码的ioutil.Discard(该标识符不存在)。示例仍保留旧的Request.Cancel用法;AfterFunc回调与Reset可能并发,实际代码还需避免重复关闭channel,并清理计时器。现在更适合使用可重复调用的Context cancel函数。

context包升级了,进入到标准库中。关于Contexts,我们有大量需要学习的东西。基于本文的主旨,你首先应该知道的是:Contexts将替代Request.Cancel,Request.Cancel 已被弃用。

为了使用Contexts来取消一个请求,我们需要创建一个新的Context以及它的基于context.WithCancel的cancel()函数,同时还有创建一个基于Request.WithContext的Request。当我们要取消一个请求时,我们实际上是通过 cancel() 函数取消相应的Context(取代原有的关闭Cancel channel的方式):

1
2
3
4
5
6
7
8
9
10
ctx, cancel := context.WithCancel(context.TODO())
timer := time.AfterFunc(5*time.Second, func() {
cancel()
})

req, err := http.NewRequest("GET", "http://httpbin.org/range/2048?duration=8&chunk_size=256", nil)
if err != nil {
log.Fatal(err)
}
req = req.WithContext(ctx)

Context好处还在于如果parent context被取消的时候(在context.WithCancel调用的时候传递进来的),子context也会取消, 消息会进行传递。