context canceled,谁是罪魁祸首?
source link: https://studygolang.com/articles/35384
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
context canceled,谁是罪魁祸首?
nanjingfm · 16天之前 · 512 次点击 · 预计阅读时间 8 分钟 · 大约8小时之前 开始浏览灵魂三问:
- 客户端请求超时,取消了请求,服务端还会继续执行么?
- 客户端请求超时,取消了请求,服务端还会返回结果么?
- 客户端请求超时,取消了请求,服务端会报错么?
告警群里有告警,定位到报错的微服务看到如下报错: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
,从而中断执行是很有意义的。(快速失败,节省资源)
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK