關於Go net/http 超時完全指南

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

使用Go編寫HTTP服務器或客戶端時,超時是最容易出錯的最容易發生的事情:一個錯誤可能在很長一段時間內沒有任何影響,直到網絡出現故障並掛起該進程爲止。

HTTP是一個複雜的多階段協議,因此沒有一個適合所有超時的解決方案。考慮一下流端點、JSON API和Comet端點。實際上,默認設置通常不是您想要的。

在本文中,我將介紹在服務器端和客戶端都可能會導致應用超時的各個階段,並探討解決超時的不同方法。

SetDeadline

首先,您需要了解Go用於實現超時的網絡原語:Deadlines。

net.Conn使用Set[Read|Write]Deadline(time.Time)方法公開,Deadlines是絕對時間,一旦到時,所有I/O操作都會因超時錯誤而失敗。

Deadlines不是超時。一旦設置,它們就會永久生效(直到下一次調用SetDeadline),不管在此期間是否使用連接以及如何使用連接。因此,要使用SetDeadline建立超時,您必須在每次讀/寫操作之前調用它。

實際開發中,你並不需要直接調用SetDeadline,而是在標準庫net/http中使用更高層次的超時設置。但是,請記住,所有超時都是根據Deadlines實現的,因此它們不會在每次發送或接收數據時重置。

Server Timeouts

“So you want to expose Go on the Internet”一文提供了有關服務器超時的更多信息,尤其是有關HTTP/2和Go 1.7的信息。

對於暴露於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完全被讀取(if you do read the body, otherwise to the end of the headers)。net/http的內部實現是在Accept之後立即調用SetReadDeadline。

WriteTimeout的時間計算正常是從request header的讀取結束開始,到response write結束爲止(也就是ServeHTTP的生命週期), 它是通過在readRequest方法結束的時候調用SetWriteDeadline實現的。

但是,當連接爲HTTPS時,會在Accept之後立即調用SetWriteDeadline,所以它的時間計算也包括 TLS握手時的寫的時間。令人討厭的是,這意味着(僅在這種情況下)WriteTimeout最終將包括Header和讀取body第一個字節這段時間。

當你處理不可信的客戶端和網絡的時候,你應該同時設置讀寫超時,這樣客戶端就不會因爲讀慢或者寫慢長久的持有這個連接了。

最後,還有http.TimeoutHandler。它不是一個Server參數,而是一個Handler包裝函數,限制了ServeHTTP調用的最大持續時間。它緩存response, 如果deadline超過了則發送504 Gateway Timeout錯誤。注意這個功能在1.6 中有問題,在1.6.2中改正了。

http.ListenAndServe is doing it wrong

順便說一句,這意味着繞過諸如http.ListenAndServe,http.ListenAndServeTLS和http.Serve之類的http.Server的程序包級便捷功能不適用於公共Internet服務器。

因爲這些函數默認關閉了超時設置,也無法手動設置。使用這些函數,將很快泄露連接,然後耗盡文件描述符。對於這點,我至少犯了6次以上這樣的錯誤。

對此,你應該使用http.server。在創建http.server實例的時候,調用相應的方法指定ReadTimeout(讀取超時時間)和WriteTimeout(寫超時時間),在以下會有一些案例。

About streaming

非常煩人的是,無法從ServeHTTP訪問底層net.Conn,因此打算流式傳輸響應的服務器被迫取消WriteTimeout的設置(這也可能是默認情況下它們爲0的原因)。這是因爲沒有net.Conn訪問,就無法在每次Write之前調用SetWriteDeadline來實現適當的空閒(不是絕對)超時。

同樣,也沒有辦法取消一個被阻塞的ResponseWriter。由於無法確認ResponseWriter.Close支持併發寫操作。因此,也沒有辦法使用計時器手動構建超時。

這意味着流媒體服務器面對一個低速客戶端時,將無法有效保障自身的效率、穩定

我提交了一個問題和一些建議,期待反饋。

譯者注:: 原文作者此處的說法有問題,其實通過Hijack是可以獲取到net.Conn的。

Client Timeouts

客戶端超時,可以很簡單,也可以很複雜,取決於你怎麼用。但同樣重要的是:要防止資源泄漏和阻塞。

最簡單的使用超時的方式是http.Client。它涵蓋整個交互過程,從發起連接(如果未重用連接)到接收響應報文結束。

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 限制客戶端在發送一個包含:100-continue的http報文頭後,等待收到一個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,
    }
}

據我瞭解,尚沒有限制發送請求使用時間的機制。目前的解決方案是,在客戶端方法返回後,通過time.Timer來個手工控制讀取請求信息的時間(參見下面的“如何取消請求”)。

最後,在新的1.7版本中,提供了http.Transport.IdleConnTimeout。它用於控制一個閒置連接在連接池中的保留時間,而不考慮一個客戶端請求被阻塞在哪個階段。

請注意,客戶端將使用默認的重定向機制。由於http.Transport是一個底層的系統機制,沒有重定向概念,因此http.Client.Timeout涵蓋了用於重定向花費的時間,而更精細的超時控,可以根據請求的不同,進行定製。

Cancel and Context

net/http提供了兩種方式取消一個client的請求: Request.Cancel以及Go 1.7新加的Context。

Request.Cancel是一個可選channel。在Request.Timeout被觸發時,Request.Cancel將被設置並關閉,進而促使請求中斷(基本上“撤銷”都採用相同的機制,在寫此文時,我發現一個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
42
package main

import (
	"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)
		}
	}
}

在上面這個例子中,我們在請求階段,設置了一個5秒鐘的超時。但讀取響應報文階段,我們需要讀8次,至少8秒鐘的時間。每次讀操作,設置2秒鐘的超時。採用這樣的機制,我們可以無限制的獲取流媒體,而不用擔心阻塞的風險。如果我們沒有在2秒鐘內讀取到任何數據,io.CopyN將返回錯誤信息:net/http: request canceled.。

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也會取消, 消息會進行傳遞。

< END >

 or 

            

推薦閱讀:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章