深入理解 Golang HTTP Timeout


原文链接: 深入理解 Golang HTTP Timeout
  1. Go实现超时是基于 Deadline (最后期限)
  2. Deadline是一个绝对时间值,当到达这个时间的时候,所有的 I/O 操作都会失败,返回超时(timeout)错误。
  3. Deadline不是超时(timeout)。一旦设置它们永久生效(或者直到下一次调用SetDeadline)
  4. timeout是高层封装的函数,所有的超时(timeout)的实现都是基于Deadline
  5. 所以每次接收或者发送数据,都不会重新设置Deadline


原文地址 https://colobu.com/2016/07/01/the-complete-guide-to-golang-net-http-timeouts/

目录 [−]

  1. SetDeadline
  2. 服务器端超时设置
    1. http.ListenAndServe 的错误
    2. 关于流
  3. 客户端超时设置
  4. Cancel 和 Context

英文原始出处: The complete guide to Go net/http timeouts, 作者: Filippo Valsorda

当用 Go 写 HTTP 的服务器和客户端的时候,超时处理总是最易犯错和最微妙的地方之一。错误可能来自很多地方,一个错误可能等待很长时间没有结果,直到网络故障或者进程挂起。

HTTP 是一个复杂的、多阶段 (multi-stage) 协议,所以没有一个放之四海而皆准的超时解决方案,比如一个流服务、一个 JSON API 和一个 Comet 服务对超时的需求都不相同, 往往默认值不是你想要的。

本文我将拆解需要超时设置的各个阶段,看看用什么不同的方式去处理它, 包括服务器端和客户端。

SetDeadline

首先,你需要了解 Go 实现超时的网络原语 (primitive): Deadline (最后期限)。

net.Conn为 Deadline 提供了多个方法Set[Read|Write]Deadline(time.Time)。Deadline 是一个绝对时间值,当到达这个时间的时候,所有的 I/O 操作都会失败,返回超时 (timeout) 错误。

Deadline 不是超时 (timeout)。一旦设置它们永久生效 (或者直到下一次调用 SetDeadline), 不管此时连接是否被使用和怎么用。所以如果想使用SetDeadline建立超时机制,你不得不每次在Read/Write操作之前调用它。

你可能不想自己调用SetDeadline, 而是让net/http代替你调用,所以你可以调用更高级的 timeout 方法。但是请记住,所有的超时的实现都是基于 Deadline, 所以它们不会每次接收或者发送重新设置这个值 (so they do NOT reset every time data is sent or received)。

江南雨的指正:
应该是由于 “Deadline 是一个绝对时间值”,不是真的超时机制,所以作者特别提醒,这个值不会自动重置的,需要每次手动设置。

服务器端超时设置

对于暴露在网上的服务器来说,为客户端连接设置超时至关重要,否则巨慢的或者隐失的客户端可能导致文件句柄无法释放,最终导致服务器出现下面的错误:

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

http.Server有两个设置超时的方法: ReadTimeoutandWriteTimeout`。你可以显示地设置它们:

12345

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

ReadTimeout的时间计算是从连接被接受 (accept) 到 request body 完全被读取(如果你不读取 body,那么时间截止到读完 header 为止)。它的内部实现是在Accept立即调用SetReadDeadline方法 (代码行)。

…… if d := c.server.ReadTimeout; d != 0 { c.rwc.SetReadDeadline(time.Now().Add(d))}if d := c.server.WriteTimeout; d != 0 { c.rwc.SetWriteDeadline(time.Now().Add(d))} ……

WriteTimeout的时间计算正常是从 request header 的读取结束开始,到 response write 结束为止 (也就是 ServeHTTP 方法的声明周期), 它是通过在readRequest方法结束的时候调用SetWriteDeadline实现的 (代码行)。

func (c *conn) readRequest(ctx context.Context) (w *response, err error) { if c.hijacked() { return nil, ErrHijacked } if d := c.server.ReadTimeout; d != 0 { c.rwc.SetReadDeadline(time.Now().Add(d)) } if d := c.server.WriteTimeout; d != 0 { defer func() { c.rwc.SetWriteDeadline(time.Now().Add(d)) }() } ……}

但是,当连接是 HTTPS 的时候,SetWriteDeadline会在Accept之后立即调用 (代码),所以它的时间计算也包括 TLS 握手时的写的时间。 讨厌的是, 这就意味着 (也只有这种情况) WriteTimeout设置的时间也包含读取 Headerd 到读取 body 第一个字节这段时间。

if tlsConn, ok := c.rwc.(*tls.Conn); ok { if d := c.server.ReadTimeout; d != 0 { c.rwc.SetReadDeadline(time.Now().Add(d)) } if d := c.server.WriteTimeout; d != 0 { c.rwc.SetWriteDeadline(time.Now().Add(d)) } ……

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

最后,还有一个http.TimeoutHandler方法。 它并不是 Server 参数,而是一个 Handler 包装函数,可以限制 ServeHTTP调用。它缓存 response, 如果 deadline 超过了则发送 504 Gateway Timeout 错误。 注意这个功能在 1.6 中有问题,在 1.6.2 中改正了

http.ListenAndServe 的错误

顺便提一句,net/http包下的封装的绕过http.Server的函数http.ListenAndServe, http.ListenAndServeTLShttp.Serve并不适合实现互联网的服务器。这些函数让超时设置默认不启用,并且你没有办法设置启用超时处理。所以如果你使用它们,你会很快发现连接泄漏,太多的文件句柄。我犯过这种错误至少五六次。

取而代之,你应该创建一个http.Server示例,设置ReadTimeoutWriteTimeout, 像上面的例子中一样使用相应的方法。

关于流

令人心塞的是, 没有办法从ServeHTTP中访问底层的net.Conn,所以提供流服务强制不去设置WriteTimeout(这也可能是为什么这些值的默认值总为 0)。如果无法访问net.Conn就不能在每次Write的时候调用SetWriteDeadline来实现一个正确的 idle timeout。

而且,也没有办法取消一个阻塞的ResponseWriter.Write,因为ResponseWriter.Close没有文档指出它可以取消一个阻塞并发写。也没有办法使用 Timer 创建以俄国手工的 timeout 杯具就是流服务器不能对于慢读的客户端进行防护。我提交的了一个[bug](https://github.com/golang/go/issues/16100),欢迎大家反馈。,欢迎大家反馈。)

编者按: 作者此处的说法是有问题的,可以通过 Hijack 获取 net.Conn, 既然可以可以获取 net.Conn, 我们就可以调用它的 SetWriteDeadline 方法。代码例子如下:

package mainimport ( "fmt" "log" "net/http")func main() { http.HandleFunc("/hijack", func(w http.ResponseWriter, r *http.Request) { hj, ok := w.(http.Hijacker) if !ok { http.Error(w, "webserver doesn't support hijacking", http.StatusInternalServerError) return } conn, bufrw, err := hj.Hijack() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Don't forget to close the connection: defer conn.Close() conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) bufrw.WriteString("Now we're speaking raw TCP. Say hi: ") bufrw.Flush() s, err := bufrw.ReadString('\n') if err != nil { log.Printf("error reading string: %v", err) return } fmt.Fprintf(bufrw, "You said: %q\nBye.\n", s) bufrw.Flush() })}

客户端超时设置

Client 端的超时设置说复杂也复杂,说简单也简单,看你怎么用了,最重要的就是不要有资源泄漏的情况或者程序被卡住。

最简单的方式就是使用http.ClientTimeout字段。 它的时间计算包括从连接 (Dial) 到读完 response body。

c := &http.Client{ Timeout: 15 * time.Second,}resp, err := c.Get("https://blog.filippo.io/")

就像服务器端一样,http.GET使用 Client 的时候也没有超时设置, 所以在互联网上使用也很危险。

有一些更细粒度的超时控制:

  • net.Dialer.Timeout 限制建立 TCP 连接的时间
  • http.Transport.TLSHandshakeTimeout 限制 TLS 握手的时间
  • http.Transport.ResponseHeaderTimeout 限制读取 response header 的时间
  • http.Transport.ExpectContinueTimeout 限制 client 在发送包含 Expect: 100-continue的 header 到收到继续发送 body 的 response 之间的时间等待。注意在 1.6 中设置这个值会禁用 HTTP/2(DefaultTransport自 1.6.2 起是个特例)



c := &http.Client{ Transport: &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, }}

如我所讲,没有办法限制发送 request 的时间。读取 response body (原文是读取 request body,按照理解应该是读取 response 可以手工控制) 的时间花费可以手工的通过一个time.Timer来实现, 读取发生在调用 Client.Do 之后(详见下一节)。

最后将一点,在 Go 1.7 中,增加了一个http.Transport.IdleConnTimeout, 它不控制 client request 的阻塞阶段,但是可以控制连接池中一个连接可以 idle 多长时间。

注意一个 Client 缺省的可以执行 _redirect_。http.Client.Timeout包含所有的redirect,而细粒度的超时控制参数只针对单次请求有效, 因为http.Transport是一个底层的类型,没有redirect的概念。

Cancel 和 Context

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

Request.Cancel是一个可选的 channel, 当设置这个值并且 close 它的时候,request 就会终止,就好像超时了一样 (实际它们的实现是一样的,在写本文的时候我还发现一个 1.7 的 一个 bug, 所有的 cancel 操作返回的错误还是 timeout error )。

我们可以使用Request.Canceltime.Timer来构建一个细粒度的超时控制,允许读取流数据的时候推迟 deadline:

package mainimport ( "io" "io/ioutil" "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(ioutil.Discard, resp.Body, 256) if err == io.EOF { break } else if err != nil { log.Fatal(err) } }}

上面的例子中我们为 Do 方法执行阶段设置 5 秒的超时,但是我们至少花费 8 秒执行 8 次才能读完所欲的 body,每一次设置 2 秒的超时。我们可以为流 API 这样处理避免程序死在那里。 如果超过两秒我们没有从服务器读取到数据, io.CopyN 会返回net/http: request canceled错误。

在 1.7 中, context 包升级了,进入到标准库中。Context 有很多值得学习的功能,但是对于本文介绍的内容来讲,你只需直到它可以用来替换和扔掉Request.Cancel

用 Context 取消请求很简单,我们只需得到一个新的 Context 和它的 cancel() 函数,这是通过 context.WithCancel 方法得到的,然后创建一个 request 并使用Request.WithContext绑定它。当我们想取消这个请求是,我们调用cancel()取消这个 Context:

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 也会取消, 命令会进行传递。

好了,这就是本文要讲的全部,希望我没有超过你的阅读 deadline。

作者的公司 cloudflare 在英国、美国和新加坡招人。云初创公司,挺知名。

`