从 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
,然后执行resp.Body.Close()
之后,则其底层连接会直接关闭 - 如果读取了
resp.Body
,然后关闭,则当前 HTTP 请求结束之后,其底层 TCP 连接不会立即关闭,会放入底层连接池中等待下一个同主机请求使用
一般情况下,考虑到性能因素,都需要尽可能保持底层的连接,所以有时对 resp.Body
不感兴趣时,通常使用 io.Copy(io.Discard, resp.Body)
,读取响应体然后将其丢弃。这样就保持住了底层的连接。
代码验证下上面的结论
下面的代码区分了两种情况:
- 一种是读取了
resp.Body
,然后resp.Body.Close()
,连续请求三次。 - 另一种未读取
resp.Body
,然后resp.Body.Close()
,连续请求三次。
我们使用 httptrace
的 GotConn
来查看每次建立连接时的情况。
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
条件。那也就进入到上面的两个不同的流程中。