go 的 net/http 有哪些值得关注的细节?
golang 的 net/http 库是我们平时写代码中,非常常用的标准库。由于 go 语言拥有 goroutine,goroutine 的上下文切换成本比普通线程低很多,net/http 库充分利用了这个优势,因此,它的内部实现跟其他语言会有一些区别。
其中最大的区别在于,其他语言中,一般是多个网络句柄共用一个或多个线程,以此来减少线程之间的切换成本。而 golang 则会为每个网络句柄创建两个 goroutine,一个用于读数据,一个用于写数据。
图片读写协程下图是 net/http 源码中创建这两个 goroutine 的地方。
图片源码中创建两个协程的地方了解它的内部实现原理,可以帮助我们写出更高性能的代码,以及避免协程泄露造成的内存泄漏问题。
这篇文章是希望通过几个例子让大家对 net/http 的内部实现有更直观的理解。
连接与协程数量的关系首先我们来看一个例子。
func main() {tr := &http.Transport{MaxIdleConns: 100,IdleConnTimeout: 3 * time.Second,}
}上面的代码做的事情很简单,执行 5 次循环 http 请求,最终通过 runtime.NumGoroutine()方法打印当前的 goroutine 数量。
代码里只有三个地方需要注意:
Transport 设置了一个 3s 的空闲连接超时
for 循环执行了 5 次 http 请求
程序退出前执行了 5s sleep
答案输出 1。也就是说当程序退出的时候,当前的 goroutine 数量为 1,毫无疑问它指的是正在运行 main 方法的 goroutine,后面我们都叫它 main goroutine。
再来看个例子。
func main() {tr := &http.Transport{MaxIdleConns: 100,IdleConnTimeout: 3 * time.Second,}
}在原来的基础上,我们程序退出前的睡眠时间,从 5s 改成 1s,此时输出 3。也就是说除了 main 方法所在的 goroutine,还多了两个 goroutine,我们大概也能猜到,这就是文章开头提到的读 goroutine 和写 goroutine。也就是说程序在退出时,还有一个网络连接没有断开。
这是一个 TCP 长连接。
图片 HTTP1.1 底层依赖 TCP 网络五层模型中,HTTP 处于应用层,它的底层依赖了传输层的 TCP 协议。
当我们发起 http 请求时,如果每次都要建立新的 TCP 协议,那就需要每次都经历三次握手,这会影响性能,因此更好的方式就是在 http 请求结束后,不立马断开 TCP 连接,将它放到一个空闲连接池中,后续有新的 http 请求时就复用该连接。
像这种长时间存活,被多个 http 请求复用的 TCP 连接,就是所谓的长连接。反过来,如果每次 HTTP 请求结束就将 TCP 连接进行四次挥手断开,下次有需要执行 HTTP 调用时就再建立,这样的 TCP 连接就是所谓的短连接。
HTTP1.1 之后默认使用长连接。
图片连接池复用连接那为什么这跟 5s 和 1s 有关系?
这是因为长连接在空闲连接池也不能一直存放着,如果一直没被使用放着也是浪费资源,因此会有个空闲回收时间,也就是上面代码中的 IdleConnTimeout,我们设置的是 3s,当代码在结束前 sleep 了 5s 后,长连接就已经被释放了,因此输出结果是只剩一个 main goroutine。当 sleep 1s 时,长连接还在空闲连接池里,因此程序结束时,就还剩 3 个 goroutine(main goroutine+网络读 goroutine+网络写 goroutine)。
我们可以改下代码下验证这个说法。我们知道,HTTP 可以通过 connection 的 header 头来控制这次的 HTTP 请求是用的长连接还是短连接。connection:keep-alive 表示 http 请求结束后,tcp 连接保持存活,也就是长连接, connection:close 则是短连接。
req.Header.Add("connection", "close")就像下面这样。
func main() {tr := &http.Transport{MaxIdleConns: 100,IdleConnTimeout: 3 * time.Second,}
}此时,会发现,程序重新输出 1。完全符合我们预期。
resp.body 是否读取对连接复用的影响 func main() {n := 5for i := 0; i < n; i++ {resp, _ := http.Get("https://www.baidu.com")_ = resp.Body.Close()}fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())}注意这里没有执行 ioutil.ReadAll(resp.Body)。也就是说 http 请求响应的结果并没有被读取的情况下,net/http 库会怎么处理。
上面的代码最终输出 3,分别是 main goroutine,read goroutine 以及 write goroutine。也就是说长连接没有断开,那长连接是会在下一次 http 请求中被复用吗?先说答案,不会复用。
我们可以看代码。resp.Body.Close() 会执行到 func (es * bodyEOFSignal) Close() error 中,并执行到 es.earlyCloseFn()中。
图片图片 earlyCloseFn 的逻辑也非常简单,就是将一个 false 传入到 waitForBodyRead 的 channel 中。那写入通道后的数据会在另外一个地方被读取,我们来看下读取的地方。
图片 bodyEOF 为 false, 也就不需要执行 tryPutIdleConn()方法。
tryPutIdleConn 会将连接放到长连接池中备用)。
最终就是 alive=bodyEOF ,也就是 false,字面意思就是该连接不再存活。因此该长连接并不会复用,而是会释放。
那为什么 output 输出为 3?这是因为长连接释放需要时间。
我们可以在结束前加一个休眠,比如再执行休眠 1 毫秒。
func main() {n := 5for i := 0; i < n; i++ {resp, _ := http.Get("https://www.baidu.com")_ = resp.Body.Close()}time.Sleep(time.Millisecond * 1)fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())}此时就会输出 1。说明协程是退出中的,只是没来得及完全退出,休眠 1ms 后彻底退出了。
如果我们,将在代码中重新加入 ioutil.ReadAll(resp.Body),就像下面这样。
func main() {n := 5for i := 0; i < n; i++ {resp, _ := http.Get("https://www.baidu.com")_, _ = ioutil.ReadAll(resp.Body)_ = resp.Body.Close()}fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())}此时,output 还是输出 3,但这个 3 跟上面的 3 不太一样,休眠 5s 后还是输出 3。这是因为长连接被推入到连接池了,连接会重新复用。
下面是源码的解释。
图片图片
body.close()不执行会怎么样网上都说不执行 body.close()会协程泄漏(导致内存泄露),真的会出现协程泄漏吗,如果泄漏,会泄漏多少?
func main() {tr := &http.Transport{MaxIdleConns: 100,IdleConnTimeout: 3 * time.Second,}
}我们可以运行这段代码,代码中将 resp.body.close()注释掉,结果输出 3。debug 源码,会发现连接其实复用了。代码执行到 tryPutIdleConn 函数中,会将连接归还到空闲连接池中。
图片休眠 5s,结果输出 1,这说明达到 idleConnTimeout,空闲连接断开。看起来一切正常。
将 resp.Body.Close()那一行代码重新加回来,也就是下面这样,会发现代码结果依然输出 3。我们是否删除这行代码,对结果没有任何影响。
func main() {tr := &http.Transport{MaxIdleConns: 100,IdleConnTimeout: 3 * time.Second,}
}既然执不执行 body.close()都没啥区别,那 body.close()的作用是什么呢?
它是为了标记当前连接请求中,response.body 是否使用完毕,如果不执行 body.close(),则 resp.Body 中的数据是可以不断重复读且不报错的(但不一定能读到数据),执行了 body.close(),再次去读取 resp.Body 则会报错,如果 resp.body 数据读一半,处理代码逻辑就报错了,此时你不希望其他地方继续去读,那就需要使用 body.close()去关闭它。这更像是一种规范约束,它可以更好的保证数据正确。
也就是说不执行 body.close(),并不一定会内存泄露。那么什么情况下会协程泄露呢?
直接说答案,既不执行 ioutil.ReadAll(resp.Body) 也不执行 resp.Body.Close(),并且不设置 http.Client 内 timeout 的时候,就会导致协程泄露。
比如下面这样。
func main() {tr := &http.Transport{MaxIdleConns: 100,IdleConnTimeout: 3 * time.Second,}
}最终结果会输出 11,也就是 1 个 main goroutine + (1 个 read goroutine + 1 个 read goroutine)* 5 次 http 请求。
前面提到,不执行 ioutil.ReadAll(resp.Body),网络连接无法归还到连接池。不执行 resp.Body.Close(),网络连接就无法为标记为关闭,也就无法正常断开。因此能导致协程泄露,非常好理解。
但 http.Client 内 timeout 有什么关系?这是因为 timeout 是指,从发起请求到从 resp.body 中读完响应数据的总时间,如果超过了,网络库会自动断开网络连接,并释放 read+write goroutine。因此如果设置了 timeout,则不会出现协程泄露的问题。
另外值得一提的是,我看到有不少代码都是直接用下面的方式去做网络请求的。
resp, _ := http.Get("https://www.baidu.com")这种方式用的是 DefaultClient,是没有设置超时的,生产环境中使用不当,很容易出现问题。
func Get(url string) (resp *Response, err error) {return DefaultClient.Get(url)}
var DefaultClient = &Client{}
连接池的结构我们了解到连接池可以复用网络连接,接下来我们通过一个例子来看看网络连接池的结构。
func main() {tr := &http.Transport{MaxIdleConns: 100,IdleConnTimeout: 3 * time.Second,}
}注意这里请求的不是 https,而是 http。最终结果输出 5,为什么?
这是因为,http://www.baidu.com会返回 307,重定向到https://www.baidu.com。
图片 http 重定向为 https 在网络中,我们可以通过一个五元组来唯一确定一个 TCP 连接。
图片五元组它们分别是源 ip,源端口,协议,目的 ip,目的端口。只有当多次请求的五元组一样的情况下,才有可能复用连接。
放在我们这个场景下,源 ip、源端口、协议都是确定的,也就是两次 http 请求的目的 ip 或目的端口有区别的时候,就需要使用不同的 TCP 长连接。
而 http 用的是 80 端口,https 用的是 443 端口。于是连接池就为不同的网络目的地建立不同的长连接。
因此最终结果 5 个 goroutine,其实 2 个 goroutine 来自 http,2 个 goroutine 来自 https,1 个 main goroutine。
我们来看下源码的具体实现。net/http 底层通过一个叫 idleConn 的 map 去存空闲连接,也就是空闲连接池。
图片图片 idleConn 这个 map 的 key 是协议和地址,其实本质上就是 ip 和端口。map 的 value 是长连接的数组([]*persistConn),说明 net/http 支持为同一个地址建立多个 TCP 连接,这样可以提升传输的吞吐。
图片连接池的结构和逻辑
Transport 是什么?Transport 本质上是一个用来控制 http 调用行为的一个组件,里面包含超时控制,连接池等,其中最重要的是连接池相关的配置。
我们通过下面的例子感受下。
func main() {n := 5for i := 0; i < n; i++ {httpClient := &http.Client{}resp, _ := httpClient.Get("https://www.baidu.com")_, _ = ioutil.ReadAll(resp.Body)_ = resp.Body.Close()}time.Sleep(time.Second * 1)fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())}func main() {n := 5for i := 0; i < n; i++ {httpClient := &http.Client{Transport: &http.Transport{},}resp, _ := httpClient.Get("https://www.baidu.com")_, _ = ioutil.ReadAll(resp.Body)_ = resp.Body.Close()}time.Sleep(time.Second * 1)fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())}上面的代码第一个例子的代码会输出 3。分别是 main goroutine + read goroutine + write goroutine,也就是有一个被不断复用的 TCP 连接。
在第二例子中,当我们在每次 client 中都创建一个新的 http.Transport,此时就会输出 11。
说明 TCP 连接没有复用,每次请求都会产生新的连接。这是因为每个 http.Transport 内都会维护一个自己的空闲连接池,如果每个 client 都创建一个新的 http.Transport,就会导致底层的 TCP 连接无法复用。如果网络请求过大,上面这种情况会导致协程数量变得非常多,导致服务不稳定。
因此,最佳实践是所有 client 都共用一个 transport。
func main() {tr := &http.Transport{MaxIdleConns: 100,IdleConnTimeout: 3 * time.Second,}
}如果创建客户端的时候不指定 http.Client,会默认所有 http.Client 都共用同一个 DefaultTransport。这一点可以从源码里看出。
图片默认使用 DefaultTransport 图片 DefaultTransport 因此当第二段代码中,每次都重新创建一个 Transport 的时候,每个 Transport 内都会各自维护一个空闲连接池。因此每次建立长连接后都会多两个协程(读+写),对应 1 个 main goroutine+(read goroutine + write goroutine)* 5 =11。
别设置 Transport.Dail 里的 SetDeadlinehttp.Transport.Dial 的配置里有个 SetDeadline,它表示连接建立后发送接收数据的超时时间。听起来跟 client.Timeout 很像。
那么他们有什么区别呢?我们通过一个例子去看下。
package main
import ("bytes""encoding/json""fmt""io/ioutil""net""net/http""time")
var tr *http.Transport
func init() {tr = &http.Transport{MaxIdleConns: 100,Dial: func(netw, addr string) (net.Conn, error) {conn, err := net.DialTimeout(netw, addr, time.Second*2) //设置建立连接超时 if err != nil {return nil, err}err = conn.SetDeadline(time.Now().Add(time.Second * 3)) //设置发送接受数据超时 if err != nil {return nil, err}return conn, nil},}}
func main() {for {_, err := Get("http://www.baidu.com/")if err != nil {fmt.Println(err)break}}}
func Get(url string) ([]byte, error) {m := make(map[string]interface{})data, err := json.Marshal(m)if err != nil {return nil, err}body := bytes.NewReader(data)req, _ := http.NewRequest("Get", url, body)req.Header.Add("content-type", "application/json")
}
上面这段代码,我们设置了 SetDeadline 为 3s,当你执行一段时间,会发现请求 baidu 会超时,但其实 baidu 的接口很快,不可能超过 3s。
在生产环境中,假如是你的服务调用下游服务,你看到的现象就是,你的服务显示 3s 超时了,但下游服务可能只花了 200ms 就已经响应你的请求了,并且这是随机发生的问题。遇到这种情况,我们一般会认为是“网络波动”。
但如果我们去对网络抓包,就很容易发现问题的原因 。
图片抓包结果可以看到,在 tcp 三次握手之后,就会开始多次网络请求。直到 3s 的时候,就会触发 RST 包,断开连接。也就是说,我们设置的 SetDeadline,并不是指单次 http 请求的超时是 3s,而是指整个 tcp 连接的存活时间是 3s,计算长连接被连接池回收,这个时间也不会重置。
图片 SetDeadline 的解释我实在想不到什么样的场景会需要这个功能,因此我的建议是,不要使用它。
下面是修改后的代码。这个问题其实在我另外一篇文章有过详细的解释,如果你对源码解析感兴趣的话,可以去看看。
package main
import ("bytes""encoding/json""fmt""io/ioutil""net/http""time")
var tr *http.Transport
func init() {tr = &http.Transport{MaxIdleConns: 100,// 下面的代码被干掉了//Dial: func(netw, addr string) (net.Conn, error) {// conn, err := net.DialTimeout(netw, addr, time.Second*2) //设置建立连接超时// if err != nil {// return nil, err// }// err = conn.SetDeadline(time.Now().Add(time.Second * 3)) //设置发送接受数据超时// if err != nil {// return nil, err// }// return conn, nil//},}}
func Get(url string) ([]byte, error) {m := make(map[string]interface{})data, err := json.Marshal(m)if err != nil {return nil, err}body := bytes.NewReader(data)req, _ := http.NewRequest("Get", url, body)req.Header.Add("content-type", "application/json")
}
func main() {for {_, err := Get("http://www.baidu.com/")if err != nil {fmt.Println(err)break}}}总结 golang 的 net/http 部分有不少细节点,直接上源码分析怕劝退不少人,所以希望以几个例子作为引子展开话题然后深入了解它的内部实现。总体内容比较碎片化,但这个库的重点知识点基本都在这里面了。希望对大家后续排查问题有帮助。
评论