[译]关于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 | srv := &http.Server{ |
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 握手的超时处理也应以实际版本为准。

Client Timeouts

客户端超时,可以很简单,也可以很复杂,取决于你怎么用。但同样重要的是:要防止资源泄漏和阻塞。
最简单的使用超时的方式是http.Client。它涵盖整个交互过程,从发起连接(如果未重用连接)到读完响应体(body)。
1 | c := &http.Client{ |
与服务端情况类似,程序包级别的功能(例如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 | c := &http.Client{ |
据原文作者了解,当时没有专门限制发送请求所用时间的机制。客户端方法返回后,读取响应体(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 | package main |
在上面这个例子中,我们在请求阶段设置了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 | ctx, cancel := context.WithCancel(context.TODO()) |
Context好处还在于如果parent context被取消的时候(在context.WithCancel调用的时候传递进来的),子context也会取消, 消息会进行传递。