Golang 優化之路——HTTP長連接

寫在前面

壓測的是否發現服務端TIME_WAIT狀態的連接很多。

netstat -nat | grep :8080 | grep TIME_WAIT | wc -l   
17731

TIME_WAIT狀態多,簡單的說就是服務端主動關閉了TCP連接。

IMG-THUMBNAIL

TCP頻繁的建立連接,會有一些問題:

  1. 三次握手建立連接、四次握手斷開連接都會對性能有損耗;
  2. 斷開的連接斷開不會立刻釋放,會等待2MSL的時間,據我觀察是1分鐘;
  3. 大量TIME_WAIT會占用內存,一個連接實測是3.155KB。而且占用太多,有可能會占滿端口,一臺服務器最多只能有6萬多個端口;

TCP 相關

長連接的概念包括TCP長連接和HTTP長連接。首先得保證TCP是長連接。我們就從它說起。

func (c *TCPConn) SetKeepAlive(keepalive bool) error

SetKeepAlive sets whether the operating system should send keepalive messages on the connection. 這個方法比較簡單,設置是否開啟長連接。

func (c *TCPConn) SetReadDeadline(t time.Time) error

SetReadDeadline sets the deadline for future Read calls and any currently-blocked Read call. A zero value for t means Read will not time out.這個函數就很講究了。我之前的理解是設置讀取超時時間,這個方法也有這個意思,但是還有別的內容。它設置的是讀取超時的絕對時間。

func (c *TCPConn) SetWriteDeadline(t time.Time) error

SetWriteDeadline sets the deadline for future Write calls and any currently-blocked Write call. Even if write times out, it may return n > 0, indicating that some of the data was successfully written. A zero value for t means Write will not time out. 這個方法是設置寫超時,同樣是絕對時間。

HTTP 包如何使用 TCP 長連接?

http 服務器啟動之后,會循環接受新請求,為每一個請求(連接)創建一個協程。

// net/http/server.go L1892
for {
    rw, e := l.Accept()
    go c.serve()
}

下面是每個協程的執行的代碼,我只摘錄了一部分關鍵的邏輯。可以發現,serve方法里面還有一個for循環。

// net/http/server.go L1320
func (c *conn) serve() {
    defer func() {
        if !c.hijacked() {
            c.close()
        }
    }()

    for {
        w, err := c.readRequest()
        
        if err != nil {
        }
        
        serverHandler{c.server}.ServeHTTP(w, w.req)
    }
}

這個循環是用來做什么的?其實也容易理解,如果是長連接,一個協程可以執行多次響應。如果只執行了一次,那就是短連接。長連接會在超時或者出錯后退出循環,也就是關閉長連接。defer函數可以讓協程結束之后關閉 TCP 連接。

readRequest函數用來解析 HTTP 協議。

// net/http/server.go
func (c *conn) readRequest() (w *response, err error) {
    if d := c.server.ReadTimeout; d != 0 {
        c.rwc.SetReadDeadline(time.Now().Add(d))
    }
    if d := c.server.WriteTimeout; d != 0 {
        defer func() {
            c.rwc.SetWriteDeadline(time.Now().Add(d))
        }()
    }
    
    if req, err = ReadRequest(c.buf.Reader); err != nil {
        if c.lr.N == 0 {
            return nil, errTooLarge
        }
        return nil, err
    }
}

func ReadRequest(b *bufio.Reader) (req *Request, err error) {
    // First line: GET /index.html HTTP/1.0
    var s string
    if s, err = tp.ReadLine(); err != nil {
        return nil, err
    }
    
    req.Method, req.RequestURI, req.Proto, ok = parseRequestLine(s)
    
    mimeHeader, err := tp.ReadMIMEHeader()
}

具體參與解析 HTTP 協議的部分是ReadRequest方法,而調用它之前,設置了讀寫超時時間。根據前面的描述,超時時間設置的是絕對時間。所以這里都是通過time.Now().Add(d)來設置的。不同的是寫超時是defer執行,也就是函數返回后才執行。

我們的程序為啥長連接失效?

通過源碼我們能大概知道程序流程了,按道理是支持長連接的。為啥我們的程序不行呢?

我們的程序使用的是 beego 框架,它支持的超時是同時設置讀寫超時。而我們的設置是1秒。

beego.HttpServerTimeOut = 1

我對讀寫超時的理解,讀超時是收到數據到讀取完畢的時間;寫超時是從一開始寫到寫完的時間。我對這兩個超時的理解都不對。

實際上,從上面的源碼可以發現,寫超時是讀取完畢之后設置的超時時間。也就是讀取完畢之后的時間,加上邏輯執行時間,加上內容返回時間的總和。按照我們的設置,超過1秒就算超時。

下面詳細說說讀超時。ReadRequest是堵塞執行的,如果沒有用戶請求,它會一直等待著。而讀超時是ReadRequest之前設置的,它除了讀取數據之外,還有一部分耗時,那就是等待時間。假如一直沒有用戶請求,此時讀超時已經被設置成1秒后了,超過1秒之后,這個連接還是會被斷開。

如何解決問題?

原因已經說明白了。大量TIME_WAIT是超時引起的,有可能是等待時間過長引起的讀超時;也有可能是程序在壓測情況下出現一部分執行超時,這樣會導致寫超時。

我們目前使用的是 beego 框架,它并不支持單獨設置讀寫超時,所以我目前的解決方式是將讀寫超時調整得大一些。

從1.6版本開始,Golang 能夠支持空閑超時IdleTimeout,可以認為讀超時就是讀取數據的時間,空閑超時來控制等待時間。但是它有一個問題,如果空閑超時沒有設置,而讀超時設置了,那么讀超時還是會作為空閑超時時間來使用。我估計這么做的原因是為了向前兼容。再一個問題就是 beego 并不支持這個時間的設置,所以我目前也沒有別的太好的方法來控制超時時間。

后續

其實服務端最合理的超時控制需要這幾個方面:

  1. 讀超時。就是單純的讀超時,不要包括等待時間,否則無法區分超時是讀數據引起的還是等待引起的。

  2. 寫超時。最好也是單純的寫數據超時。如果網絡良好,因為邏輯執行慢就把連接斷開,這樣也不是很合適。讀寫超時都應該和目前邏輯設置的一樣,設置得短一些。

  3. 空閑超時。這個可以根據實際情況配置,可以適當大一些。

  4. 邏輯超時。一般情況下是不會發生網絡層面的讀寫超時的,壓測情況下超時大部分都是由于邏輯超時引起的。Golang 原生包支持了TimeoutHandler。它可以控制邏輯的超時。可惜 beego 目前不支持設置邏輯超時。而我也沒有想到太好的方法把 beego 中接入它。

     func TimeoutHandler(h Handler, dt time.Duration, msg string) Handler
    

參考文獻

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容