天天看點

http 預設逾時時間_實戰:用取消參數使 Go net/http 服務更靈活服務逾時 — 基本原理timeout 和 deadline處理逾時Timeout未處理的逾時和請求取消上下文逾時和取消有彈性的 net/http 服務

關于逾時,可以把開發者分為兩類:一類是了解逾時多麼難以捉摸的人,另一類是正在感受逾時如何難以捉摸的人。

逾時既難以捉摸,卻又真實地存在于我們生活的由網絡連接配接的世界中。在我寫這篇文章的同時,隔壁兩個同僚正在用他們的智能手機打字,也許是在跟與他們相距萬裡的人聊天。網絡使這一切變為可能。

這裡要說的是網絡及其複雜性,作為寫網絡服務的我們,必須掌握如何高效地駕馭它們,并規避它們的缺陷。

閑話少說,來看看逾時和它們是如何影響我們的 net/http 服務的。

服務逾時 — 基本原理

web 程式設計中,逾時通常分為用戶端和服務端逾時兩種。我之是以要研究這個主題,是因為我自己遇到了一個有意思的服務端逾時的問題。這也是本文我們将要重點讨論服務側逾時的原因。

先解釋下基本術語:逾時是一個時間間隔(或邊界),用來辨別在這個時間段内要完成特定的行為。如果在給定的時間範圍内沒有完成操作,就産生了逾時,這個操作會被取消。

從一個 net/http 的服務的初始化中,能看出一些逾時的基礎配置:

srv := &http.Server{    ReadTimeout:       1 * time.Second,    WriteTimeout:      1 * time.Second,    IdleTimeout:       30 * time.Second,    ReadHeaderTimeout: 2 * time.Second,    TLSConfig:         tlsConfig,    Handler:           srvMux,}
           

http.Server 類型的服務可以用四個不同的 timeout 來初始化:

  • ReadTimeout:讀取包括請求體的整個請求的最大時長
  • WriteTimeout:寫響應允許的最大時長
  • IdleTimetout:當開啟了保持活動狀态(keep-alive)時允許的最大空閑時間
  • ReadHeaderTimeout:允許讀請求頭的最大時長

對上述逾時的圖表展示:

http 預設逾時時間_實戰:用取消參數使 Go net/http 服務更靈活服務逾時 — 基本原理timeout 和 deadline處理逾時Timeout未處理的逾時和請求取消上下文逾時和取消有彈性的 net/http 服務

服務生命周期和逾時

當心!不要以為這些就是你所需要的所有的逾時了。除此之外還有很多逾時,這些逾時提供了更小的粒度控制,對于我們的持續運作的 HTTP 處理器不會生效。

請聽我解釋。

timeout 和 deadline

如果我們檢視 net/http 的源碼,尤其是看到 `conn` 類型[1] 時,我們會發現conn 實際上使用了 net.Conn 連接配接,net.Conn 表示底層的網絡連接配接:

// Taken from: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L247// A conn represents the server-side of an HTTP connection.type conn struct {    // server is the server on which the connection arrived.    // Immutable; never nil.    server *Server    // * Snipped *    // rwc is the underlying network connection.    // This is never wrapped by other types and is the value given out    // to CloseNotifier callers. It is usually of type *net.TCPConn or    // *tls.Conn.    rwc net.Conn    // * Snipped *}
           

換句話說,我們的 HTTP 請求實際上是基于 TCP 連接配接的。從類型上看,TLS 連接配接是 *net.TCPConn 或 *tls.Conn 。

serve 函數[2]處理每一個請求[3]時調用 readRequest 函數。readRequest使用我們設定的 timeout 值[4]來設定 TCP 連接配接的 deadline:

// Taken from: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L936// Read next request from connection.func (c *conn) readRequest(ctx context.Context) (w *response, err error) {        // *Snipped*        t0 := time.Now()        if d := c.server.readHeaderTimeout(); d != 0 {                hdrDeadline = t0.Add(d)        }        if d := c.server.ReadTimeout; d != 0 {                wholeReqDeadline = t0.Add(d)        }        c.rwc.SetReadDeadline(hdrDeadline)        if d := c.server.WriteTimeout; d != 0 {                defer func() {                        c.rwc.SetWriteDeadline(time.Now().Add(d))                }()        }        // *Snipped*}
           

從上面的摘要中,我們可以知道:我們對服務設定的 timeout 值最終表現為 TCP 連接配接的 deadline 而不是 HTTP 逾時。

是以,deadline 是什麼?工作機制是什麼?如果我們的請求耗時過長,它們會取消我們的連接配接嗎?

一種簡單地了解 deadline 的思路是,把它了解為對作用于連接配接上的特定的行為的發生限制的一個時間點。例如,如果我們設定了一個寫的 deadline,當過了這個 deadline 後,所有對這個連接配接的寫操作都會被拒絕。

盡管我們可以使用 deadline 來模拟逾時操作,但我們還是不能控制處理器完成操作所需的耗時。deadline 作用于連接配接,是以我們的服務僅在處理器嘗試通路連接配接的屬性(如對 http.ResponseWriter 進行寫操作)之後才會傳回(錯誤)結果。

為了實際驗證上面的論述,我們來建立一個小的 handler,這個 handler 完成操作所需的耗時相對于我們為服務設定的逾時更長:

package mainimport ( "fmt" "io" "net/http" "time")func slowHandler(w http.ResponseWriter, req *http.Request) { time.Sleep(2 * time.Second) io.WriteString(w, "I am slow!")}func main() { srv := http.Server{  Addr:         ":8888",  WriteTimeout: 1 * time.Second,  Handler:      http.HandlerFunc(slowHandler), } if err := srv.ListenAndServe(); err != nil {  fmt.Printf("Server failed: %s", err) }}
           

上面的服務有一個 handler,這個 handler 完成操作需要兩秒。另一方面,http.Server 的 WriteTimeout 屬性設為 1 秒。基于服務的這些配置,我們猜測 handler 不能把響應寫到連接配接。

我們可以用 go run server.go 來啟動服務。使用 curl localhost:8888 來發送一個請求:

$ time curl localhost:8888curl: (52) Empty reply from servercurl localhost:8888  0.01s user 0.01s system 0% CPU 2.021 total
           

這個請求需要兩秒來完成處理,服務傳回的響應是空的。雖然我們的服務知道在 1 秒之後我們寫不了響應了,但 handler 還是多耗了 100% 的時間(2 秒)來完成處理。

雖然這是個類似逾時的處理,但它更大的作用是在到達逾時時間時,阻止服務進行更多的操作,結束請求。在我們上面的例子中,handler 在完成之前一直在處理請求,即使已經超出響應寫逾時時間(1 秒)100%(耗時 2 秒)。

最根本的問題是,對于處理器來說,我們應該怎麼設定逾時時間才更有效?

處理逾時

我們的目标是確定我們的 slowHandler 在 1s 内完成處理。如果超過了 1s,我們的服務會停止運作并傳回對應的逾時錯誤。

在 Go 和一些其它程式設計語言中,組合往往是設計和開發中最好的方式。标準庫的 `net/http` 包[5]有很多互相相容的元素,開發者可以不需經過複雜的設計考慮就可以輕易将它們組合在一起。

基于此,net/http 包提供了`TimeoutHandler`[6] — 傳回了一個在給定的時間限制内運作的 handler。

函數簽名:

func TimeoutHandler(h Handler, dt time.Duration, msg string) Handler
           

第一個參數是 Handler,第二個參數是 time.Duration (逾時時間),第三個參數是 string 類型,當到達逾時時間後傳回的資訊。

用 TimeoutHandler 來封裝我們的 slowHandler,我們隻需要:

package mainimport ( "fmt" "io" "net/http" "time")func slowHandler(w http.ResponseWriter, req *http.Request) { time.Sleep(2 * time.Second) io.WriteString(w, "I am slow!")}func main() { srv := http.Server{  Addr:         ":8888",  WriteTimeout: 5 * time.Second,  Handler:      http.TimeoutHandler(http.HandlerFunc(slowHandler), 1*time.Second, "Timeout!"), } if err := srv.ListenAndServe(); err != nil {  fmt.Printf("Server failed: %s", err) }}
           

兩個需要留意的地方是:

  • 我們在 http.TimetoutHandler 裡封裝 slowHanlder,逾時時間設為 1s,逾時資訊為 “Timeout!”。
  • 我們把 WriteTimeout 增加到 5s,以給予 http.TimeoutHandler 足夠的時間執行。如果我們不這麼做,當 TimeoutHandler 開始執行時,已經過了 deadline,不能再寫到響應。

如果我們再啟動服務,當程式運作到 slow handler 時,會有如下輸出:

$ time curl localhost:8888Timeout!curl localhost:8888  0.01s user 0.01s system 1% CPU 1.023 total
           

1s 後,我們的 TimeoutHandler 開始執行,阻止運作 slowHandler,傳回文本資訊 ”Timeout!“。如果我們設定資訊為空,handler 會傳回預設的逾時響應資訊,如下:

      Timeout       
           

Timeout

如果忽略掉輸出,這還算是整潔,不是嗎?現在我們的程式不會有過長耗時的處理;也避免了有人惡意發送導緻長耗時處理的請求時,導緻的潛在的 DoS 攻擊。

盡管我們設定逾時時間是一個偉大的開始,但它仍然隻是初級的保護。如果你可能會面臨 DoS 攻擊,你應該采用更進階的保護工具和技術。(可以試試Cloudflare[7] )

我們的 slowHandler 僅僅是個簡單的 demo。但是,如果我們的程式複雜些,能向其他服務和資源送出請求會發生什麼呢?如果我們的程式在逾時時向諸如 S3 的服務發出了請求會怎麼樣?

會發生什麼?

未處理的逾時和請求取消

我們稍微展開下我們的例子:

func slowAPICall() string { d := rand.Intn(5) select { case 
           

我們假設最初我們不知道 slowHandler 由于通過 slowAPICall 函數向 API 發請求導緻需要耗費這麼長時間才能處理完成,

slowAPICall 函數很簡單:使用 select 和一個能阻塞 0 到 5 秒的 time.After 。當經過了阻塞的時間後,time.After 方法通過它的 channel 發送一個值,傳回 "foobar" 。

(另一種方法是,使用 sleep(time.Duration(rand.Intn(5)) * time.Second),但我們仍然使用 select,因為它會使我們下面的例子更簡單。)

如果我們運作起服務,我們預期逾時 handler 會在 1 秒之後中斷請求處理。來發送一個請求驗證一下:

$ time curl localhost:8888Timeout!curl localhost:8888  0.01s user 0.01s system 1% CPU 1.021 total
           

通過觀察服務的輸出,我們會發現,它是在幾秒之後打出日志的,而不是在逾時 handler 生效時打出:

$ Go run server.go2019/12/29 17:20:03 Slow API call done after 4 seconds.
           

這個現象表明:雖然 1 秒之後請求逾時了,但是服務仍然完整地處理了請求。這就是在 4 秒之後才打出日志的原因。

雖然在這個例子裡問題很簡單,但是類似的現象在生産中可能變成一個嚴重的問題。例如,當 slowAPICall 函數開啟了幾個百個協程,每個協程都處理一些資料時。或者當它向不同系統發出多個不同的 API 送出請求時。這種耗時長的的程序,它們的請求方/用戶端并不會使用服務端的傳回結果,會耗盡你系統的資源。

是以,我們怎麼保護系統,使之不會出現類似的未優化的逾時或取消請求呢?

上下文逾時和取消

Go 有一個包名為 `context`[8] 專門處理類似的場景。

context 包在 Go 1.7 版本中提升為标準庫,在之前的版本中,以`golang.org/x/net/context`[9] 的路徑作為 Go Sub-repository Packages[10]出現。

這個包定義了 Context 類型。它最初的目的是儲存不同 API 和不同處理的截止時間、取消信号和其他請求相關的值。如果你想了解關于 context 包的其他資訊,可以閱讀 Golang's blog[11] 中的 “Go 并發模式:Context”(譯注:Go Concurrency Patterns: Context) .

net/http 包中的的 Request 類型已經有 context 與之綁定。從 Go 1.7 開始,Request 新增了一個傳回請求的上下文的 `Context` 方法[12]。對于進來的請求,在用戶端關閉連接配接、請求被取消(HTTP/2 中)或 ServeHTTP 方法傳回後,服務端會取消上下文。

我們期望的現象是,當用戶端取消請求(輸入了 CTRL + C)或一段時間後TimeoutHandler 繼續執行然後終止請求時,服務端會停止後續的處理。進而關閉所有的連接配接,釋放所有被運作中的處理程序(及它的所有子協程)占用的資源。

我們把 Context 作為參數傳給 slowAPICall 函數:

func slowAPICall(ctx context.Context) string { d := rand.Intn(5) select { case 
           

在例子中我們利用了請求上下文,實際中怎麼用呢?`Context` 類型[13]有個 Done 屬性,類型為 。當程序處理完成時,Done 關閉,此時表示上下文應該被取消,而這正是例子中我們需要的。

我們在 slowAPICall 函數中用 select 處理 ctx.Done 通道。當我們通過 Done 通道接收一個空的 struct 時,意味着上下文取消,我們需要讓 slowAPICall 函數傳回一個空字元串。

func slowAPICall(ctx context.Context) string { d := rand.Intn(5) select { case 
           

(這就是使用 select 而不是 time.Sleep -- 這裡我們隻能用 select 處理 Done 通道。)

在這個簡單的例子中,我們成功得到了結果 -- 當我們從 Done 通道接收值時,我們列印了一行日志到 STDOUT 并傳回了一個空字元串。在更複雜的情況下,如發送真實的 API 請求,你可能需要關閉連接配接或清理檔案描述符。

我們再啟動服務,發送一個 cRUL 請求:

# The cURL command:$ curl localhost:8888Timeout!# The server output:$ Go run server.go2019/12/30 00:07:15 slowAPICall was supposed to take 2 seconds, but was canceled.
           

檢查輸出:我們發送了 cRUL 請求到服務,它耗時超過 1 秒,服務取消了 slowAPICall 函數。我們幾乎不需要寫任何代碼。TimeoutHandler 為我們代勞了 -- 當處理耗時超過預期時,TimeoutHandler 終止了處理程序并取消請求上下文。

TimeoutHandler 是在 `timeoutHandler.ServeHTTP` 方法[14] 中取消上下文的:

// Taken from: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L3217-L3263func (h *timeoutHandler) ServeHTTP(w ResponseWriter, r *Request) {        ctx := h.testContext        if ctx == nil {         var cancelCtx context.CancelFunc         ctx, cancelCtx = context.WithTimeout(r.Context(), h.dt)         defer cancelCtx()        }        r = r.WithContext(ctx)        // *Snipped*}
           

上面例子中,我們通過調用 context.WithTimeout 來使用請求上下文。逾時值 h.dt (TimeoutHandler 的第二個參數)設定給了上下文。傳回的上下文是請求上下文設定了逾時值後的一份拷貝。随後,它作為請求上下文傳給r.WithContext(ctx)。

context.WithTimeout 方法執行了上下文取消。它傳回了 Context 設定了一個逾時值之後的副本。當到達逾時時間後,就取消上下文。

這裡是執行的代碼:

// Taken from: https://github.com/golang/go/blob/bbbc6589/src/context/context.go#L486-L498func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { return WithDeadline(parent, time.Now().Add(timeout))}// Taken from: https://github.com/golang/go/blob/bbbc6589/src/context/context.go#L418-L450func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {        // *Snipped*        c := &timerCtx{         cancelCtx: newCancelCtx(parent),         deadline:  d,        }        // *Snipped*        if c.err == nil {         c.timer = time.AfterFunc(dur, func() {          c.cancel(true, DeadlineExceeded)         })        }        return c, func() { c.cancel(true, Canceled) }}
           

這裡我們又看到了截止時間。WithDeadline 函數設定了一個 d 到達之後執行的函數。當到達截止時間後,它調用 cancel 方法處理上下文,此方法會關閉上下文的 done 通道并設定上下文的 timer 屬性為 nil。

Done 通道的關閉有效地取消了上下文,使我們的 slowAPICall 函數終止了它的執行。這就是 TimeoutHandler 終止耗時長的處理程序的原理。

(如果你想閱讀上面提到的源碼,你可以去看 `cancelCtx` 類型[15] 和`timerCtx` 類型[16])

有彈性的 net/http 服務

連接配接截止時間提供了低級的細粒度控制。雖然它們的名字中含有“逾時”,但它們并沒有表現出人們通常期望的“逾時”。實際上它們非常強大,但是使用它們有一定的門檻。

另一個角度講,當處理 HTTP 時,我們仍然應該考慮使用 TimeoutHandler。Go 的作者們也選擇使用它,它有多種處理,提供了如此有彈性的處理以至于我們甚至可以對每一個處理使用不同的逾時。TimeoutHandler 可以根據我們期望的表現來控制執行程序。

除此之外,TimeoutHandler 完美相容 context 包。context 包很簡單,包含了取消信号和請求相關的資料,我們可以使用這些資料來使我們的應用更好地處理錯綜複雜的網絡問題。

結束之前,有三個建議。寫 HTTP 服務時,怎麼設計逾時:

  1. 最常用的,到達 TimeoutHandler 時,怎麼處理。它進行我們通常期望的逾時處理。
  2. 不要忘記上下文取消。context 包使用起來很簡單,并且可以節省你伺服器上的很多處理資源。尤其是在處理異常或網絡狀況不好時。
  3. 一定要用截止時間。確定做了完整的測試,驗證了能提供你期望的所有功能。

更多關于此主題的文章:

  • “The complete guide to Go net/http timeouts” on Cloudflare's blog[17]
  • “So you want to expose Go on the Internet” on Cloudflare's blog[18]
  • “Use http.TimeoutHandler or ReadTimeout/WriteTimeout?” onStackoverflow[19]
  • “Standard net/http config will break your production environment” onSimon Frey's blog[20]