context canceled,誰是罪魁禍首?

nanjingfm發表於2021-12-23

靈魂三問:

  1. 客戶端請求超時,取消了請求,服務端還會繼續執行麼?
  2. 客戶端請求超時,取消了請求,服務端還會返回結果麼?
  3. 客戶端請求超時,取消了請求,服務端會報錯麼?

告警群裡有告警,定位到報錯的微服務看到如下報錯:Post http://ms-user-go.mp.online/user/listByIDs: context canceled

image-20211223104344170

專案中沒有發現cancel context的程式碼,那麼context canceled的錯誤是哪裡來的?

特別說明,這裡討論的context是指gin Context中的Request Context

image-20211223164714302

image-20211223120759735

  • client請求server1時設定5s超時
  • server1收到請求時先sleep 3秒,然後請求server2並設定5s超時
  • server2收到請求時sleep5

畫個時序圖看的更直觀(看完文章你會發現這是錯的):

image-20211223121138694

程式碼如下:

client:

// client
func main() {
    ctx, cancelFun := context.WithTimeout(context.Background(), time.Second*5)
    defer cancelFun()
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:8887/sleep/3", nil)
    if err != nil {
          panic(err)
    }
    do, err := http.DefaultClient.Do(req)
    spew.Dump(do, err)
}

server 1:

// server 1
func main() {
    server := gin.New()
    server.GET("/sleep/:time", func(c *gin.Context) {
        t := c.Param("time")
        t1, _ := strconv.Atoi(t)
        time.Sleep(time.Duration(t1) * time.Second)

        ctx, cancelFun := context.WithTimeout(c.Request.Context(), time.Second*5)
        defer cancelFun()
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:8888/sleep/5", nil)
        if err != nil {
              panic(err)
        }
        do, err := http.DefaultClient.Do(req)
        spew.Dump(do, err)

        c.String(http.StatusOK, "sleep "+t)
    })
    server.Run(":8887")
}

Server 2:

func main() {
    server := gin.New()
    server.GET("/sleep/:time", func(context *gin.Context) {
        t := context.Param("time")
        t1, _ := strconv.Atoi(t)
        time.Sleep(time.Duration(t1) * time.Second)
        context.String(http.StatusOK, "sleep "+t)
    })
    server.Run(":8888")
}

client請求server 1

過程:(58533是客戶端請求時的隨機埠,8887是server 1的服務埠)

image-20211223131051156

  1. 首先是三次握手,clientserver1建立連結(好基友,來牽牽手)
  2. 客戶端請求介面(5s超時),服務端返回了ACK(client:我有5s時間,但是隻睡你3秒,server 1:好嘞!)
  3. 客戶端設定的超時間(5s)時間到了,傳送FIN取消請求(client:服務還沒好?等不了了,我走了,server 1:好嘞!)
  4. 服務端返回response,但是客戶端返回FIN(server 1:我好了,client:我都已經走了,?)

客戶端取消請求之後,服務端居然還返回了結果!

簡單總結下:第5秒客戶端因為超時時間到,取消了請求。而隨後服務端立即返回了結果。重點是服務端結果是在客戶端請求之後返回的

server 1請求server 2

過程:(58535是server 1請求時的隨機埠,8888是server 2的服務埠)

  1. server 1 sleep3秒之後,開始對server 2發起請求(server1:我要睡你5s,server2:好嘞!)
  2. 2秒過後,因為client取消了請求,server1也取消了對server2的請求(server1:client不要我了,我們的交易也取消,server2:好嘞!)
  3. 又過了3秒,server2終於完成了睡眠,返回了結果(server2:您的服務已完成,server 1:交易早已取消,滾!)

image-20211223131332123

server1取消了請求,server 2居然還在繼續sleep

server 2好像有點笨,server 1都取消了請求,server 2還在sleep

報錯資訊

  • client在第5秒報錯context deadline exceeded
  • server 1在第5秒報錯context canceled
  • server 2沒有報錯

正確的時序圖

透過抓包分析發現剛開始畫的時序圖有問題:

錯誤的:

image-20211223140510937

正確的:

image-20211223120948836

兩個時序圖的差別就在於server 1處理請求所花費的時間。

server 1提前返回是因為server 1請求的時候繫結了request context。而reqeust contextclient超時之後立即被cancel掉了,從而導致server 1請求server 2http請求被迫停止。

context什麼時候cancel的

接下來看下原始碼:(go.1.17 & gin.1.3.0,可以跳過程式碼看小結部分)

server端接受新請求時會起一個協程go c.serve(connCtx)

func (srv *Server) Serve(l net.Listener) error {
 // ...
    for {
        rw, err := l.Accept()
        connCtx := ctx
     // ...
        go c.serve(connCtx)
    }
}

協程裡面for迴圈從連結中讀取請求,重點是這裡每次讀取到請求的時候都會啟動後臺協程(w.conn.r.startBackgroundRead())繼續從連結中讀取。

// Serve a new connection.
func (c *conn) serve(ctx context.Context) {
                // ...
   // HTTP/1.x from here on.

   ctx, cancelCtx := context.WithCancel(ctx)
   c.cancelCtx = cancelCtx
   defer cancelCtx()

             // ...
   for {
      // 從連結中讀取請求
      w, err := c.readRequest(ctx)
      if c.r.remain != c.server.initialReadLimitSize() {
         // If we read any bytes off the wire, we're active.
         c.setState(c.rwc, StateActive, runHooks)
      }

                            // ....

      // 啟動協程後臺讀取連結
      if requestBodyRemains(req.Body) {
         registerOnHitEOF(req.Body, w.conn.r.startBackgroundRead)
      } else {
         w.conn.r.startBackgroundRead()
      }

      // ...
      // 這裡轉到gin裡面的serverHttp方法
      serverHandler{c.server}.ServeHTTP(w, w.req)

      // 請求結束之後cancel掉context
      w.cancelCtx()
      // ...
   }
}

gin中執行ServeHttp方法

// ServeHTTP conforms to the http.Handler interface.
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    // ...
    // 執行我們寫的handle方法
          engine.handleHTTPRequest(c)
    // ...
}

目前為止,我們看到是請求結束之後才會cancel掉context,而不是cancel掉context導致的請求結束。

那是什麼時候cancel掉context的呢?

秘密就在w.conn.r.startBackgroundRead()這個後臺讀取的協程裡了。

func (cr *connReader) startBackgroundRead() {
    // ...
    go cr.backgroundRead()
}

func (cr *connReader) backgroundRead() {
    n, err := cr.conn.rwc.Read(cr.byteBuf[:])
 // ...
    if ne, ok := err.(net.Error); ok && cr.aborted && ne.Timeout() {
        // Ignore this error. It's the expected error from
        // another goroutine calling abortPendingRead.
    } else if err != nil {
                    cr.handleReadError(err)
    }
    // ...
}

func (cr *connReader) handleReadError(_ error) {
       // 這裡cancel了context
    cr.conn.cancelCtx()
    cr.closeNotify()
}

startBackgroundRead -> backgroundRead -> handleReadError。程式碼一頓跟蹤,最終發現在handleReadError函式里面會把context cancel掉。

原來如此,當服務端在處理業務的同時,後臺有個協程監控連結的狀態,如果連結有問題就會把context cancel掉。(cancel的目的就是快速失敗——業務不用處理了,就算服務端返回結果,客戶端也不會處理了)

那當客戶端超時的時候,backgroundRead協程式會收到EOF的錯誤。抓包看對應的就是FIN報文。

image-20211223160513420

image-20211223160602783

從業務程式碼中看到的context就是context canceled 狀態

image-20211223160824497

客戶端超時

客戶端超時場景總結如下

  • 客戶端本身會收到context deadline exceeded錯誤
  • 服務端對應的請求中的request contextcancel
  • 服務端業務可以繼續執行業務程式碼,如果有繫結request context(比如http請求),那麼會收到context canceled錯誤
  • 儘管客戶端請求取消了,服務端依然會返回結果

context 生命週期

下面再來看看request context的生命週期

image-20211223162851921

  • 大多數情況下,context一直能持續到請求結束

  • 當請求發生錯誤的時候,context會立刻被cancel

ctx避坑

  • http請求中不要使用gin.Context,而要使用c.Request.Context()(其他框架類似)
  • http請求中如果啟動了協程,並且在response之前不能結束的,不能使用request context(因為response之後context就會被cancel掉了),應當使用獨立的context(比如context.Background()
  • 如果業務程式碼執行時間長、佔用資源多,那麼去感知contextcancel,從而中斷執行是很有意義的。(快速失敗,節省資源)
本作品採用《CC 協議》,轉載必須註明作者和本文連結
您的點贊、評論和關注,是我創作的不懈動力。 學無止境,讓我們一起加油,在技術的衚衕裡越走越深!

相關文章