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,
    }

    n := 5
    for i := 0; i < n; i++ {
        req, _ := http.NewRequest("POST""https://www.baidu.com", nil)
        req.Header.Add("content-type""application/json")
        client := &http.Client{
            Transport: tr,
            Timeout:   3 * time.Second,
        }
        resp, _ := client.Do(req)
        _, _ = ioutil.ReadAll(resp.Body)
        _ = resp.Body.Close()
    }
    time.Sleep(time.Second * 5)
    fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())
}

上面的代碼做的事情很簡單,執行 5 次循環 http 請求,最終通過 runtime.NumGoroutine() 方法打印當前的 goroutine 數量。

代碼裏只有三個地方需要注意:

  1. 1. Transport 設置了一個 3s 的空閒連接超時

  2. 2. for 循環執行了 5 次 http 請求

  3. 3. 程序退出前執行了 5s sleep

答案輸出1。也就是說當程序退出的時候,當前的 goroutine 數量爲 1,毫無疑問它指的是正在運行 main 方法的 goroutine,後面我們都叫它main goroutine

再來看個例子。

func main() {
    tr := &http.Transport{
        MaxIdleConns:    100,
        IdleConnTimeout: 3 * time.Second,
    }

    n := 5
    for i := 0; i < n; i++ {
        req, _ := http.NewRequest("POST""https://www.baidu.com", nil)
        req.Header.Add("content-type""application/json")
        client := &http.Client{
            Transport: tr,
            Timeout:   3 * time.Second,
        }
        resp, _ := client.Do(req)
        _, _ = ioutil.ReadAll(resp.Body)
        _ = resp.Body.Close()
    }
    time.Sleep(time.Second * 1)
    fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())
}

在原來的基礎上,我們程序退出前的睡眠時間,從 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 可以通過connectionheader頭來控制這次的 HTTP 請求是用的長連接還是短連接。connection:keep-alive 表示 http 請求結束後,tcp 連接保持存活,也就是長連接, connection:close則是短連接。

req.Header.Add("connection""close")

就像下面這樣。

func main() {
    tr := &http.Transport{
        MaxIdleConns:    100,
        IdleConnTimeout: 3 * time.Second,
    }

    n := 5
    for i := 0; i < n; i++ {
        req, _ := http.NewRequest("POST""https://www.baidu.com", nil)
        req.Header.Add("content-type""application/json")
        req.Header.Add("connection""close")
        client := &http.Client{
            Transport: tr,
            Timeout:   3 * time.Second,
        }
        resp, _ := client.Do(req)
        _, _ = ioutil.ReadAll(resp.Body)
        _ = resp.Body.Close()
    }
    time.Sleep(time.Second * 1)
    fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())
}

此時,會發現,程序重新輸出1。完全符合我們預期。

resp.body 是否讀取對連接複用的影響

func main() {
   n := 5
   for i := 0; i < n; i++ {
      resp, _ := http.Get("https://www.baidu.com")
      _ = resp.Body.Close()
   }
   fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())
}

我們可以看代碼。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 := 5
    for 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 := 5
    for 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,
    }

    n := 5
    for i := 0; i < n; i++ {
        req, _ := http.NewRequest("POST""https://www.baidu.com", nil)
        req.Header.Add("content-type""application/json")
        client := &http.Client{
            Transport: tr,
        }
        resp, _ := client.Do(req)
        _, _ = ioutil.ReadAll(resp.Body)
        //_ = resp.Body.Close()
    }
    time.Sleep(time.Second * 1)
    fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())
}

我們可以運行這段代碼,代碼中將resp.body.close()註釋掉,結果輸出3。debug 源碼,會發現連接其實複用了。代碼執行到tryPutIdleConn函數中,會將連接歸還到空閒連接池中。

休眠 5s,結果輸出1,這說明達到idleConnTimeout,空閒連接斷開。看起來一切正常。

resp.Body.Close()那一行代碼重新加回來,也就是下面這樣,會發現代碼結果依然輸出3我們是否刪除這行代碼,對結果沒有任何影響。

func main() {
    tr := &http.Transport{
        MaxIdleConns:    100,
        IdleConnTimeout: 3 * time.Second,
    }

    n := 5
    for i := 0; i < n; i++ {
        req, _ := http.NewRequest("POST""https://www.baidu.com", nil)
        req.Header.Add("content-type""application/json")
        client := &http.Client{
            Transport: tr,
        }
        resp, _ := client.Do(req)
        _, _ = ioutil.ReadAll(resp.Body)
        _ = resp.Body.Close()
    }
    time.Sleep(time.Second * 1)
    fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())
}

既然執不執行 body.close() 都沒啥區別,那 body.close() 的作用是什麼呢?

也就是說不執行 body.close(),並不一定會內存泄露。那麼什麼情況下會協程泄露呢?

直接說答案,既不執行 ioutil.ReadAll(resp.Body) 也不執行resp.Body.Close(),並且不設置http.Clienttimeout的時候,就會導致協程泄露

比如下面這樣。

func main() {
    tr := &http.Transport{
        MaxIdleConns:    100,
        IdleConnTimeout: 3 * time.Second,
    }

    n := 5
    for i := 0; i < n; i++ {
        req, _ := http.NewRequest("POST""https://www.baidu.com", nil)
        req.Header.Add("content-type""application/json")
        client := &http.Client{
            Transport: tr,
        }
        resp, _ := client.Do(req)
        _ = resp
    }
    time.Sleep(time.Second * 5)
    fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())
}

最終結果會輸出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,
    }

    n := 5
    for i := 0; i < n; i++ {
        req, _ := http.NewRequest("POST""http://www.baidu.com", nil)
        req.Header.Add("content-type""application/json")
        client := &http.Client{
            Transport: tr,
            Timeout:   3 * time.Second,
        }
        resp, _ := client.Do(req)
        _, _ = ioutil.ReadAll(resp.Body)
        _ = resp.Body.Close()
    }
    time.Sleep(time.Second * 1)
    fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())
}

注意這裏請求的不是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 底層通過一個叫idleConnmap 去存空閒連接,也就是空閒連接池。

idleConn這個 map 的 key 是協議和地址,其實本質上就是 ip 和端口。map 的 value 是長連接的數組([]*persistConn),說明 net/http 支持爲同一個地址建立多個 TCP 連接,這樣可以提升傳輸的吞吐。

連接池的結構和邏輯

Transport 是什麼?

Transport 本質上是一個用來控制 http 調用行爲的一個組件,裏面包含超時控制,連接池等,其中最重要的是連接池相關的配置。

我們通過下面的例子感受下。

func main() {
    n := 5
    for 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 := 5
    for 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,
    }

    n := 5
    for i := 0; i < n; i++ {
        req, _ := http.NewRequest("POST""https://www.baidu.com", nil)
        req.Header.Add("content-type""application/json")
        client := &http.Client{
            Transport: tr,
            Timeout:   3 * time.Second,
        }
        resp, _ := client.Do(req)
        _, _ = ioutil.ReadAll(resp.Body)
        _ = resp.Body.Close()
    }
    time.Sleep(time.Second * 1)
    fmt.Printf("goroutine num is %d\n", runtime.NumGoroutine())
}

如果創建客戶端的時候不指定http.Client,會默認所有 http.Client 都共用同一個DefaultTransport。這一點可以從源碼裏看出。

默認使用 DefaultTransportDefaultTransport

因此當第二段代碼中,每次都重新創建一個 Transport 的時候,每個 Transport 內都會各自維護一個空閒連接池。因此每次建立長連接後都會多兩個協程(讀 + 寫),對應 1 個 main goroutine+(read goroutine + write goroutine)* 5 =11。

別設置 Transport.Dail 裏的 SetDeadline

http.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")

    client := &http.Client{
        Transport: tr,
    }
    res, err := client.Do(req)
    if res != nil {
        defer res.Body.Close()
    }
    if err != nil {
        return nil, err
    }
    resBody, err := ioutil.ReadAll(res.Body)
    if err != nil {
        return nil, err
    }
    return resBody, nil
}

上面這段代碼,我們設置了 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")

    client := &http.Client{
        Transport: tr,
        Timeout: 3*time.Second,  // 超時加在這裏,是每次調用的超時
    }
    res, err := client.Do(req) 
    if res != nil {
        defer res.Body.Close()
    }
    if err != nil {
        return nil, err
    }
    resBody, err := ioutil.ReadAll(res.Body)
    if err != nil {
        return nil, err
    }
    return resBody, nil
}

func main() {
    for {
        _, err := Get("http://www.baidu.com/")
        if err != nil {
            fmt.Println(err)
            break
        }
    }
}

總結

golang 的 net/http 部分有不少細節點,直接上源碼分析怕勸退不少人,所以希望以幾個例子作爲引子展開話題然後深入瞭解它的內部實現。總體內容比較碎片化,但這個庫的重點知識點基本都在這裏面了。希望對大家後續排查問題有幫助。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/QfeycEFqeqqhRKrYYL5mGA