實戰(zhàn):用取消參數(shù)使 Go net/http 服務更靈活
關于超時,可以把開發(fā)者分為兩類:一類是了解超時多么難以捉摸的人,另一類是正在感受超時如何難以捉摸的人。
超時既難以捉摸,卻又真實地存在于我們生活的由網(wǎng)絡連接的世界中。在我寫這篇文章的同時,隔壁兩個同事正在用他們的智能手機打字,也許是在跟與他們相距萬里的人聊天。網(wǎng)絡使這一切變?yōu)榭赡堋?/p>
這里要說的是網(wǎng)絡及其復雜性,作為寫網(wǎng)絡服務的我們,必須掌握如何高效地駕馭它們,并規(guī)避它們的缺陷。
閑話少說,來看看超時和它們是如何影響我們的 net/http 服務的。
服務超時 — 基本原理
web 編程中,超時通常分為客戶端和服務端超時兩種。我之所以要研究這個主題,是因為我自己遇到了一個有意思的服務端超時的問題。這也是本文我們將要重點討論服務側超時的原因。
先解釋下基本術語:超時是一個時間間隔(或邊界),用來標識在這個時間段內要完成特定的行為。如果在給定的時間范圍內沒有完成操作,就產(chǎn)生了超時,這個操作會被取消。
從一個 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:當開啟了保持活動狀態(tài)(keep-alive)時允許的最大空閑時間
- ReadHeaderTimeout:允許讀請求頭的最大時長
對上述超時的圖表展示:

服務生命周期和超時
當心!不要以為這些就是你所需要的所有的超時了。除此之外還有很多超時,這些超時提供了更小的粒度控制,對于我們的持續(xù)運行的 HTTP 處理器不會生效。
請聽我解釋。
timeout 和 deadline
如果我們查看 net/http 的源碼,尤其是看到 `conn` 類型[1] 時,我們會發(fā)現(xiàn)conn 實際上使用了 net.Conn 連接,net.Conn 表示底層的網(wǎng)絡連接:
- // 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 函數(shù)[2]處理每一個請求[3]時調用 readRequest 函數(shù)。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 值最終表現(xiàn)為 TCP 連接的 deadline 而不是 HTTP 超時。
所以,deadline 是什么?工作機制是什么?如果我們的請求耗時過長,它們會取消我們的連接嗎?
一種簡單地理解 deadline 的思路是,把它理解為對作用于連接上的特定的行為的發(fā)生限制的一個時間點。例如,如果我們設置了一個寫的 deadline,當過了這個 deadline 后,所有對這個連接的寫操作都會被拒絕。
盡管我們可以使用 deadline 來模擬超時操作,但我們還是不能控制處理器完成操作所需的耗時。deadline 作用于連接,因此我們的服務僅在處理器嘗試訪問連接的屬性(如對 http.ResponseWriter 進行寫操作)之后才會返回(錯誤)結果。
為了實際驗證上面的論述,我們來創(chuàng)建一個小的 handler,這個 handler 完成操作所需的耗時相對于我們?yōu)榉赵O置的超時更長:
- package main
- import (
- "fmt"
- "io"
- "net/http"
- "time"
- )
- func slowHandler(w http.ResponseWriter, req *http.Request) {
- time.Sleep(2 * time.Second)
- io.WriteString(w, "I am slow!\n")
- }
- 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\n", err)
- }
- }
上面的服務有一個 handler,這個 handler 完成操作需要兩秒。另一方面,http.Server 的 WriteTimeout 屬性設為 1 秒?;诜盏倪@些配置,我們猜測 handler 不能把響應寫到連接。
我們可以用 go run server.go 來啟動服務。使用 curl localhost:8888 來發(fā)送一個請求:
- $ time curl localhost:8888
- curl: (52) Empty reply from server
- curl localhost:8888 0.01s user 0.01s system 0% CPU 2.021 total
這個請求需要兩秒來完成處理,服務返回的響應是空的。雖然我們的服務知道在 1 秒之后我們寫不了響應了,但 handler 還是多耗了 100% 的時間(2 秒)來完成處理。
雖然這是個類似超時的處理,但它更大的作用是在到達超時時間時,阻止服務進行更多的操作,結束請求。在我們上面的例子中,handler 在完成之前一直在處理請求,即使已經(jīng)超出響應寫超時時間(1 秒)100%(耗時 2 秒)。
最根本的問題是,對于處理器來說,我們應該怎么設置超時時間才更有效?
處理超時
我們的目標是確保我們的 slowHandler 在 1s 內完成處理。如果超過了 1s,我們的服務會停止運行并返回對應的超時錯誤。
在 Go 和一些其它編程語言中,組合往往是設計和開發(fā)中最好的方式。標準庫的 `net/http` 包[5]有很多相互兼容的元素,開發(fā)者可以不需經(jīng)過復雜的設計考慮就可以輕易將它們組合在一起。
基于此,net/http 包提供了`TimeoutHandler`[6] — 返回了一個在給定的時間限制內運行的 handler。
函數(shù)簽名:
- func TimeoutHandler(h Handler, dt time.Duration, msg string) Handler
第一個參數(shù)是 Handler,第二個參數(shù)是 time.Duration (超時時間),第三個參數(shù)是 string 類型,當?shù)竭_超時時間后返回的信息。
用 TimeoutHandler 來封裝我們的 slowHandler,我們只需要:
- package main
- import (
- "fmt"
- "io"
- "net/http"
- "time"
- )
- func slowHandler(w http.ResponseWriter, req *http.Request) {
- time.Sleep(2 * time.Second)
- io.WriteString(w, "I am slow!\n")
- }
- func main() {
- srv := http.Server{
- Addr: ":8888",
- WriteTimeout: 5 * time.Second,
- Handler: http.TimeoutHandler(http.HandlerFunc(slowHandler), 1*time.Second, "Timeout!\n"),
- }
- if err := srv.ListenAndServe(); err != nil {
- fmt.Printf("Server failed: %s\n", err)
- }
- }
兩個需要留意的地方是:
- 我們在 http.TimetoutHandler 里封裝 slowHanlder,超時時間設為 1s,超時信息為 “Timeout!”。
- 我們把 WriteTimeout 增加到 5s,以給予 http.TimeoutHandler 足夠的時間執(zhí)行。如果我們不這么做,當 TimeoutHandler 開始執(zhí)行時,已經(jīng)過了 deadline,不能再寫到響應。
如果我們再啟動服務,當程序運行到 slow handler 時,會有如下輸出:
- $ time curl localhost:8888
- Timeout!
- curl localhost:8888 0.01s user 0.01s system 1% CPU 1.023 total
1s 后,我們的 TimeoutHandler 開始執(zhí)行,阻止運行 slowHandler,返回文本信息 ”Timeout!“。如果我們設置信息為空,handler 會返回默認的超時響應信息,如下:
- <html>
- <head>
- <title>Timeout</title>
- </head>
- <body>
- <h1>Timeout</h1>
- </body>
- </html>
如果忽略掉輸出,這還算是整潔,不是嗎?現(xiàn)在我們的程序不會有過長耗時的處理;也避免了有人惡意發(fā)送導致長耗時處理的請求時,導致的潛在的 DoS 攻擊。
盡管我們設置超時時間是一個偉大的開始,但它仍然只是初級的保護。如果你可能會面臨 DoS 攻擊,你應該采用更高級的保護工具和技術。(可以試試Cloudflare[7] )
我們的 slowHandler 僅僅是個簡單的 demo。但是,如果我們的程序復雜些,能向其他服務和資源發(fā)出請求會發(fā)生什么呢?如果我們的程序在超時時向諸如 S3 的服務發(fā)出了請求會怎么樣?
會發(fā)生什么?
未處理的超時和請求取消
我們稍微展開下我們的例子:
- func slowAPICall() string {
- d := rand.Intn(5)
- select {
- case <-time.After(time.Duration(d) * time.Second):
- log.Printf("Slow API call done after %s seconds.\n", d)
- return "foobar"
- }
- }
- func slowHandler(w http.ResponseWriter, r *http.Request) {
- result := slowAPICall()
- io.WriteString(w, result+"\n")
- }
我們假設最初我們不知道 slowHandler 由于通過 slowAPICall 函數(shù)向 API 發(fā)請求導致需要耗費這么長時間才能處理完成,
slowAPICall 函數(shù)很簡單:使用 select 和一個能阻塞 0 到 5 秒的 time.After 。當經(jīng)過了阻塞的時間后,time.After 方法通過它的 channel 發(fā)送一個值,返回 "foobar" 。
(另一種方法是,使用 sleep(time.Duration(rand.Intn(5)) * time.Second),但我們仍然使用 select,因為它會使我們下面的例子更簡單。)
如果我們運行起服務,我們預期超時 handler 會在 1 秒之后中斷請求處理。來發(fā)送一個請求驗證一下:
- $ time curl localhost:8888
- Timeout!
- curl localhost:8888 0.01s user 0.01s system 1% CPU 1.021 total
通過觀察服務的輸出,我們會發(fā)現(xiàn),它是在幾秒之后打出日志的,而不是在超時 handler 生效時打出:
- $ Go run server.go
- 2019/12/29 17:20:03 Slow API call done after 4 seconds.
這個現(xiàn)象表明:雖然 1 秒之后請求超時了,但是服務仍然完整地處理了請求。這就是在 4 秒之后才打出日志的原因。
雖然在這個例子里問題很簡單,但是類似的現(xiàn)象在生產(chǎn)中可能變成一個嚴重的問題。例如,當 slowAPICall 函數(shù)開啟了幾個百個協(xié)程,每個協(xié)程都處理一些數(shù)據(jù)時?;蛘弋斔虿煌到y(tǒng)發(fā)出多個不同的 API 發(fā)出請求時。這種耗時長的的進程,它們的請求方/客戶端并不會使用服務端的返回結果,會耗盡你系統(tǒng)的資源。
所以,我們怎么保護系統(tǒng),使之不會出現(xiàn)類似的未優(yōu)化的超時或取消請求呢?
上下文超時和取消
Go 有一個包名為 `context`[8] 專門處理類似的場景。
context 包在 Go 1.7 版本中提升為標準庫,在之前的版本中,以`golang.org/x/net/context`[9] 的路徑作為 Go Sub-repository Packages[10]出現(xiàn)。
這個包定義了 Context 類型。它最初的目的是保存不同 API 和不同處理的截止時間、取消信號和其他請求相關的值。如果你想了解關于 context 包的其他信息,可以閱讀 Golang's blog[11] 中的 “Go 并發(fā)模式:Context”(譯注:Go Concurrency Patterns: Context) .
net/http 包中的的 Request 類型已經(jīng)有 context 與之綁定。從 Go 1.7 開始,Request 新增了一個返回請求的上下文的 `Context` 方法[12]。對于進來的請求,在客戶端關閉連接、請求被取消(HTTP/2 中)或 ServeHTTP 方法返回后,服務端會取消上下文。
我們期望的現(xiàn)象是,當客戶端取消請求(輸入了 CTRL + C)或一段時間后TimeoutHandler 繼續(xù)執(zhí)行然后終止請求時,服務端會停止后續(xù)的處理。進而關閉所有的連接,釋放所有被運行中的處理進程(及它的所有子協(xié)程)占用的資源。
我們把 Context 作為參數(shù)傳給 slowAPICall 函數(shù):
- func slowAPICall(ctx context.Context) string {
- d := rand.Intn(5)
- select {
- case <-time.After(time.Duration(d) * time.Second):
- log.Printf("Slow API call done after %d seconds.\n", d)
- return "foobar"
- }
- }
- func slowHandler(w http.ResponseWriter, r *http.Request) {
- result := slowAPICall(r.Context())
- io.WriteString(w, result+"\n")
- }
在例子中我們利用了請求上下文,實際中怎么用呢?`Context` 類型[13]有個 Done 屬性,類型為 <-chan struct{}。當進程處理完成時,Done 關閉,此時表示上下文應該被取消,而這正是例子中我們需要的。
我們在 slowAPICall 函數(shù)中用 select 處理 ctx.Done 通道。當我們通過 Done 通道接收一個空的 struct 時,意味著上下文取消,我們需要讓 slowAPICall 函數(shù)返回一個空字符串。
- func slowAPICall(ctx context.Context) string {
- d := rand.Intn(5)
- select {
- case <-ctx.Done():
- log.Printf("slowAPICall was supposed to take %s seconds, but was canceled.", d)
- return ""
- //time.After() 可能會導致內存泄漏
- case <-time.After(time.Duration(d) * time.Second):
- log.Printf("Slow API call done after %d seconds.\n", d)
- return "foobar"
- }
- }
(這就是使用 select 而不是 time.Sleep -- 這里我們只能用 select 處理 Done 通道。)
在這個簡單的例子中,我們成功得到了結果 -- 當我們從 Done 通道接收值時,我們打印了一行日志到 STDOUT 并返回了一個空字符串。在更復雜的情況下,如發(fā)送真實的 API 請求,你可能需要關閉連接或清理文件描述符。
我們再啟動服務,發(fā)送一個 cRUL 請求:
- # The cURL command:
- $ curl localhost:8888
- Timeout!
- # The server output:
- $ Go run server.go
- 2019/12/30 00:07:15 slowAPICall was supposed to take 2 seconds, but was canceled.
檢查輸出:我們發(fā)送了 cRUL 請求到服務,它耗時超過 1 秒,服務取消了 slowAPICall 函數(shù)。我們幾乎不需要寫任何代碼。TimeoutHandler 為我們代勞了 -- 當處理耗時超過預期時,TimeoutHandler 終止了處理進程并取消請求上下文。
TimeoutHandler 是在 `timeoutHandler.ServeHTTP` 方法[14] 中取消上下文的:
- // Taken from: https://github.com/golang/go/blob/bbbc658/src/net/http/server.go#L3217-L3263
- func (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 的第二個參數(shù))設置給了上下文。返回的上下文是請求上下文設置了超時值后的一份拷貝。隨后,它作為請求上下文傳給r.WithContext(ctx)。
context.WithTimeout 方法執(zhí)行了上下文取消。它返回了 Context 設置了一個超時值之后的副本。當?shù)竭_超時時間后,就取消上下文。
這里是執(zhí)行的代碼:
- // Taken from: https://github.com/golang/go/blob/bbbc6589/src/context/context.go#L486-L498
- func 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-L450
- func 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 函數(shù)設置了一個 d 到達之后執(zhí)行的函數(shù)。當?shù)竭_截止時間后,它調用 cancel 方法處理上下文,此方法會關閉上下文的 done 通道并設置上下文的 timer 屬性為 nil。
Done 通道的關閉有效地取消了上下文,使我們的 slowAPICall 函數(shù)終止了它的執(zhí)行。這就是 TimeoutHandler 終止耗時長的處理進程的原理。
(如果你想閱讀上面提到的源碼,你可以去看 `cancelCtx` 類型[15] 和`timerCtx` 類型[16])
有彈性的 net/http 服務
連接截止時間提供了低級的細粒度控制。雖然它們的名字中含有“超時”,但它們并沒有表現(xiàn)出人們通常期望的“超時”。實際上它們非常強大,但是使用它們有一定的門檻。
另一個角度講,當處理 HTTP 時,我們仍然應該考慮使用 TimeoutHandler。Go 的作者們也選擇使用它,它有多種處理,提供了如此有彈性的處理以至于我們甚至可以對每一個處理使用不同的超時。TimeoutHandler 可以根據(jù)我們期望的表現(xiàn)來控制執(zhí)行進程。
除此之外,TimeoutHandler 完美兼容 context 包。context 包很簡單,包含了取消信號和請求相關的數(shù)據(jù),我們可以使用這些數(shù)據(jù)來使我們的應用更好地處理錯綜復雜的網(wǎng)絡問題。
結束之前,有三個建議。寫 HTTP 服務時,怎么設計超時:
- 最常用的,到達 TimeoutHandler 時,怎么處理。它進行我們通常期望的超時處理。
- 不要忘記上下文取消。context 包使用起來很簡單,并且可以節(jié)省你服務器上的很多處理資源。尤其是在處理異?;蚓W(wǎng)絡狀況不好時。
- 一定要用截止時間。確保做了完整的測試,驗證了能提供你期望的所有功能。


2020-09-14 09:33:02




