服务端提前返回 502 后,Go 的 writeLoop 还在读你的 buffer
一条 HTTP/1.1 连接上,http.Transport 的 writeLoop 可能在你已经拿到响应之后,继续从请求体底下的内存里读数据。这是 golang/go#81445。
复现
程序批量写日志,失败重试。为了不每次都分配新 buffer,用了一个包级 bytes.Buffer:
var buf = bytes.NewBuffer(nil)
func insertLogs(client *http.Client, logs []LogEntry) error {
buf.Reset()
if err := json.MarshalWrite(buf, logs); err != nil {
panic(err)
}
body := bytes.NewReader(buf.Bytes())
resp, err := client.Post("http://example.com/insert/logs", "application/json", body)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode/100 != 2 {
return fmt.Errorf("unexpected status code %d", resp.StatusCode)
}
return nil
}
main 里 for range 100 重试。1024 条日志、每条 256 字节,请求体约 300KB。测试服务器对所有请求立即返回 502,并且不读请求体。用 -race 跑几次就会掉出:
WARNING: DATA RACE
Write at 0x00c000390f60 by main goroutine:
runtime.slicecopy
encoding/json/v2.makeStructArshaler.func2
encoding/json/v2.MarshalWrite
main.insertLogs
Previous read at 0x00c000390f60 by goroutine 49:
runtime.slicecopy
bytes.(*Reader).Read
io.(*LimitedReader).Read
io.Copy
net.genericReadFrom
net.(*TCPConn).ReadFrom
net/http.persistConnWriter.ReadFrom
bufio.(*Writer).ReadFrom
net/http.(*transferWriter).doBodyCopy
net/http.(*transferWriter).writeBody
net/http.(*Request).write
net/http.(*persistConn).writeLoop
主 goroutine 在写,writeLoop 在读,同一个地址。
三条 goroutine,roundTrip 不等 writeLoop
HTTP/1.1 下 http.Transport 为每条连接建一个 persistConn,跑两条 goroutine:writeLoop 负责写请求行、头和体,readLoop 负责读响应。第三条是调用方自己的 goroutine。
persistConn.roundTrip 把请求同时投给两边:
pc.writech <- writeRequest{req, writeErrCh, continueCh}
pc.reqch <- requestAndChan{treq: req, ch: resc, ...}
然后进入 select 循环:writeErrCh 表示发送完成,resc 表示响应到达。只要响应先到,就走 handleResponse 把结果返回,不等 writeLoop 收尾。TCP 两个方向彼此独立,服务端不必等客户端把请求体发完才能回响应。
什么情况下会触发
有些响应只看请求头就能决定:无权限、Content-Length 过大、代理连不上后端。这类情况下服务端立即回响应(本例是 502),请求体大部分还没被读取,响应通常还带 Connection: close。
readLoop 把响应交回,Do 返回。writeLoop 没收到任何停止信号,继续按 32KiB 分块从你的 buffer 读数据往 socket 里写。你的代码此时进入重试:buf.Reset() 把长度归零但不释放底层数组,紧接着重新序列化,又写进同一块内存。窗口就此打开——一条 goroutine 写新数据,另一条还在读旧数据。等 readLoop 关掉 socket、writeLoop 写失败,它才关闭请求体收场。
不是每次都撞得上。本地连接几乎瞬间就把 body 发完了;而且如果这条连接还要复用,Go 会等 writeLoop 最多 50ms 才让调用方继续。延迟高、body 大、服务端回得快,才容易复现。
文档里的约定
http.RoundTripper 要求 RoundTrip 必须关闭请求体(出错时也要),但关闭动作可能发生在另一条 goroutine,甚至晚于 RoundTrip 返回。所以要复用请求体的调用方,必须等到 Close。Client.Do 也提示,请求体可能在 Do 返回之后被异步关闭。
后果
内存模型保证单字节读到的要么是旧值要么是新值,不会读到撕裂的半个值,进程不会因此崩。但混出来的内容就是错的:结构依然合法的 JSON,可能一半旧 Timestamp、一半新 Content。如果下一轮 body 更短,而 writeLoop 仍按上一次的长度读,拼出来的就是非法 JSON。
vmagent 里故意留下的竞争(#11507)
vmagent 的 worker 从队列取数据块到复用的 []byte,每次请求用 bytes.NewBuffer(body) 新建 reader——只共享底层字节,不共享读位置。向 httpbin.org/status/200 这种不读 body 就回 200 的端点灌大量数据,-race 会报出同样的问题。
团队接受过修复,3 天后回滚,最后 issue 以不修关闭。理由:服务端已经回了响应也不再读,连接通常已关闭,没人会去读那些混起来的字节;为了这段没人读的内存让每个请求都分配新 buffer,等于丢掉复用的全部收益。他们试过等 transport 结束,可能拖慢 worker,罕见情况下还会死锁,也没能彻底解决问题。
vmauth 里是真 bug(#11508)
vmauth 在后端不可用或返回 503 时,会把同一个请求转给下一个后端。为了发两次,body 存在 bufferedBody 里。修复前 bufferedBody 自己同时是 reader,带读位置,重试时把位置归零再交给下一个请求。残留的 writeLoop 会继续前移这个共享位置,结束时还要把它清零——清零可能正好落在第二次请求发送的中途。下一个后端于是收到缺片段或重复片段的 body;有时长度恰好对上,后端就接受了被污染的数据。
修法在 #11647:仍然只存字节,但每次尝试新建一个 bytes.Buffer 指向同一份数据。代价是每次尝试几个小分配,不复制字节。
规则
- 把 transport 当成可能在 Do 返回之后还在读请求体。
- 不要把带内部状态的同一个
io.Reader交给两个请求。 - 复用内存、缓冲池,或修改已发送 body 所依赖的数据时要谨慎,等 transport 调用 Close。
Damien Neil 在 issue 里提议给 Request 加一个 Close,让调用方能等 transport 真正结束。在那之前,跟踪 golang/go#81445。