从 Golang 中是否需要关闭 HTTP Body 说起

2024年06月09日

对于 Golang 开发者来说,是需要经常和 HTTP 打交道的。标准库中的 http 包也是很常用的。那么今天就来讨论一下在使用 http 包时, HTTP Body 在哪些情况下需要开发者主动关闭,哪些情况下不需要关心。

作为服务端,无需关心 req.Body 是否关闭

在服务端中,我们一般会提供一个 HandleFunc 来处理请求。

针对请求的 Body,我们完全没必要做任何额外的处理。如果需要则读取它,不需要则不用做任何操作。之后也无需考虑关闭。

1// 可不处理 req
2http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
3	fmt.Fprintf(w, "hello world")
4})

可以从标准库开发者的角度来考虑一下,当服务端给客户端发送响应之前,绝大多数情况下都需要先将 request 接收完,处理完,所以在发送响应状态码之前,肯定是需要先将 request.Body 读取完然后再 Close。而此操作完全不必在 http.HandleFunc 中处理,而是在标准库中做了统一处理。

可以看到在标准库 net/http/server.go 源码中,服务端在响应之前,通常会先将 reqBody 消耗掉关闭掉。

 1func (cw *chunkWriter) writeHeader(p []byte) {
 2	// ...
 3	// We do this by default because there are a number of clients that
 4	// send a full request before starting to read the response, and they
 5	// can deadlock if we start writing the response with unconsumed body
 6	// remaining. See Issue 15527 for some history.
 7	if w.req.ContentLength != 0 && !w.closeAfterReply && !w.fullDuplex {
 8		// ...
 9		discard = true
10		// ...
11	}
12	if discard {
13		_, err := io.CopyN(io.Discard, w.reqBody, maxPostHandlerReadBytes+1)
14		// err is io.EOF, then Close
15		w.reqBody.Close()
16	}
17	// ...
18}

了解了服务端无需对 req.Body 关闭之后,我还好奇如果在 w http.ResponseWriter 中不做任何处理,或者仅写了数据却没有写 HTTP 状态码,其默认会返回哪个状态码呢?

还是通过阅读源码可以看到,其默认的状态码是 200 StatusOK

1func (w *response) finishRequest() {
2	// ...
3	if !w.wroteHeader {
4		w.WriteHeader(StatusOK)
5	}
6	// ...
7}

作为客户端,需手动关闭 resp.Body

但当使用客户端请求时,返回的 *http.Response中,其 resp.Body都是需要开发者来主动关闭的。代码一般长这样:

1resp, err := client.Do(req)
2defer resp.Body.Close()
3// handle resp.Body
4io.Copy(io.Discard, resp.Body)

如果少了 defer resp.Body.Close(),那么就会出现资源泄漏。

此时应该注意,在关闭之前,我们可以读取 resp.Body,也可以不读取直接关闭,对于这两种选择所造成的后果是不同的。

一般情况下,考虑到性能因素,都需要尽可能保持底层的连接,所以有时对 resp.Body 不感兴趣时,通常使用 io.Copy(io.Discard, resp.Body),读取响应体然后将其丢弃。这样就保持住了底层的连接。

代码验证下上面的结论

下面的代码区分了两种情况:

我们使用 httptraceGotConn 来查看每次建立连接时的情况。

  1package main
  2
  3import (
  4	"fmt"
  5	"io"
  6	"log"
  7	"net"
  8	"net/http"
  9	"net/http/httptrace"
 10	"time"
 11)
 12
 13func main() {
 14	err := do()
 15	if err != nil {
 16		log.Fatal(err)
 17	}
 18}
 19
 20func do() error {
 21	client := &http.Client{
 22		Timeout: 5 * time.Second,
 23		Transport: &http.Transport{
 24			Proxy: http.ProxyFromEnvironment,
 25			DialContext: (&net.Dialer{
 26				Timeout: 2 * time.Second,
 27			}).DialContext,
 28			ForceAttemptHTTP2:     false,
 29			MaxIdleConns:          100,
 30			MaxIdleConnsPerHost:   10,
 31			IdleConnTimeout:       10 * time.Second,
 32			TLSHandshakeTimeout:   2 * time.Second,
 33			ResponseHeaderTimeout: 2 * time.Second,
 34		},
 35	}
 36	trace := &httptrace.ClientTrace{
 37		GotConn: func(connInfo httptrace.GotConnInfo) {
 38			fmt.Printf("Got Conn: %+v\n", connInfo)
 39		},
 40	}
 41
 42	fmt.Printf("multiGetReadBody:\n")
 43	if err := multiGetReadBody(client, trace); err != nil {
 44		return err
 45	}
 46
 47	client.CloseIdleConnections()
 48
 49	fmt.Printf("\nmultiGetNotReadBody:\n")
 50	if err := multiGetNotReadBody(client, trace); err != nil {
 51		return err
 52	}
 53
 54	return nil
 55}
 56
 57func multiGetReadBody(client *http.Client, trace *httptrace.ClientTrace) error {
 58	if err := get(client, trace, true); err != nil {
 59		return err
 60	}
 61	if err := get(client, trace, true); err != nil {
 62		return err
 63	}
 64	if err := get(client, trace, true); err != nil {
 65		return err
 66	}
 67	return nil
 68}
 69
 70func multiGetNotReadBody(client *http.Client, trace *httptrace.ClientTrace) error {
 71	if err := get(client, trace, false); err != nil {
 72		return err
 73	}
 74	if err := get(client, trace, false); err != nil {
 75		return err
 76	}
 77	if err := get(client, trace, false); err != nil {
 78		return err
 79	}
 80	return nil
 81}
 82
 83func get(client *http.Client, trace *httptrace.ClientTrace, readBody bool) error {
 84	req, err := http.NewRequest("GET", "https://www.baidu.com/", nil)
 85	if err != nil {
 86		return err
 87	}
 88	req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
 89
 90	resp, err := client.Do(req)
 91	if err != nil {
 92		return err
 93	}
 94	defer func() {
 95		err := resp.Body.Close()
 96		if err != nil {
 97			log.Printf("err: %v\n", err)
 98		}
 99	}()
100	if readBody {
101		_, err = io.Copy(io.Discard, resp.Body)
102		if err != nil {
103			return nil
104		}
105	}
106
107	return nil
108}

日志输出如下:

multiGetReadBody:
Got Conn: {Conn:0x1400018a008 Reused:false WasIdle:false IdleTime:0s}
Got Conn: {Conn:0x1400018a008 Reused:true WasIdle:true IdleTime:44µs}
Got Conn: {Conn:0x1400018a008 Reused:true WasIdle:true IdleTime:49.125µs}

multiGetNotReadBody:
Got Conn: {Conn:0x1400018a388 Reused:false WasIdle:false IdleTime:0s}
Got Conn: {Conn:0x14000128008 Reused:false WasIdle:false IdleTime:0s}
Got Conn: {Conn:0x1400009a708 Reused:false WasIdle:false IdleTime:0s}

前三次 HTTP 请求,我们是读取了 resp.Body,可以看到后两次请求 Reused:true WasIdle:true,表示后两次的请求都是复用的之前的底层连接。

后三次 HTTP 请求,根据日志输出可得到结论,其每次请求开始时都会新建一个 TCP 连接,这就说明了其未消耗掉 resp.Body,会使其每次结束都会关闭底层连接。

上面的代码也再次印证了我们已经得出的结论:

  • 如果没有读取 resp.Body,然后执行 resp.Body.Close() 之后,则其底层连接会直接关闭
  • 如果读取了 resp.Body,然后关闭,则当前 HTTP 请求结束之后,其底层 TCP 连接不会立即关闭,会放入底层连接池中等待下一个同主机请求使用

标准库源码中是如何处理的

只是读取或未读取 resp.Body,就会产生不一样的行为,此时我们就深入看下源码,看下其是在何时做的判断。

在标准库 net/http/transport.go 中:

1func (es *bodyEOFSignal) Close() error {
2	// ...
3	if es.earlyCloseFn != nil && es.rerr != io.EOF {
4		return es.earlyCloseFn()
5	}
6	err := es.body.Close()
7	return es.condfn(err)
8}

resp.Body.Close() 调用时,会进入到 bodyEOFSignal 结构的 Close 方法中。

在上面代码中可以看到,根据 es.rerr != io.EOF 的判断情况,会决定是进入 es.earlyCloseFn() 还是 es.body.Close()。前者会进入到直接关闭底层连接的流程,后者会将底层连接放入连接池中。

而我们代码中对于 resp.Body 的读取或未读取,会决定是否触达 io.EOF 条件。那也就进入到上面的两个不同的流程中。