靈魂三問:
- 客戶端請求超時,取消了請求,服務端還會繼續執行麼?
- 客戶端請求超時,取消了請求,服務端還會返回結果麼?
- 客戶端請求超時,取消了請求,服務端會報錯麼?
告警群裡有告警,定位到報錯的微服務看到如下報錯:Post http://ms-user-go.mp.online/user/listByIDs: context canceled
。
專案中沒有發現cancel context
的程式碼,那麼context canceled
的錯誤是哪裡來的?
特別說明,這裡討論的
context
是指gin Context
中的Request Context
client
請求server1
時設定5s超時server1
收到請求時先sleep
3秒,然後請求server2
並設定5s超時server2
收到請求時sleep5
秒
畫個時序圖看的更直觀(看完文章你會發現這是錯的):
程式碼如下:
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的服務埠)
- 首先是三次握手,
client
和server1
建立連結(好基友,來牽牽手) - 客戶端請求介面(5s超時),服務端返回了
ACK
(client:我有5s時間,但是隻睡你3秒,server 1:好嘞!) - 客戶端設定的超時間(5s)時間到了,傳送
FIN
取消請求(client:服務還沒好?等不了了,我走了,server 1:好嘞!) - 服務端返回
response
,但是客戶端返回FIN
(server 1:我好了,client:我都已經走了,?)
客戶端取消請求之後,服務端居然還返回了結果!
簡單總結下:第5秒客戶端因為超時時間到,取消了請求。而隨後服務端立即返回了結果。重點是服務端結果是在客戶端請求之後返回的
server 1請求server 2
過程:(58535是server 1請求時的隨機埠,8888是server 2的服務埠)
server 1
sleep3
秒之後,開始對server 2
發起請求(server1:我要睡你5s,server2:好嘞!)- 2秒過後,因為
client
取消了請求,server1
也取消了對server2
的請求(server1:client不要我了,我們的交易也取消,server2:好嘞!) - 又過了3秒,
server2
終於完成了睡眠,返回了結果(server2:您的服務已完成,server 1:交易早已取消,滾!)
server1
取消了請求,server 2
居然還在繼續sleep
server 2
好像有點笨,server 1
都取消了請求,server 2還在sleep
報錯資訊
client
在第5秒報錯context deadline exceeded
server 1
在第5秒報錯context canceled
server 2
沒有報錯
正確的時序圖
透過抓包分析發現剛開始畫的時序圖有問題:
錯誤的:
正確的:
兩個時序圖的差別就在於server 1
處理請求所花費的時間。
server 1
提前返回是因為server 1請求的時候繫結了request context
。而reqeust context
在client
超時之後立即被cancel
掉了,從而導致server 1
請求server 2
的http
請求被迫停止。
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報文。
從業務程式碼中看到的context
就是context canceled
狀態
客戶端超時
客戶端超時場景總結如下
- 客戶端本身會收到
context deadline exceeded
錯誤 - 服務端對應的請求中的
request context
被cancel
掉 - 服務端業務可以繼續執行業務程式碼,如果有繫結
request context
(比如http
請求),那麼會收到context canceled錯誤 - 儘管客戶端請求取消了,服務端依然會返回結果
context 生命週期
下面再來看看request context
的生命週期
大多數情況下,
context
一直能持續到請求結束當請求發生錯誤的時候,
context
會立刻被cancel
掉
ctx避坑
- http請求中不要使用
gin.Context
,而要使用c.Request.Context()(其他框架類似) - http請求中如果啟動了協程,並且在
response
之前不能結束的,不能使用request context
(因為response
之後context
就會被cancel
掉了),應當使用獨立的context
(比如context.Background()
) - 如果業務程式碼執行時間長、佔用資源多,那麼去感知
context
被cancel
,從而中斷執行是很有意義的。(快速失敗,節省資源)
本作品採用《CC 協議》,轉載必須註明作者和本文連結