bigywater.tech
Gin · 26

客户端断开后 Request.Context() 什么时候取消

客户端关掉浏览器标签,服务端的 handler 什么时候才知道?这篇用原始 TCP 连接模拟断开,量出 GET 和 POST 的感知时间,看断开之后写响应会怎样,并从 net/http 的源码解释为什么不读 body 的 POST 感知不到。

201 ms
GET 请求客户端 200 ms 断开后,handler 感知到的时间
2000 ms
不读 body 的 POST 在客户端断开后,等满 2 秒仍没有收到取消
68 KB
断开后继续 Flush 64 KB 的块,这次运行在第 2 次写(累计 68 KB)收到 broken pipe

GET 与读 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。

c.Done() 的区别

实测 · 本机执行输出
    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 在上面永远不会命中。

不读 body 的 POST

实测 · 本机执行输出
  <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 之类限制后读掉)。

分步演示

客户端断开
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。服务端是 httptest.NewServer 起的真实 net/http 服务器,客户端用 net.Dial 的原始 TCP 连接手写 HTTP/1.1 请求并在指定时刻 Close;源码引用来自 Go 1.27.1 的标准库;每种情形各一次(断开后写入的第几次失败在两次运行里不同,见正文)
    • 没有验证:HTTP/2 下的取消行为(RST_STREAM);经过反向代理(nginx 等)时断开的传播;keep-alive 连接复用时的行为;大 body 且已经读完时的情形;“读完 body 再等”的手法是否在所有情形下都能及时取消(只验证了读不到更多数据时的 unexpected EOF)
    京ICP备2026067502号-1京公网安备11010502063487号