客户端关掉浏览器标签,服务端的 handler 什么时候才知道?这篇用原始 TCP 连接模拟断开,量出 GET 和 POST 的感知时间,看断开之后写响应会怎样,并从 net/http 的源码解释为什么不读 body 的 POST 感知不到。
[GET /wait] 客户端 200 ms 时关闭连接
handler 在 201 ms 时看到 Request.Context() 取消:context canceled
[POST /nobody(声明 1000 字节只发 10 字节)] 客户端 200 ms 时关闭连接
handler(不读 body)等满 2000 ms,始终没有收到取消
[POST /readbody(声明 1000 字节只发 10 字节)] 客户端 200 ms 时关闭连接
handler 读 body:读到 10 字节后返回 err=unexpected EOF,201 ms;此时 Request.Context().Err()=context canceled没有 body 的请求,net/http 在请求一开始就启动后台读 goroutine 监测连接,断开后 1~2 ms 内 Request.Context() 就被取消。有 body 的请求在 handler 读 body 时,读到连接关闭会返回 unexpected EOF,同时 context 也被取消。
客户端 100ms 时断开;handler 300ms 时开始写,每块 64KB 并 Flush
第 2 次写(累计 68 KB)返回错误:write tcp 127.0.0.1:50794->127.0.0.1:50795: write: broken pipe;c.Request.Context().Err()=context canceled断开之后往连接里写,前几 KB 会被内核缓冲区吸收而不报错,继续写才会返回 write: broken pipe。具体是第几次写失败取决于 TCP 缓冲区,所以不能指望“写一次就知道客户端走了”,要靠 context。
ContextWithFallback=false → c.Request.Context().Done() 在 151 ms 触发;c.Done() 100 ms 内没有触发(c.Done()==nil: true)
ContextWithFallback=true → c.Request.Context().Done() 在 152 ms 触发;c.Done() 也已触发断开后 c.Request.Context().Done() 总是会触发;c.Done() 只有在 ContextWithFallback 打开时才会触发,否则是 nil 通道,select 在上面永远不会命中。
<GOROOT>/src/net/http/server.go(Go 1.27.1):
2120 | // Start background read, which detects when a client has closed its connection
2121 | // while a request handler is still running. When the request has a body, we
2122 | // start the background read only after the entire body has been consumed.
2123 | if w.reqBody.bodyRemains() {
2124 | w.reqBody.registerOnHitEOF(w.conn.r.startBackgroundRead)
2125 | } else {
2126 | w.conn.r.startBackgroundRead()
2127 | }net/http 的服务器代码里写着:请求带 body 时,要等整个 body 读完才启动后台读。所以只处理 header、不读 body 的 handler(比如先鉴权再拒绝)看不到客户端断开,Request.Context() 不会被取消,耗时的下游调用也不会被取消。需要及时取消时,应当先把 body 读掉(或者用 http.MaxBytesReader 之类限制后读掉)。