<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          Go 的 net/http 有哪些值得關注的細節(jié)?

          共 28110字,需瀏覽 57分鐘

           ·

          2023-08-24 03:12

          golang的net/http庫是我們平時寫代碼中,非常常用的標準庫。由于go語言擁有goroutine,goroutine的上下文切換成本比普通線程低很多,net/http庫充分利用了這個優(yōu)勢,因此,它的內部實現跟其他語言會有一些區(qū)別。

          其中最大的區(qū)別在于,其他語言中,一般是多個網絡句柄共用一個或多個線程,以此來減少線程之間的切換成本。而golang則會為每個網絡句柄創(chuàng)建兩個goroutine,一個用于讀數據,一個用于寫數據。

          讀寫協(xié)程

          下圖是net/http源碼中創(chuàng)建這兩個goroutine的地方。

          源碼中創(chuàng)建兩個協(xié)程的地方

          了解它的內部實現原理,可以幫助我們寫出更高性能的代碼,以及避免協(xié)程泄露造成的內存泄漏問題。

          這篇文章是希望通過幾個例子讓大家對net/http的內部實現有更直觀的理解。


          連接與協(xié)程數量的關系

          首先我們來看一個例子。

          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())
          }

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

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

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

          2. 2. for循環(huán)執(zhí)行了5次http請求

          3. 3. 程序退出前執(zhí)行了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協(xié)議。

          當我們發(fā)起http請求時,如果每次都要建立新的TCP協(xié)議,那就需要每次都經歷三次握手,這會影響性能,因此更好的方式就是在http請求結束后,不立馬斷開TCP連接,將它放到一個空閑連接池中,后續(xù)有新的http請求時就復用該連接。

          像這種長時間存活,被多個http請求復用的TCP連接,就是所謂的長連接。反過來,如果每次HTTP請求結束就將TCP連接進行四次揮手斷開,下次有需要執(zhí)行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())
          }

          此時,會發(fā)現,程序重新輸出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())
          }

          注意這里沒有執(zhí)行 ioutil.ReadAll(resp.Body)。也就是說http請求響應的結果并沒有被讀取的情況下,net/http庫會怎么處理。

          上面的代碼最終輸出3,分別是main goroutine,read goroutine 以及write goroutine。也就是說長連接沒有斷開,那長連接是會在下一次http請求中被復用嗎?先說答案,不會復用

          我們可以看代碼。resp.Body.Close() 會執(zhí)行到 func (es * bodyEOFSignal) Close() error 中,并執(zhí)行到es.earlyCloseFn()中。

          earlyCloseFn的邏輯也非常簡單,就是將一個false傳入到waitForBodyRead的channel中。那寫入通道后的數據會在另外一個地方被讀取,我們來看下讀取的地方。

          bodyEOF為false, 也就不需要執(zhí)行 tryPutIdleConn()方法。

          tryPutIdleConn會將連接放到長連接池中備用)。

          最終就是alive=bodyEOF ,也就是false,字面意思就是該連接不再存活。因此該長連接并不會復用,而是會釋放。

          那為什么output輸出為3?這是因為長連接釋放需要時間。

          我們可以在結束前加一個休眠,比如再執(zhí)行休眠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。說明協(xié)程是退出中的,只是沒來得及完全退出,休眠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()不執(zhí)行會怎么樣

          網上都說不執(zhí)行body.close()會協(xié)程泄漏(導致內存泄露),真的會出現協(xié)程泄漏嗎,如果泄漏,會泄漏多少?

          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源碼,會發(fā)現連接其實復用了。代碼執(zhí)行到tryPutIdleConn函數中,會將連接歸還到空閑連接池中。

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

          resp.Body.Close()那一行代碼重新加回來,也就是下面這樣,會發(fā)現代碼結果依然輸出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())
          }

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

          它是為了標記當前連接請求中,response.body是否使用完畢,如果不執(zhí)行body.close(),則resp.Body中的數據是可以不斷重復讀且不報錯的(但不一定能讀到數據),執(zhí)行了body.close(),再次去讀取resp.Body則會報錯,如果resp.body數據讀一半,處理代碼邏輯就報錯了,此時你不希望其他地方繼續(xù)去讀,那就需要使用body.close()去關閉它。這更像是一種規(guī)范約束,它可以更好的保證數據正確。

          也就是說不執(zhí)行body.close(),并不一定會內存泄露。那么什么情況下會協(xié)程泄露呢?

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

          比如下面這樣。

          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請求。

          前面提到,不執(zhí)行ioutil.ReadAll(resp.Body),網絡連接無法歸還到連接池不執(zhí)行resp.Body.Close(),網絡連接就無法為標記為關閉,也就無法正常斷開。因此能導致協(xié)程泄露,非常好理解。

          但http.Client內timeout有什么關系?這是因為timeout是指,從發(fā)起請求到從resp.body中讀完響應數據的總時間,如果超過了,網絡庫會自動斷開網絡連接,并釋放read+write goroutine。因此如果設置了timeout,則不會出現協(xié)程泄露的問題。

          另外值得一提的是,我看到有不少代碼都是直接用下面的方式去做網絡請求的。

          resp, _ := http.Get("https://www.baidu.com")

          這種方式用的是DefaultClient,是沒有設置超時的,生產環(huán)境中使用不當,很容易出現問題。

          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,源端口,協(xié)議,目的ip,目的端口。只有當多次請求的五元組一樣的情況下,才有可能復用連接。

          放在我們這個場景下,源ip、源端口、協(xié)議都是確定的,也就是兩次http請求的目的ip或目的端口有區(qū)別的時候,就需要使用不同的TCP長連接。

          而http用的是80端口,https用的是443端口。于是連接池就為不同的網絡目的地建立不同的長連接。

          因此最終結果5個goroutine,其實2個goroutine來自http,2個goroutine來自https,1個main goroutine。

          我們來看下源碼的具體實現。net/http底層通過一個叫idleConnmap去存空閑連接,也就是空閑連接池。

          idleConn這個map的key是協(xié)議和地址,其實本質上就是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中都創(chuàng)建一個新的http.Transport,此時就會輸出11

          說明TCP連接沒有復用,每次請求都會產生新的連接。這是因為每個http.Transport內都會維護一個自己的空閑連接池,如果每個client都創(chuàng)建一個新的http.Transport,就會導致底層的TCP連接無法復用。如果網絡請求過大,上面這種情況會導致協(xié)程數量變得非常多,導致服務不穩(wěn)定。

          因此,最佳實踐是所有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())
          }

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

          默認使用DefaultTransport
          DefaultTransport

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


          別設置 Transport.Dail里的SetDeadline

          http.Transport.Dial的配置里有個SetDeadline,它表示連接建立后發(fā)送接收數據的超時時間。聽起來跟client.Timeout很像。

          那么他們有什么區(qū)別呢?我們通過一個例子去看下。

          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)) //設置發(fā)送接受數據超時
                      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) ([]byteerror) {
              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,當你執(zhí)行一段時間,會發(fā)現請求baidu會超時,但其實baidu的接口很快,不可能超過3s。

          在生產環(huán)境中,假如是你的服務調用下游服務,你看到的現象就是,你的服務顯示3s超時了,但下游服務可能只花了200ms就已經響應你的請求了,并且這是隨機發(fā)生的問題。遇到這種情況,我們一般會認為是“網絡波動”。

          但如果我們去對網絡抓包,就很容易發(fā)現問題的原因 。

          抓包結果

          可以看到,在tcp三次握手之后,就會開始多次網絡請求。直到3s的時候,就會觸發(fā)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)) //設置發(fā)送接受數據超時
                  // if err != nil {
                  //  return nil, err
                  // }
                  // return conn, nil
                  //},
              }
          }


          func Get(url string) ([]byteerror) {
              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部分有不少細節(jié)點,直接上源碼分析怕勸退不少人,所以希望以幾個例子作為引子展開話題然后深入了解它的內部實現。總體內容比較碎片化,但這個庫的重點知識點基本都在這里面了。希望對大家后續(xù)排查問題有幫助。

          瀏覽 164
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  美女露出粉嫩的尿囗桶爽 | 亚洲中文字幕性爱 | 日韩草比视频 | 四虎成人无码影院 | 在线免费观看黄片视频 |