NBIO 第二彈 —— 支援 Non-Blocking HTTP 1.x

lesismal發表於2021-03-15

一、簡介

最近兩週擼了份 HTTP 1.x 的 Parser ,用於支援非同步網路庫的資料解析(同步網路庫當然也可以使用),在此基礎之上實現了 NBIO HTTP Server ,其他非同步網路庫也可以使用這個 Parser 進行 HTTP Server 的封裝,但需依賴其他網路庫實現 net.Conn。

眾所周知,標準庫的 HTTP 為每個連線建立一個協程,在高併發場景下比如 10k、100k 甚至 1000k,需要建立大量的協程,消耗大量的記憶體、協程排程等成本。但是使用非同步網路庫,可以不用為每個連線都建立單獨的協程,從而降低相應的消耗、極大提高同等硬體的負載能力。

NBIO HTTP Server 相容標準庫的 http.Handler ,所以已有的基於標準庫的 web 框架也可以很容易地使用 NBIO HTTP Server 作為非同步網路層來替換標準庫。 如果需要對 fasthttp 這類不使用標準庫的 web 框架進行支援,也只需參考預設相容標準庫的 Processor,實現一份對應 fasthttp Hadler 的 Processor 即可。但由於 fasthttp 預設使用 [] byte 作為原始資料欄位的儲存,而 Parser 兼顧應用層便利在引數傳遞中直接轉換成了 string ,所以需要浪費一點不必要的 string/[] byte 轉換,也可以考慮是否需要把引數傳遞改成 [] byte,但改成 [] byte 看上去就不那麼友好、美觀了。

NBIO HTTP Server 網路層介面在 *nix 系統上是非同步的,處理流程是:

  1. NBIO 作為網路層處理資料 IO 。
  2. 讀取到的資料回撥應用層方法執行 Parser 進行解析,這裡給應用層留了引數,應用層可以自己定製執行的回撥函式,比如可以就在 NBIO 讀取資料的協程中進行解析,也可以自己定製協程池進行解析(但要注意,同一個連線的資料應該指定到同一個協程中進行解析,否則由於 TCP 的 Stream 特性,可能導致 "粘包" 相關的資料錯亂)。為了使用者便利,如果應用層傳入 nil 引數,NBIO HTTP Server 則提供預設的協程池進行解析。
  3. Parser 解析到一個完整訊息後呼叫業務層回撥進行處理,這裡與 Parser 類似,可由應用層傳入處理函式,如果傳入 nil 引數,則由預設的協程池進行處理,這裡的協程池與 Parser 的協程池不同,因為已經是完整的訊息,可以由協程池內空閒協程而非指定協程搶任務執行,以避免單個連線某個方法處理中可能存在 DB 等慢操作導致其他連線的訊息處理被阻塞。
  • 關於 3 中協程池,NBIO HTTP Server 支援亂序處理、順序回包。如果請求方的客戶端實現支援單個連線的多個訊息非線頭阻塞傳送、而不用等待每個訊息收到回覆才發出下個請求的資料,則該連線的多個請求有可能在 NBIO HTTP Server 預設協程池中亂序執行,比如 request 1 需要 1 秒進行處理,request 2 也到達並且只需要 10ms 進行處理,則 request 2 先被處理完,但是 request 2 回覆的資料會被快取,仍然等 request 1 處理完成後先回復 request 1、再回復 request 2,不會導致客戶端收到的響應亂序。

二、兩點澄清

  1. 以前有小夥伴提出,golang 底層也是非同步、我這種重複再造輪子也是非同步、沒有意義——這種說法是不正確的:golang 底層也是非同步,但是語言層面或者標準庫 net 的介面層是同步的,所以才需要每個連線一個協程,而 NBIO 介面層也是非同步的,所以可以自行定製管理、避免不必要的協程建立,兩者的非同步是不一樣的。
  2. 還有的小夥伴提出,golang 的同步模式是巨大的進步,我這個庫又回到非同步模式,是倒退——這種說法也是不準確的:底層基礎設施的非同步,並不代表應用層也一定要非同步,golang 的協程和 chan 足夠方便,應用層完全可以自己定製多種程式設計模式。NBIO HTTP Server 在上面簡介流程 3 中的訊息處理,應用層的 http.Handler 內,和使用標準庫的方式是沒有變化的,業務層仍然是按照同步的方式進行順序邏輯的處理。

三、示例程式碼

NBIO HTTP Server 的示例請參考這裡:https://github.com/lesismal/nbio/tree/master/examples/http

這裡也包括了一份百萬連線的測試樣例:百萬連線測試程式碼 ,由於網路協議棧的 PORT 使用 short 型別導致的 65535 限制,為了免去單機壓測部署環境的麻煩,百萬連線測試的示例程式碼開啟監聽了多組埠,因為這些埠接受連線和處理 IO 都是共用相同的一組 poller ,單一埠也是使用這組 poller,所以多埠跟單一埠的效能是基本一致的,有興趣的小夥伴也可以改成單一埠、自行搭建虛擬網路或者多組 docker、真實多機環境、壓測客戶端之類的進行壓測 PS:NBIO 主要針對 *nix 系統,在 windows 下為了方便使用者除錯,使用標準庫的 net 實現了介面相容,windows 下的壓測資料不用來作為效能對比的參考,壓測請於 linux 環境下進行。

四、路線圖

  1. Websocket
  2. HTTP2.0
  3. 前陣子有魔改了一份標準庫的 TLS 支援非同步並與 NBIO 打通,但是標準庫的 TLS 原來是同步模式的程式碼、魔改成支援非同步的很多細節我沒有優化、顯得臃腫浪費,希望以後有檔期完全重寫一份更清爽的
  • 每一項都是體力活,感覺路漫漫,也希望有興趣的大佬、小夥伴多來交流、PR

五、以 gin 為例,分別使用 STD、NBIO 進行壓測對比

  • 壓測環境: 4c8t / 8g 虛擬機器,C/S localhost

1. gin 預設使用標準庫壓測

1)gin std server 程式碼

package main

import (
        "fmt"
        "net/http"
        "runtime"
        "sync/atomic"
        "time"

        "github.com/gin-gonic/gin"
)

func main() {
        var (
                qps   uint64 = 0
                total uint64 = 0
        )

        router := gin.New()
        router.GET("/hello", func(c *gin.Context) {
                atomic.AddUint64(&qps, 1)
                c.String(http.StatusOK, "hello")
        })

        go router.Run()

        ticker := time.NewTicker(time.Second)
        for i := 1; true; i++ {
                <-ticker.C
                n := atomic.SwapUint64(&qps, 0)
                total += n
                fmt.Printf("running for %v seconds, NumGoroutine: %v, qps: %v, total: %v\n", i, runtime.NumGoroutine(), n, total)
        }
}

2)wrk 壓測 20k 連線數

wrk -t4 -c20000 -d30s --latency http://localhost:8080/hello

3)壓測結果日誌

所有連線建立成功直到 qps 穩定的 server 日誌:

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /hello                    --> main.main.func1 (1 handlers)
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080
running for 1 seconds, NumGoroutine: 2, qps: 0, total: 0
running for 2 seconds, NumGoroutine: 2, qps: 0, total: 0
running for 3 seconds, NumGoroutine: 5277, qps: 0, total: 0
running for 4 seconds, NumGoroutine: 9411, qps: 0, total: 0
running for 5 seconds, NumGoroutine: 11404, qps: 0, total: 0
running for 6 seconds, NumGoroutine: 15696, qps: 95115, total: 95115
running for 7 seconds, NumGoroutine: 16653, qps: 74368, total: 169483
running for 8 seconds, NumGoroutine: 19188, qps: 72357, total: 241840
running for 9 seconds, NumGoroutine: 19942, qps: 68762, total: 310602
running for 10 seconds, NumGoroutine: 19936, qps: 86198, total: 396800
running for 11 seconds, NumGoroutine: 20008, qps: 114406, total: 511206
running for 12 seconds, NumGoroutine: 20015, qps: 137557, total: 648763
running for 13 seconds, NumGoroutine: 20003, qps: 135883, total: 784646
running for 14 seconds, NumGoroutine: 20009, qps: 130973, total: 915619
running for 15 seconds, NumGoroutine: 20011, qps: 130860, total: 1046479

wrk 測試結果日誌:

Running 30s test @ http://localhost:8080/hello
  4 threads and 20000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   145.59ms   79.06ms   1.36s    88.79%
    Req/Sec    32.62k    10.49k   73.27k    79.31%
  Latency Distribution
     50%  131.01ms
     75%  151.73ms
     90%  186.63ms
     99%  542.54ms
  3391563 requests in 30.09s, 391.37MB read
Requests/sec: 112705.44
Transfer/sec:     13.01MB

2. 使用 NBIO HTTP Server 作為 gin 的網路層壓測

1)gin nbio server 程式碼

package main

import (
    "fmt"
    "net/http"
    "runtime"
    "sync/atomic"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/lesismal/nbio/nbhttp"
)

func main() {
    var (
        qps   uint64 = 0
        total uint64 = 0
    )

    router := gin.New()
    router.GET("/hello", func(c *gin.Context) {
        atomic.AddUint64(&qps, 1)
        c.String(http.StatusOK, "hello")
    })

    svr := nbhttp.NewServer(nbhttp.Config{
        Network:      "tcp",
        Addrs:        []string{"localhost:8080"},
        NPoller:      8, // runtime.NumCPU(),
        NParser:      8, // runtime.NumCPU(),
        TaskPoolSize: 100, // runtime.NumCPU() * 10, // goroutines pool to execute http.Handler
    }, router, nil, nil)

    err := svr.Start()
    if err != nil {
        fmt.Printf("nbio.Start failed: %v\n", err)
        return
    }
    defer svr.Stop()

    ticker := time.NewTicker(time.Second)
    for i := 1; true; i++ {
        <-ticker.C
        n := atomic.SwapUint64(&qps, 0)
        total += n
        fmt.Printf("running for %v seconds, online: %v, NumGoroutine: %v, qps: %v, total: %v\n", i, svr.State().Online, runtime.NumGoroutine(), n, total)
    }
}

2)wrk 壓測 20k 連線數

wrk -t4 -c20000 -d30s --latency http://localhost:8080/hello

3)壓測結果

所有連線建立成功直到 qps 穩定的 server 日誌:

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /hello                    --> main.main.func1 (1 handlers)
2021/03/13 14:06:03.797 [INF] Gopher[NB] start listen on: ["localhost:8080"]
running for 1 seconds, online: 0, NumGoroutine: 19, qps: 0, total: 0
running for 2 seconds, online: 0, NumGoroutine: 19, qps: 0, total: 0
running for 3 seconds, online: 0, NumGoroutine: 19, qps: 0, total: 0
running for 4 seconds, online: 4068, NumGoroutine: 19, qps: 0, total: 0
running for 5 seconds, online: 9061, NumGoroutine: 19, qps: 0, total: 0
running for 6 seconds, online: 12567, NumGoroutine: 119, qps: 3598, total: 3598
running for 7 seconds, online: 18018, NumGoroutine: 119, qps: 126743, total: 130341
running for 8 seconds, online: 19916, NumGoroutine: 119, qps: 153748, total: 284089
running for 9 seconds, online: 19916, NumGoroutine: 119, qps: 152665, total: 436754
running for 10 seconds, online: 19916, NumGoroutine: 119, qps: 156468, total: 593222
running for 11 seconds, online: 20000, NumGoroutine: 119, qps: 146699, total: 739921
running for 12 seconds, online: 20000, NumGoroutine: 119, qps: 145776, total: 885697
running for 13 seconds, online: 20000, NumGoroutine: 119, qps: 155327, total: 1041024
running for 14 seconds, online: 20000, NumGoroutine: 119, qps: 148740, total: 1189764
running for 15 seconds, online: 20000, NumGoroutine: 119, qps: 143539, total: 1333303

wrk 測試結果日誌:

Running 30s test @ http://localhost:8080/hello
  4 threads and 20000 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   129.22ms   26.45ms 609.89ms   74.38%
    Req/Sec    38.08k     3.69k   57.58k    72.97%
  Latency Distribution
     50%  128.42ms
     75%  144.86ms
     90%  160.37ms
     99%  191.20ms
  4146017 requests in 30.06s, 478.43MB read

資料對比

指標 GIN+STD GIN+NBIO
壓測連線數 20000 20000
峰值程式協程數量 20000+ 119
峰值記憶體佔用 600+M 60+M
峰值 CPU 佔用 500-600% 400-500%
wrk Latency Avg 145.59ms 129.22ms
wrk Latency Stdev 79.06ms 26.45ms
wrk Latency Max 1.36s 609.89ms
wrk Latency 50% 131.01ms 128.42ms
wrk Latency 75% 151.73ms 144.86ms
wrk Latency 90% 186.63ms 160.37ms
wrk Latency 99% 542.54ms 191.20ms
wrk Req/Sec Avg 32.62k 38.08k
wrk Req/Sec Stdev 10.49k 3.69k
wrk Req/Sec Max 73.27k 57.58k

GIN+NBIO 方式整體壓測指標好於 GIN+STD,相比之下,極低的記憶體佔用尤為明顯,NBIO 可以使同配置或者低配硬體的負載能力大幅提升。

多數小夥伴們的業務可能不需要極致的資源控制、通常加機器就行,但面對海量併發場景、大規模叢集時,非同步網路框架可以極大降低相應的硬體成本。

現在的雲、大資料、人工智慧、物聯網、5G 時代已經蓬勃發展,但這一切只是開始,IT 爆炸的時代,很多傳統領域都在 IT 化,未來的資料量、計算量、網路傳輸量更會越來越迅猛地增長,海量計算的基礎之上,一點算力的節約會在放大效應下變得非常明顯。

以物聯網為例,海量接入裝置、海量併發連線數之下,golang 標準庫的每個連線一個協程的預設同步模式可能會成為效能瓶頸,需要更多的硬體開銷、能源消耗。超高併發場景下,以 golang 標準庫方案的效能、資源消耗、負載能力,目前趕不上 java netty、nodejs,更不用說 c/c++/rust,所以個人認為 golang 的非同步基礎設施很有必要,還有很大發展空間。

歡迎有興趣的小夥伴關注、進行更多測試,以及 issue、pr、star,^_^

更多原創文章乾貨分享,請關注公眾號
  • NBIO 第二彈 —— 支援 Non-Blocking HTTP 1.x
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章