go http1.1 長連接編程

http1.1 長連接編程

前言

作爲 server to server 模式的程序,http 長連接必不可少,本文假設的應用條件是高併發下的場景。
在 go 中,官方 http 包默認啓用了長連接,但爲了更好地理解,我們進行手動配置來說明。

服務端

服務啓動代碼
服務端開啓 http 長連接,設置了超時時間爲5秒。

func (h1 *H1Server) Open() {
	l, err := net.Listen("tcp", h1.addr)
	if err != nil {
		panic(err)
	}
	server := &http.Server{
		Handler:h1,
	}
	server.SetKeepAlivesEnabled(true)
	server.IdleTimeout = 5 * time.Second
	server.Serve(l)
}

請求處理代碼,我們假設處理每個請求需要 10ms

func (h1 *H1Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// 假設 http 處理一個請求需要10 毫秒
	time.Sleep(10 * time.Millisecond)

	var status int
	w.WriteHeader(http.StatusOK)
	_, err := w.Write([]byte("hello world"))
	if err != nil {
		status = http.StatusExpectationFailed
	}
	status = http.StatusOK

	slog := fmt.Sprintf(`%s %s %s [%s] "%s %s %s" %s %d`,
		r.Host,
		detect(parseUsername(r)), "-",
		r.Header.Get("Request-Id"),
		time.Now().Format("02/Jan/2006:15:04:05 -0700"),
		r.Method,
		r.URL.RequestURI(),
		r.Proto,
		status)
	fmt.Println(slog)
}

默認客戶端

接下來,我們使用 http 默認的客戶端進行測試

客戶端代碼

func NewH1Client() *H1Client {
	return &H1Client{
		c: &http.Client{
			Transport:http.DefaultTransport,
			//Transport: &http.Transport{
			//	MaxConnsPerHost:1,
			//	MaxIdleConns:1,
			//	MaxIdleConnsPerHost:1,
			//	DisableKeepAlives:   false,
			//},
		},
	}
}

http 的客戶端實際上是用 transport 進行 http 消息傳遞。假如我們什麼都不設置,transport 會使用默認的 transport。
而默認的 transport 並不適合 server to server 模式的通信。我們測試一下默認的 transport 性能。

benchmark 測試
在benchmark 測試中,我們設置併發數爲 NumCPU,但實際上 benchmark 設置的併發是單個 cpu 的併發,所以總併發爲 NumCPU * NumCPU

func BenchmarkPing_H1P1(b *testing.B) {
	c := NewH1Client()
	b.ReportAllocs()
	b.SetParallelism(runtime.NumCPU())
	for i := 0; i < b.N; i++ {
		b.RunParallel(func(pb *testing.PB) {
			for pb.Next() {
				Ping(c.c, "http://127.0.0.1:8086")
			}
		})
	}
}

測試結果

goos: windows
goarch: amd64
pkg: github.com/bemyth/go-language-advanced/httpx/client
BenchmarkPing_H1P1-4   	     100	  79405498 ns/op	  549047 B/op	    5522 allocs/op
PASS

執行了100次,每個請求耗時80ms,看上去沒什麼問題,但是執行 netstat 查看網絡狀態時,卻發現大量的 time_wait。
統計發現,共有 1000 多個 tcp 連接。連接過多肯定不是好事,因爲每個主機的可使用端口是受限制的,並且我們使用長連接,
很大的原因就是想限制連接數目,而現在看起來似乎鏈接數目並沒有被限制。
在高併發的server to server模式下,這種使用肯定是不被允許的。

C:\Users\dier\netstat -an | find "8086" /C
1883

自定義客戶端

爲了改變這種情況,我們自定義客戶端。

客戶端代碼

func NewH1Client() *H1Client {
	return &H1Client{
		c: &http.Client{
			//Transport:http.DefaultTransport,
			Transport: &http.Transport{
				MaxConnsPerHost:1,
				MaxIdleConns:1,
				MaxIdleConnsPerHost:1,
				DisableKeepAlives:   false,
			},
		},
	}
}

說明一下這段代碼,限制長連接數爲 1,即全部請求都只能通過同一個長連接進行。

benchmark 測試結果

goos: windows
goarch: amd64
pkg: github.com/bemyth/go-language-advanced/httpx/client
BenchmarkPing_H1P1-4   	     100	1149716865 ns/op	  352871 B/op	    4931 allocs/op
PASS

同樣是執行了 100 次,每次操作耗時 1000 ms,比起默認的客戶端差了 12 倍。細心的會發現,內存分配有略微下降。
我們再看一下網絡情況 netstat

C:\Users\dier\netstat -an | find "8086" /C
3

這次只有三個連接,我們可以打印出來看一下

C:\Users\dier\netstat -an | find "8086"
TCP 127.0.0.1:8086   0.0.0.0:0           LISTENING
TCP 127.0.0.1:8086   127.0.0.1:52425     LISTENING
TCP 127.0.0.1:52425  127.0.0.0:8086      LISTENING

因爲我們的服務端和客戶端都是在同一主機上運行的,所以同一個連接會顯示兩個,從端口可以看出來。
確實是只有一個連接,長連接成功了,但是性能並不樂觀。

自定義客戶端2

雖然通過自定義客戶端,我們限制了連接數,但是性能並不樂觀,這在高併發場景下也是不能接受的。
那麼我們進行二次自定義來調整性能。

客戶端代碼

func NewH1Client() *H1Client {
	return &H1Client{
		c: &http.Client{
			//Transport:http.DefaultTransport,
			Transport: &http.Transport{
				MaxConnsPerHost:10,
				MaxIdleConns:10,
				MaxIdleConnsPerHost:10,
				DisableKeepAlives:   false,
			},
		},
	}
}

以上代碼,我們將客戶端的連接數置爲10,看一下效果。

goos: windows
goarch: amd64
pkg: github.com/bemyth/go-language-advanced/httpx/client
BenchmarkPing_H1P1-4   	     100	 113806224 ns/op	  352494 B/op	    4911 allocs/op
PASS

還是 100次請求,每次耗時爲 100ms。效率提升了10倍。但是僅提高長連接數量並不能無限的提高效率,因爲客戶端併發數的限制。
所以長連接數量和併發量共同作用於效率,一般來說,當併發量增加,長連接數也應該隨之增加。

本機極限測試

經過我反覆調試,當併發量爲NumCPU * NumCPU * 30。長連接數量爲100時,每次操作耗時達到 14 ms。

goos: windows
goarch: amd64
pkg: github.com/bemyth/go-language-advanced/httpx/client
BenchmarkPing_H1P20-4   	     100	  14451606 ns/op	  370297 B/op	    5209 allocs/op
PASS

問題

  1. 默認的 transport 長連接爲什麼失效
  2. 三個參數各自代表什麼含義

由於第一個問題就是第二個問題的一個反映,所以我們來看一下,這三個參數各自有什麼意義。

長連接參數詳解

  • MaxConnsPerHost
  • MaxIdleConns
  • MaxIdleConnsPerHost

官方解釋

  • MaxConnsPerHost
	MaxConnsPerHost optionally limits the total number of
	connections per host, including connections in the dialing,
	active, and idle states. On limit violation, dials will block.
	
	Zero means no limit.
	
	For HTTP/2, this currently only controls the number of new
	connections being created at a time, instead of the total
	number. In practice, hosts using HTTP/2 only have about one
	idle connection, though.
MaxConnsPerHost 控制了每個客戶端中包含呼叫中,活動,空閒狀態的連接總數。超出這個限制的呼叫將會阻塞。
0 表示無限制。
對 HTTP/2 來說,這個參數當前僅控制同一時間能夠被創建的連接數,而不是總數。實際上, HTTP/2 也總是使用
一個空閒連接(長連接)。

我們當前使用的 HTTP/1.1 也就是說,這個鏈接控制了每個客戶端的最大連接數,一旦創建的連接等於這個最大連接數,就不會創建新連接了。
不會創建新的連接怎麼辦呢,那當然是等待了,等一個有緣人(idle空閒連接)。

  • MaxIdleConns
	// MaxIdleConns controls the maximum number of idle (keep-alive)
	// connections across all hosts. Zero means no limit.
  • MaxIdleConnsPerHost
	// MaxIdleConnsPerHost, if non-zero, controls the maximum idle
	// (keep-alive) connections to keep per-host. If zero,
	// DefaultMaxIdleConnsPerHost is used.

這兩個合在一起說,MaxIdleConns 是總的長連接數量。MaxIdleConnsPerHost 是每個 Host(ip:port) 長連接數量,並且如果這個參數是0的話,會默認置爲2。
看完了官方解釋,說實話還是不懂這三個參數是幹啥的,後兩個還好區分,屬於一類。那麼第一個和第二個都是控制鏈接數量的,這二者之間有什麼關係嗎。

源碼導讀

爲了理解這三個參數的作用,我們只能看一下源碼。

MaxIdleConns

我們先看一下 MaxIdleConns 參數,這個比較簡單,通過查找引用,我發現這個參數只使用了一次,並且這一次是在放回連接的時候使用的。

func (t *Transport) tryPutIdleConn(pconn *persistConn) error {
        ...
	
	if t.MaxIdleConns != 0 && t.idleLRU.len() > t.MaxIdleConns {
		oldest := t.idleLRU.removeOldest()
		oldest.close(errTooManyIdle)
		t.removeIdleConnLocked(oldest)
	}
	
	...
}

這個參數的作用很簡單,就是在將使用完的連接放回連接池的時候,如果設置了連接池允許的最大連接數量,
並且已存在的連接大於這個設定,則移除舊的連接,以便將當前較新的連接放回。

MaxConnsPerHost

同樣的對於 MaxIdleConnsPerHost, 我們查看引用,也只在放回的時候使用。

func (t *Transport) tryPutIdleConn(pconn *persistConn) error {
        ...
	if len(idles) >= t.maxIdleConnsPerHost() {
		return errTooManyIdleHost
	}
		
	...
}

這個使用也很簡單。如果空閒連接超過設定,就會返回錯誤,而返回錯誤之後,上層都會將這個鏈接關閉。
這個連接當然也不能放回連接池了。

MaxConnsPerHost

我們查看一下 MaxConnsPerHost 的引用

func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (*persistConn, error) {
	...
	if t.MaxConnsPerHost > 0 {
		select {
		case <-t.incHostConnCount(cmKey):
			// count below conn per host limit; proceed
		case pc := <-t.getIdleConnCh(cm):
			if trace != nil && trace.GotConn != nil {
				trace.GotConn(httptrace.GotConnInfo{Conn: pc.conn, Reused: pc.isReused()})
			}
			return pc, nil
		case <-req.Cancel:
			return nil, errRequestCanceledConn
		case <-req.Context().Done():
			return nil, req.Context().Err()
		case err := <-cancelc:
			if err == errRequestCanceled {
				err = errRequestCanceledConn
			}
			return nil, err
		}
	}
	...
}

果然,MaxConnsPerHost 是在申請連接的時候使用的,只有設置了 MaxConnsPerHost ,客戶端在申請連接時纔會從連接池中拿連接,
否則,每次都會新建連接。

參數結論

  • MaxConnsPerHost
    申請連接時使用,限制申請連接的最大數量,如果不設置,則每次申請連接都會新建連接。
  • MaxIdleConns
    放回連接時使用,限制連接池中長連接的數量,如果不設置,默認無上限。
  • MaxIdleConnsPerHost
    放回連接時使用,限制到每個服務(ip:port)的連接池中長連接的數量.如果不設置,默認爲 2。

結合連接池的知識,弄懂了這三個參數,就可以根據實際情況來調整長連接了。值得一提的是,
默認的 transport 沒有設置 MaxConnsPerHost, 導致每次請求都是一個新的連接,表現出來就是佔用了大量的 tcp 端口。
如果不設置 MaxConnsPerHost,每次使用的連接倒是放回了連接池,但是這些被放回的連接並不會被使用,長連接也就無效。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章