关于 Golang 中 http.Response.Body 未读取导致连接复用问题的一点研究

陪她去流浪 桃子 2021年03月28日 阅读次数:7611

在 Golang 的 HTTP 库的源代码中,关于 http.Response.Body 的说明如下:

// Body represents the response body.
//
// The response body is streamed on demand as the Body field
// is read. If the network connection fails or the server
// terminates the response, Body.Read calls return an error.
//
// The http Client and Transport guarantee that Body is always
// non-nil, even on responses without a body or responses with
// a zero-length body. It is the caller's responsibility to
// close Body. The default HTTP client's Transport may not
// reuse HTTP/1.x "keep-alive" TCP connections if the Body is
// not read to completion and closed.
//
// The Body is automatically dechunked if the server replied
// with a "chunked" Transfer-Encoding.
//
// As of Go 1.12, the Body will also implement io.Writer
// on a successful "101 Switching Protocols" response,
// as used by WebSockets and HTTP/2's "h2c" mode.
Body io.ReadCloser

注意到其中的一句话:The default HTTP client's Transport may not reuse HTTP/1.x "keep-alive" TCP connections if the Body is not read to completion and closed., 它说,如果 Body 未被读完且关闭的话,默认的 HTTP 客户端的传输层是不会复用 HTTP/1.x 的 “Keep-Alive” 连接的。 (在 HTTP/1.0 的时代,“Keep-Alive” 还不是默认行为,如果浏览器和服务器支持,可以在请求头和响应头加上“Connection: Keep-Alive”。 然而在 HTTP/1.1 中,“Keep-Alive” 是默认行为,除非特别声明了不允许(比如 Connection: close^。)

为什么要连接复用?当然是基于性能的考虑啦。多路复用能不香吗?

日常写代码的方式

而再看我们平常写的代码,似乎很少有人注意到这点(只是发送 HTTP 请求,不需要数据的情况):

// 注意是 HTTP(80) 端口的连接,大量用于内网
resp, err := http.Get(`http://www.example.com`)
if err != nil {
	panic(err)
}
defer resp.Body.Close()

如果只是忘记 Body.Close(),大概率 linter 是过不了的。但是却没有人提醒你,Body 该不该被读走。 按照文档所述,如果 Body 未被读完的话,连接是不会被复用的。为什么?我认为非常简单:因为 HTTP 客户端无法知道你是否还会用到 Body。 它可不敢帮你读走(以完成一次 Request/Response)以主动复用连接,毕竟万一网络突然卡了,读取 Body 占用大量时间。 本来 Close 一下是很快的,结果因为想要复用却导致一大堆不能用且没关闭的连接。这在标准库的设计上来说是不可取的。

按照 Go 语言中 http.Request/Response 的实现原理:服务端在读完请求(Request)的头部(Headers),或客户端在读完响应(Response)的头部(Headers),以后就算完成了请求或响应的读取,Body 被包装成 ReadCloser 接口作为流交给程序后面自己去读取(并关闭)。这一点非常好,不会因为请求或响应包含一个非常大的 Body 而占用大量内存。

应该考虑的写法

综上所述,文档中关于连接复用的现象的说法就是必然存在的了。 那么我们是不是总是应该在不需要 Body 的地方执行一句

io.Copy(ioutil.Discard, resp.Body)

以读完 Body 呢?就像 defer resp.Body.Close() 需要总是记得关闭那样。

答案我不确定:如果能确定接口非常快并且响应数据少或没有数据,那么执行一下可以想像并无性能损失。但是万一网络卡住,那么,读取 Body 的时候就会耗时很久了。 所以,按需吧。如果你在编写代码时能考虑到这一点(或者加个注释也行),那也是更严谨的体现。

一个用于验证连接复用的例子

我写了段测试代码以验证这个行为:

package main

import (
	"flag"
	"io"
	"io/ioutil"
	"net/http"
)

func issue(discard bool) {
	resp, err := http.Get(`http://www.example.com`)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	if discard {
		io.Copy(ioutil.Discard, resp.Body)
	}
}

func main() {
	var discard bool
	flag.BoolVar(&discard, `d`, false, `discard body`)
	flag.Parse()

	issue(discard)
	issue(true) // whatever
}

像下面这样,不读走 Body 调用时:

$ sudo strace -qqfe connect ./discard -d=false 2>&1 | grep '(80)'
[pid 23647] connect(6, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("93.184.216.34")}, 16) = -1 EINPROGRESS (Operation now in progress)
[pid 23649] connect(6, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("93.184.216.34")}, 16) = -1 EINPROGRESS (Operation now in progress)

多次执行,均能看到同样的结果:发起了 2 次 connect 建立连接。是不是说明连接没有复用?我想是的。 虽然两次的文件描述符都是 6,那是因为每次都关闭了连接,并且按照 Unix 的特点:文件描述符总是从最小未被使用的开始。

而当读走 Body 调用时:

$ sudo strace -qqfe connect ./discard -d=true 2>&1 | grep '(80)'
[pid 23743] connect(6, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("93.184.216.34")}, 16) = -1 EINPROGRESS (Operation now in progress)

均只能看到只有 1 次 connect 的调用。 是不是已经能说明连接确实被复用了?我想是的。

这篇文章的内容已被作者标记为“过时”/“需要更新”/“不具参考意义”。

标签:HTTP · Go