当前位置:首页 > 域名

调试 Go 中奇怪的 http.Response Read 行为

大家好,调试我是中奇程序员幽鬼。

先介绍一下背景知识。调试

调试 Go 中奇怪的 http.Response Read 行为

使用Dolt[1],中奇你可以push和pull本地 MySQL 兼容的调试数据库到远程。远程可以使用 dolt remoteCLI 命令进行管理,中奇它支持多种类型的调试 remotes[2]。你可以将单独的中奇目录用作 Dolt 远程、s3 存储桶或任何实现ChunkStoreService protocol buffer 定义的调试 grpc 服务。remotesrv是中奇 Dolt 的开源实现ChunkStoreService。它还提供一个简单的调试 HTTP 文件服务器,用于在远程和客户端之间传输数据。中奇

调试 Go 中奇怪的 http.Response Read 行为

本周早些时候,调试我们遇到了一个与 Dolt CLI 和 remotesrv HTTP 文件服务器之间的中奇交互相关的有趣问题。为了解决这个问题,调试需要了解HTTP/1.1协议并深入挖掘 Golang 源代码。在这篇博客中,我们将讨论 Golang 的net/http包如何自动设置Transfer-EncodingHTTP 响应的标头以及如何改变http.Response.Body Read客户端调用的行为。

调试 Go 中奇怪的 http.Response Read 行为

一个奇怪的 Dolt CLI 错误

这项调查是从 Dolt 用户的源码下载报告开始的。他们已经设置 remotesrv好托管他们的 Dolt 数据库,并使用 Dolt CLI 将pull 更改上传到本地克隆。虽然push工作得很好,pull 似乎取得了一些进展,但因可疑错误而失败:

throughput below minimum allowable

这个特殊错误是可疑的,因为它表明 Dolt 客户端未能以每秒 1024 字节的最小速率从remotesrv 的 HTTP 文件服务器下载数据。我们最初的假设是并行下载会导致下载路径出现某种拥塞。但不是这样。研究发现,此错误仅发生在大型下载中,并且是序列化的,因此不太可能出现拥塞。我们更深入地研究了吞吐量是如何测量的,并发现了一些令人惊讶的东西。

我们如何测量吞吐量

’让我们从 Golang 的io.Reader接口概述开始。该接口允许你将Read来自某个源的字节并写入某个缓冲区b:

func (T) Read(b []byte) (n int, err error)

作为其规约的服务器租用一部分,它保证读取的字节数不会超过 len(b) 个字节,并且读取b的字节数始终以n返回。只要 b足够大,特定 Read 调用可以返回 0 个字节、10 个字节甚至 134,232,001 个字节。如果读取器用完了要读取的字节,它会返回一个你可以测试的文件结束 (EOF) 错误。

当你使用net/http包在 Golang 中进行 HTTP 调用时,响应 body 是一个 io.Reader。你可以使用Read读取 body 上的字节。考虑到io.Reader规约,我们知道,在任何特定调用Read期间可以检索从 0 从到整个正文的任何位置。

在我们的研究中,我们发现 134,232,001 字节的下载量未能达到我们的最低吞吐量,但原因并没有立即显现。使用Wireshark[3],我们可以看到数据传输速度足够快,而且问题似乎在于 Dolt CLI 如何测量吞吐量。

下面是一些描述如何测量吞吐量的伪代码:

type measurement struct {

N int

T time.Time

}

type throughputReader struct {

io.Reader

ms chan measurement

}

func (r throughputReader) Read(bs []byte) (int, error) {

n, err := r.Reader.Read(bs)

r.ms <- measurement{ n, time.Now()}

return n, err

}

func ReadNWithMinThroughput(r io.Reader, n int64, min_bps int64) ([]byte, error) {

ms := make(chan measurement)

defer close(ms)

r = throughputReader{ r, ms}

bytes := make([]byte, n)

go func() {

for {

select {

case _, ok := <-ms:

if !ok {

return

}

// Add sample to a window of samples.

case <-time.After(1 * time.Second):

}

// Calculate the throughput by selecting a window of samples,

// summing the sampled bytes read, and dividing by the window length. If the

// throughput is less than |min_bps|, cancel our context.

}

}()

_, err := io.ReadFull(r, bytes)

return bytes, err

}

}

上面的高防服务器代码揭示了我们问题的罪魁祸首。请注意,如果单个Read 调用需要很长时间,则不会有吞吐量样本到达,最终我们的测量代码将报告吞吐量为 0 字节并抛出错误。小型下载已完成,但较大的下载始终失败这一事实进一步支持了这一点。

但是我们如何防止这些大Reads的以及导致一些读取量大而另一些读取量小的原因呢?

让我们通过剖析 HTTP 响应如何在服务器上构建以及客户端如何解析来研究这一点。

编写 HTTP 响应

在 Golang 中,你用 http.ResponseWriter 向客户端返回数据。你可以使用 writer 来编写标头和正文,但是有很多底层逻辑可以控制实际写入的标头以及正文的编码方式。

例如,在 http 文件服务器中,我们从不设置Content-Typeor Transfer-Encoding标头。我们只是调用一次带缓冲区的Write,来保存我们需要返回的数据。但是如果我们用 curl 检查响应头:

=> curl -sSL -D - http://localhost:8080/dolthub/test/53l5... -o /dev/null

HTTP/1.1 200 OK

Date: Wed, 09 Mar 2022 01:21:28 GMT

Content-Type: application/octet-stream

Transfer-Encoding: chunked

我们可以看到Content-Type和Transfer-Encodingheaders 都设置好了!此外,Transfer-Encoding设置为chunked!

这是我们从 net/http/server.go[4]找到的一条评论, 解释了这一点:

// The Life Of A Write is like this:

//

// Handler starts. No header has been sent. The handler can either

// write a header, or just start writing. Writing before sending a header

// sends an implicitly empty 200 OK header.

//

// If the handler didnt declare a Content-Length up front, we either

// go into chunking mode or, if the handler finishes running before

// the chunking buffer size, we compute a Content-Length and send that

// in the header instead.

//

// Likewise, if the handler didnt set a Content-Type, we sniff that

// from the initial chunk of output.

这是维基百科[5]对分块传输编码的解释:

分块传输编码是超文本传输协议 (HTTP) 版本 1.1 中可用的流式数据传输机制。在分块传输编码中,数据流被分成一系列不重叠的“块”。这些块彼此独立地发送和接收。在任何给定时间,发送者和接收者都不需要知道当前正在处理的块之外的数据流。

每个块前面都有其大小(以字节为单位)。当接收到零长度块时,传输结束。Transfer-Encoding 头中的 chunked 关键字用于表示分块传输。1994 年提出了一种早期形式的分块传输编码。[ 1[6] ] HTTP/2 不支持分块传输编码,它为数据流提供了自己的机制。[ 2[7] ]。

读取 HTTP 响应

要读取 http 响应的正文(body),net/http 提供的 Response.Body 是一个 io.Reader. 它还具有隐藏 HTTP 实现细节的逻辑。无论使用何种传输编码,提供的io.Reader仅返回最初写入请求中的字节。它会自动“de-chunks”分块的响应。

我们更详细地研究了这种“de-chunks”,以了解为什么这会导致大的Read。

写和读块

如果你看一下chunkedWriter实现,你会发现每个 Write都会产生一个新的块,而不管它的大小:

// Write the contents of data as one chunk to Wire.

func (cw *chunkedWriter) Write(data []byte) (n int, err error) {

// Dont send 0-length data. It looks like EOF for chunked encoding.

if len(data) == 0 {

return 0, nil

}

if _, err = fmt.Fprintf(cw.Wire, "%x\r\n", len(data)); err != nil {

return 0, err

}

if n, err = cw.Wire.Write(data); err != nil {

return

}

if n != len(data) {

err = io.ErrShortWrite

return

}

if _, err = io.WriteString(cw.Wire, "\r\n"); err != nil {

return

}

if bw, ok := cw.Wire.(*FlushAfterChunkWriter); ok {

err = bw.Flush()

}

return

}

在remotesrv中,我们首先将请求的数据加载到缓冲区中,然后调用 Write一次。所以我们通过网络发送 1 个大块。

在chunkedReader中我们看到,一次 Read 调用将读取来自网络的整个块:

func (cr *chunkedReader) Read(b []uint8) (n int, err error) {

for cr.err == nil {

if cr.checkEnd {

if n > 0 && cr.r.Buffered() < 2 {

// We have some data. Return early (per the io.Reader

// contract) instead of potentially blocking while

// reading more.

break

}

if _, cr.err = io.ReadFull(cr.r, cr.buf[:2]); cr.err == nil {

if string(cr.buf[:]) != "\r\n" {

cr.err = errors.New("malformed chunked encoding")

break

}

} else {

if cr.err == io.EOF {

cr.err = io.ErrUnexpectedEOF

}

break

}

cr.checkEnd = false

}

if cr.n == 0 {

if n > 0 && !cr.chunkHeaderAvailable() {

// Weve read enough. Dont potentially block

// reading a new chunk header.

break

}

cr.beginChunk()

continue

}

if len(b) == 0 {

break

}

rbuf := b

if uint64(len(rbuf)) > cr.n {

rbuf = rbuf[:cr.n]

}

var n0 int

/

*

Annotation by Dhruv:

This Read call directly calls Read on |net.Conn| if |rbuf| is larger

than the underlying |bufio.Reader|s buffer size.

*/

n0, cr.err = cr.r.Read(rbuf)

n += n0

b = b[n0:]

cr.n -= uint64(n0)

// If were at the end of a chunk, read the next two

// bytes to verify they are "\r\n".

if cr.n == 0 && cr.err == nil {

cr.checkEnd = true

} else if cr.err == io.EOF {

cr.err = io.ErrUnexpectedEOF

}

}

return n, cr.err

}

由于来自我们的 HTTP 文件服务器的每个请求都作为单个块提供和读取,因此Read调用的返回时间完全取决于请求数据的大小。在我们下载大量数据(134,232,001 字节)的情况下,这些Read调用始终超时。

解决问题

我们有两个候选的解决方案来解决这个问题。我们可以通过分解http.ResponseWriter Write调用来生成更小的块,或者我们可以显式地设置Content-Length将完全绕过块传输编码的标头。

我们决定通过使用 io.Copy分解http.ResponseWriter Write。io.Copy产生Write最多 32 * 1024 (32,768) 字节 。为了使用它,我们重构了我们的代码以为io.Reader提供所需的数据而不是大缓冲区。使用 io.Copy是一种在io.Reader 和io.Writer之间传递数据的惯用模式。

你可以在此处[8]查看包含这些更改的 PR 。

结论

总之,我们发现在写入响应时,如果不设置 Content-Length并且写入的大小大于分块缓冲区大小,http.ResponseWriter 将使用分块传输编码。相应地,当我们读取响应时,chunkReader将尝试从 net.Conn 读取整个块。由于remotesrv编写了一个非常大的块,Dolt CLI 上 Read的调用总是花费太长时间并导致抛出整个错误。我们通过编写更小的块来解决这个问题。

使用该net/http包和其他 Golang 标准库很愉快。由于大多数标准库都是用 Go 本身编写的,并且可以在 Github 上查看,因此很容易阅读源代码。尽管手头的具体问题几乎没有文档,但只用了一两个小时就可以挖掘到根本原因。我个人很高兴能继续在 Dolt 上工作并加深我对 Go 的了解。

原文链接:https://www.dolthub.com/blog/2022-03-09-debugging-http-body-read-behavior/

参考资料

[1]Dolt: https://github.com/dolthub/dol

t[2]类型的 remotes: https://docs.dolthub.com/concepts/dolt/remotes

[3]Wireshark: https://www.wireshark.org/

[4]net/http/server.go: https://img.ydisp.cn/news/20220902/2deuyip0qo4.go https://en.wikipedia.org/wiki/Chunked_transfer_encoding

[6][1: https://en.wikipedia.org/wiki/Chunked_transfer_encoding#cite_note-1

[7][2: https://en.wikipedia.org/wiki/Chunked_transfer_encoding#cite_note-2

[8]你可以在此处: https://github.com/dolthub/dolt/pull/2933

分享到:

滇ICP备2023006006号-16