當用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是一個絕對時間值”,不是真的超時機制,所以作者特別提醒,這個值不會自動重置的,需要每次手動設定。
伺服器端超時設定
對於暴露在網上的伺服器來說,為客戶端連線設定超時至關重要,否則巨慢的或者隱失的客戶端可能導致檔案控制程式碼無法釋放,最終導致伺服器出現下面的錯誤:
1 |
http: Accept error: accept tcp [::]:80: accept4: too many open files; retrying in 5ms |
http.Server
有兩個設定超時的方法: ReadTimeout
和 and
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為止)。它的內部實現是在Accept
立即呼叫SetReadDeadline
方法(程式碼行)。
1 2 3 4 5 6 7 8 |
…… 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
實現的(程式碼行)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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第一個位元組這段時間。
1 2 3 4 5 6 7 8 |
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.ListenAndServeTLS
和 http.Serve
並不適合實現網際網路的伺服器。這些函式讓超時設定預設不啟用,並且你沒有辦法設定啟用超時處理。所以如果你使用它們,你會很快發現連線洩漏,太多的檔案控制程式碼。我犯過這種錯誤至少五六次。
取而代之,你應該建立一個http.Server
示例,設定ReadTimeout
和WriteTimeout
,像上面的例子中一樣使用相應的方法。
關於流
令人心塞的是, 沒有辦法從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方法。程式碼例子如下:
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 |
package main import ( "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.Client
的 Timeout
欄位。 它的時間計算包括從連線(Dial)到讀完response body。
1 2 3 4 |
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起是個特例)
1 2 3 4 5 6 7 8 9 10 11 |
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.Cancel
和time.Timer
來構建一個細粒度的超時控制,允許讀取流資料的時候推遲deadline:
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 |
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) } } } |
上面的例子中我們為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:
1 2 3 4 5 6 7 8 9 |
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也會取消, 命令會進行傳遞。