目录

Go Http超时控制

在用 Go 编写 HTTP 服务器或客户端时,超时是最容易出错的事情之一:有很多选择,而且一个错误可以在很长一段时间内不产生任何后果,直到网络出现故障,进程挂起。

服务端超时

/img/go-timeout.png

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

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

在 http.Server 中,有两种超时现象。ReadTimeout 和 WriteTimeout。你可以通过明确使用 Server.WriteTimeout 来设置它们:

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

ReadTimeout 涵盖了从接受连接到完全读完请求正文的时间。在 net/http 中,它是通过在接受后立即调用 SetReadDeadline 来实现的。

WriteTimeout 通常包括从请求头读取结束到响应写入结束的时间(也就是 ServeHTTP 的生命周期),通过在 readRequest 的末尾调用 SetWriteDeadline。

但当连接是 HTTPS 时,SetWriteDeadline 会在 Accept 之后立即被调用,因此它也包括作为 TLS 握手的一部分而写入的数据包。令人讨厌的是,这意味着(仅在这种情况下)WriteTimeout 最终会包括头的读取和第一个字节的等待。

最后,还有 http.TimeoutHandler。它不是一个服务器参数,而是一个处理用户 handler 的包装器,可以限制 ServeHTTP 调用的最大时间。它的工作原理是缓冲响应,如果超过了期限,则发送 504 网关超时。

http.ListenAndServe 可以用但不建议

当然这意味着 http.Server 的包级函数,例如 http.ListenAndServe、http.ListenAndServeTLS 和 http.Serve,不适合公共互联网服务器。

这些函数将超时设置为默认的关闭值,没有办法启用它们,所以如果你使用它们,你很快就会泄露连接并耗尽文件描述符。我至少犯过半打这样的错误。

非常遗憾的是,没有办法从 ServeHTTP 访问底层的 net.Conn,所以打算流式响应的服务器被迫取消设置 WriteTimeout(这也可能是它们默认为 0 的原因)。这是因为如果没有 net.Conn 的访问,就没有办法在每次写入前调用 SetWriteDeadline 来实现适当的空闲(而不是绝对)超时。issues

可以通过 Hijack 获取 net.Conn,既然可以可以获取 net.Conn,我们就可以调用它的 SetWriteDeadline 方法

客户端超时

/img/client-timeout.png

客户端超时可以更简单,也可以更复杂,这取决于你使用的是哪种超时,但对于防止资源泄漏或被卡住也同样重要。

最容易使用的是 http.Client 的 Timeout 字段。它涵盖了整个交换过程,从拨号(如果连接不被重复使用)到读取正文。

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

与上面的服务器端情况一样,包级函数如 http.Get 使用的是没有超时的客户端,所以在开放的互联网上使用是危险的。

为了进行更精细的控制,你还可以设置一些其他更具体的超时:

  • net.Dialer.Timeout 限制了建立一个 TCP 连接的时间(如果需要一个新的连接)。
  • http.Transport.TLSHandshakeTimeout 限制了执行 TLS 握手的时间。
  • http.Transport.ResponseHeaderTimeout 限制了读取响应头信息的时间。
  • http.Transport.ExpectContinueTimeout 限制了客户端在发送包含 Expect: 100-continue 的请求头信息和收到发送正文的指令之间的等待时间。请注意,在 1.6 版本中设置这个将禁用 HTTP/2(DefaultTransport 是从 1.6.2 开始特例的)。
 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,
    }
}

最后,在 1.7 中新增了 http.Transport.IdleConnTimeout。它并不控制客户端请求的阻塞阶段,而是控制一个空闲连接在连接池中保留多长时间。

注意,客户端默认会跟随重定向。http.Client.Timeout 包括所有跟随重定向的时间,而细化的超时是针对每个请求的,因为 http.Transport 是一个较低层次的系统,没有重定向的概念。

Cancel 和 Context

WithContext

 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)

NewRequestWithContext

1
2
3
4
5
6
7
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:8090/timeout", nil)
if err != nil {
	log.Fatal(err)
}

为了使用 Context 来取消一个请求,我们只需通过 context.WithCancel 获得一个新的 Context 和它的 cancel()函数,并通过 Request.WithContext 创建一个与之绑定的 Request。当我们想取消请求时,我们通过调用 cancel()来取消 Context(而不是关闭取消通道)。

上下文的好处是,如果父级上下文(传递给 context.WithCancel 的那个)被取消了,其它上下文也会被取消,将命令传播到整个管道。

工作中问题

用协程同时发起多个请求, 任一请求返回后就结束其它请求. “结束其它”有三次意思:

  1. 任一请求返回后, 客户端返回, 无需等待其它请求.(客户端实现
  2. 客户端还没发, 这种情况几乎遇不到, 协程发起是很快的通常.(客户端实现)
  3. 客户端已经发出请求, 唯一能做的就是通知服务端.(服务端实现)

Server

 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
package main

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

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		log.Printf("income %s\n", r.RequestURI)

		select {
		case <-r.Context().Done():
			log.Printf("client cancel %s\n", r.RequestURI)
			return
		default:
			time.Sleep(time.Second * 3)
			select {
			case <-r.Context().Done():
				log.Printf("client cancel %s\n", r.RequestURI)
				return
			default:
				log.Printf("finished %s \n", r.RequestURI)
			}
		}
	})

	http.ListenAndServe(":8080", nil)
}

Client

 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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package main

import (
	"context"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"strings"
	"time"
)

func main() {
	var result = make(chan string, 3)

	Tasks([]string{"http://127.0.0.1:8080?id=1", "http://127.0.0.1:8080?id=2", "http://127.0.0.1:8080?id=3"}, result)

	for r := range <-result {
		fmt.Println(r)
	}
}

func Tasks(urls []string, resChan chan<- string) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	log.Printf("requests: %s\n", strings.Join(urls, ","))

	for _, url := range urls {
		go Get(ctx, cancel, url, resChan)
		time.Sleep(time.Second)
	}

	<-ctx.Done()

}

func Get(ctx context.Context, cancel context.CancelFunc, url string, resChan chan<- string) error {
	log.Printf("start %s", url)

	defer cancel()

	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return fmt.Errorf("http.NewRequest Error: %s", err.Error())
	}

	client := &http.Client{
		Timeout: time.Second * 5,
	}

	resp, err := client.Do(req)
	if err != nil {
		return fmt.Errorf("client.Do Error: %s", err.Error())
	}

	data, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return fmt.Errorf("ioutil.ReadAll Error: %s", err.Error())
	}

	log.Printf("finished %s", url)
	resChan <- string(data)

	return nil
}