查看原文
其他

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

程序员ug 幽鬼 2022-11-21

争做团队核心程序员,关注「幽鬼

大家好,我是程序员幽鬼。

先介绍一下背景知识。

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

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

 // Don't send 0-length data. It looks like EOF for chunked encoding.
 if len(data) == 0 {
  return 0nil
 }

 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() {
    // We've read enough. Don't 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 we're 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 Writeio.Copy产生Write最多 32 * 1024 (32,768) 字节 。为了使用它,我们重构了我们的代码以为io.Reader提供所需的数据而不是大缓冲区。使用 io.Copy是一种在io.Readerio.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/dolt

[2]

类型的 remotes: https://docs.dolthub.com/concepts/dolt/remotes

[3]

Wireshark: https://www.wireshark.org/

[4]

net/http/server.go: https://github.com/golang/go/blob/a987aaf5f7a5f64215ff75ac93a2c1b39967a8c9/src/net/http/server.go#L1538-L1561

[5]

维基百科: 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




往期推荐


欢迎关注「幽鬼」,像她一样做团队的核心。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存