编程 Do 返回之后请求体还在被读:Go http.Transport 的 data race,vmagent 留着它、vmauth 必须修

2026-11-10 21:33:51

Do 返回之后,请求体还在被读:Go http.Transport 的一个 data race 和两种相反处置

Vadim Alekseev 把 vmagent 放到 race detector 下运行,得到一条 WARNING: DATA RACE:写入方是程序自己的主 goroutine,读取方是一个程序从未启动过的 goroutine,调用栈落在 net/http 里。他把问题缩成一个最小程序,报到上游 golang/go#81445。后来同样的行为在 vmauth 上也复现了。两个报警来自同一处 net/http 行为,一个被故意留着,一个必须修。涉及的内部实现来自 Go 1.27 的 net/http 源码,机制在很多版本里都一样。

现象:谁在读我没启动的 goroutine

最小复现是客户端侧的逻辑:一批日志发给服务端,服务端返回错误就重试。为了不每次尝试都分配新缓冲,复用同一个全局 bytes.Buffer。

type LogEntry struct {
	Timestamp int64
	Content   string
}

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
}

logs 是 1024 条、每条 256 字节内容,请求体约 300 KB。测试服务端不看请求体,直接回 502。加 -race 跑几次后:

error inserting logs: unexpected status code 502
...
==================
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()
==================
exit status 66

写是下一次尝试的 json.MarshalWrite 往 buf 里写 JSON;读是 writeLoop 里的 bytes.Reader 在读同一块内存。也就是说,上一次 client.Post 已经返回、响应也已经读完并 Close 了,net/http 里还有东西在读我们的缓冲。

为什么:一次请求涉及三个 goroutine

每条 HTTP/1.1 连接,http.Transport 都会建一个 persistConn,自带两个 goroutine:writeLoop 负责发送请求行、头和 body,readLoop 负责读响应并投递。第三个 goroutine 是你的。roundTrip 把请求同时交给前两个:

// net/http/transport.go(简化)
// Write the request concurrently with waiting for a response,
// in case the server decides to reply before reading our full request body.
pc.writech <- writeRequest{req, writeErrCh, continueCh}   // writeLoop:去发
pc.reqch <- requestAndChan{treq: req, ch: resc, ...}      // readLoop:等答复

然后你的 goroutine 在 writeErrCh(写完)和 resc(响应到了)之间 select,谁先到就 handleResponse(re) 并返回。

通常服务端会先读完 body 再回复。但如果只看头就能决定——鉴权失败、Content-Length 太大、代理连不上后端——服务端可以不读 body 直接回。readLoop 把响应交上来,select 走 resc 分支,roundTrip 返回,此时客户端还在发 body。

所以 client.Post 可以在 writeLoop 仍在后台发送剩余 body 时返回。Go 文档写明了这一点。http.RoundTripper:

RoundTrip must always close the body, including on errors, but depending on the implementation may do so in a separate goroutine even after RoundTrip returns. This means that callers wanting to reuse the body for subsequent requests must arrange to wait for the Close call before doing so.

Client.Do 的说明是 “the Body may be closed asynchronously after Do returns”。go#81445 里提到,这句是 Go 1.22 才加的,http.Post 和 (*http.Client).Post 的文档至今没有。

字节是怎么走的

buf 是全局的,buf.Reset() 不丢底层数组,只是把长度标成 0,所以每次重试都往同一块内存写新 JSON。bytes.Reader 不复制数据,直接从那个数组读。writeLoop 一次 32 KiB 从缓冲往自己的 bufio 里拷,再发出去,直到 EOF 或 socket 被关。

拼起来就是:buf.Reset() → 编码 → client.Do → writeLoop 开始 32 KiB 分块拷 → readLoop 等。服务端只看头就回了 502,没读 body,并在响应里带了 Connection: close。readLoop 递回响应,Do 返回 502。对我们来说请求结束了,但 writeLoop 不知道服务端已经答复,还在读那个数组。就在这一刻我们重试:buf.Reset(),往 writeLoop 正在读的同一块数组里重新编码。这段重叠就是 data race。接着 readLoop 关 socket,writeLoop 的下一次写失败,关掉我们的 body 退出。

不是每次都触发:本地发 body 几乎瞬间完成;如果连接会被复用,Go 还会最多等 50 ms 让 writeLoop 结束。

会把什么发出去

我们的代码只写,writeLoop 只读,所以缓冲不会被写坏。上面的例子里每次重试编码的也是同一批日志,发出去的内容无所谓。但如果缓冲被不同请求复用,残留的 writeLoop 可能发出“旧的前半截 + 新的后半截”:

previous: [{"Timestamp":1790000000111111111,"Content":"disk full"}]
next:     [{"Timestamp":1790000000999999999,"Content":"disk ok!!"}]
sent:     [{"Timestamp":1790000000111119999,"Content":"disk ok!!"}]

是合法 JSON,时间戳却两个请求里都没出现过。如果下一个请求更短,writeLoop 仍按旧长度往后读(buf.Reset() 不清数据):

previous: [{"Timestamp":1,"Content":"first"},{"Timestamp":2,"Content":"second"}]
next:     [{"Timestamp":9,"Content":"new"}]
sent:     [{"Timestamp":9,"Content":"new"}]},{"Timestamp":2,"Content":"second"}]

连合法 JSON 都不是。影响取决于谁消费这份混合数据。

vmagent:留着

vmagent #11507 是同样的模式,只是规模更大。worker 从队列里取一块压缩样本读进一个每次迭代复用的 byte slice,再用新 reader 发出去:

func (c *client) runWorker(readBlock func(dst []byte) ([]byte, bool)) {
	var block []byte
	for {
		block, ok = readBlock(block[:0])  // 覆盖上一块的字节
		...
		c.sendBlock(block)
	}
}

func (c *client) newRequest(url string, body []byte) (*http.Request, error) {
	reqBody := bytes.NewBuffer(body)  // 每个请求一个新 reader
	req, err := http.NewRequest(http.MethodPost, url, reqBody)
	...
}

把 vmagent 的 remote storage 指向一个不读 body 直接回 200 的地址(Vadim 用的是 httpbin.org/status/200),推一次大 import,race detector 就会报队列在写下一块、writeLoop 还在读上一块,栈里是 persistentqueue.mustReadInMemoryBlockLocked / FastQueue.MustReadInMemoryBlockBlocking / remotewrite.(*client).runWorker 对 net/http.(*persistConn).writeLoop。

他们合了一个修复,三天后回滚,issue 以不修关闭。原因是:bytes.NewReader 和 bytes.NewBuffer 都把数组包在自己的长度和读位置里,这部分不共享,共享的只有底下的数组。Go 内存模型保证,一边读一边被覆盖,读到的要么是旧值要么是新值,不会是中间态,程序不会坏。最坏情况就是混合数据没人用:它进的是一个服务端已答复、没读 body 的请求,连接通常也在关闭。每次请求新建 buffer 会把省下来的开销还回去;等 transport 结束会卡住 worker,少数情况还会死锁,而且覆盖不全。维护者 f41gh7 在 2026-09-28 的评论里说:留着这个 race,因为等待的修法复杂、不保证标准库正确释放,影响可忽略,内存模型保证不会损坏数据。

vmauth:必须修

vmauth #11508 遇到同样的行为,但这里是真 bug。vmauth 是代理且会重试:后端失败或返回 503,就把同一个请求发给下一个后端。为了把同一个 body 发两次,它把 body 存在内存里的 bufferedBody。修复前,bufferedBody 同时就是 reader:字节加上自己的读位置。重试时 vmauth 把这个位置倒回 0,把同一个 bufferedBody 交给下一个请求。

关键区别:vmagent 每个请求有自己的 reader,只共享字节;vmauth 两个请求共享 reader 本身,包括读位置。失败请求残留的 writeLoop 可能继续推进这个位置,结束时把它重置为 0——正好发生在重试上传的中途。结果下一个后端可能收到缺片或重复的 body;有时长度算错、请求失败,有时长度刚好对上,后端接受了一个被污染的请求。这次混合数据去的是一个健康、会真正处理它的后端。

修复在 #11647:不让两个请求共用 reader。bufferedBody 仍然存字节,但每次 attempt 都基于这些字节新建一个 bytes.Buffer,残留的 writeLoop 只能读它自己的 reader。共享的字节只被读、不被写,race 就没了。代价是每次 attempt 多几个小分配、不做拷贝,而这条路径上网络往返才是大头。PR 里还列了:每次 attempt 用 io.NopCloser(bytes.NewReader(bb.buf)),canRetry 时 r.Body 同样处理;不可缓冲的流式请求 r.Body = http.NoBody;用 sync.Mutex 保护 bufferedBody 的 bufOffset/cannotRetry/r;回归测试 TestBufferedBody_DataRace。

可以拿去用的三条规则

两个报警都来自同一件事:transport 在自己的 goroutine 里写请求体,可能在读完你的 body 之前就返回响应。race detector 两次都对,但它判断不了这个 race 要不要紧。两个问题决定处置:

  1. 共享的到底是什么——是被拷走的普通字节,还是决定行为的状(offset、长度、指针)?字节上的 race 给出过期字节,游标上的 race 给出错误行为。
  2. 谁消费结果?vmagent 的混合副本进的是一个已答复、正在关闭的连接;vmauth 的竞争游标决定了健康后端收到并写入什么。

落到自己的 HTTP 客户端代码:

  • 假定 Do 返回后 transport 仍可能在读你的请求体。
  • 不要把同一个有状态的 io.Reader 交给两个请求。
  • 已经发出去的 body 背后的内存,在 transport 调用 Close() 之前不要复用、池化或改写。

上游 golang/go#81445 还在推进:Damien Neil 建议加一个 Request.Close 方法;issue 作者的提案是 Transport 在响应体关闭、或 RoundTrip 返回错误时等请求体关闭,代价是 Response.Body.Close 会阻塞(HTTP/1:50 ms 或等到服务端读完;HTTP/2:status > 299 立即返回,否则等到服务端读完或请求超时)。另有一个尚未覆盖的 HTTP/2 场景。

参考:The Data Race That Wasn't a Bug (and the One That Was)

复制全文 生成海报 Go 并发 调试

推荐文章

程序员茄子在线接单