V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
lesismal
V2EX  ›  分享创造

NBIO 第二弹 —— 支持 Non-Blocking HTTP 1.x

  •  
  •   lesismal · 264 天前 · 1022 次点击
    这是一个创建于 264 天前的主题,其中的信息可能已经有所发展或是发生改变。

    一、简介

    最近两周撸了份 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,^_^

    12 条回复    2021-03-22 00:37:53 +08:00
    abersheeran
        1
    abersheeran  
       260 天前
    如上所述的性能差异说到底就是有栈协程和无栈协程的性能差异。

    我个人选有栈协程,也就是 golang 这一套。因为写代码方便,有栈协程无感染性。虽然我一直沉浸在 Python 的无栈协程里跑不出来。

    无栈协程的性能缺陷:文件 IO 需要走多线程通过回调包装成异步的,如果细扣到有栈无栈这个地步,那么这个包装其实还是很浪费资源的。而有栈协程不需要这么包装,在有栈协程里调任何同步 IO 都没事,都可以挂起。
    缺陷之二:感染性。如上述,文件 IO 之类的同步 IO 必须包装起来,但是传统编程里很多文件 IO 是程序员自己都不不注意的,比如 file.exits()、path.is_dir() 之类的。如果要把无栈协程的性能发挥到极致,这些必须都包装起来。但如果只是简单粗暴的多线程跑,线程切换其实比这种少量 IO 还要耗资源。这里就很难优化好。

    老哥你这个帖子没啥人回复,估计是因为学 golang 的人跟我想法差不多,都更喜欢有栈协程,所以对你这个兴趣不大。
    abersheeran
        2
    abersheeran  
       260 天前
    @abersheeran 缺陷之二再补充一下说明:无栈协程需要程序员自己时刻牢记自己每一步操作是否会带来同步 IO 操作。上述只是拿了文件举例,实际上应该有更多的操作是同步的,都需要程序员自己去权衡线程池如何安排。
    lesismal
        3
    lesismal  
    OP
       260 天前
    @abersheeran 其实类似兄弟你喜欢有栈协程的问题,我在主帖 “两点澄清”部分有解释,只是大部分人 Get 不到这些点,心酸,:joy:

    除了 golang 、erlang,其他那些手动档的协程其实都是垃圾,并没有减轻代码逻辑复杂度的人脑解析负担,甚至比回调更让人恶心。。。
    lesismal
        4
    lesismal  
    OP
       260 天前
    @lesismal 业务层的程序员小伙伴们完全可以继续用同步逻辑写代码,单就 http server 来讲,应用层是不受影响的

    后续要做的 Upgrader 之类的,也是框架胶水层 wrap 一下,应用层的逻辑其实都可以不受影响。

    BTW,websocket 实现得差不多了,争取下周放出来
    FucUrFrd
        5
    FucUrFrd  
       259 天前
    louzhu 你好,
    1) NIUBILITY IO 太大陆了, 跟 github 无国界冲突
    2) louzhu 用一维数组存储 fd, 要不 map(hash), 更好是 RB tree
    FucUrFrd
        6
    FucUrFrd  
       259 天前
    lesismal
        7
    lesismal  
    OP
       259 天前
    @FucUrFrd
    1. NIUBILITY 即使太大陆了,也跟 github 无国界不冲突吧?难道 github 无国界所以就不能有大陆相关的了?你的逻辑思维有些乱,建议加强训练一下
    2. 这个地方数组比 map/rbt 好,如果看不懂说明基础有点弱,多读好书补补
    lesismal
        8
    lesismal  
    OP
       259 天前
    @FucUrFrd 如果跟太大陆了就跟 github 无国界冲突,那好多用英语的,太欧美了,其他非英语国家地区的人怎么办?这么讲的话,github 不能用地球国家的语言了
    lesismal
        9
    lesismal  
    OP
       259 天前
    既然无国界,就不要上纲上线的
    FucUrFrd
        10
    FucUrFrd  
       259 天前   ❤️ 1
    已 block, 牛逼
    lesismal
        11
    lesismal  
    OP
       259 天前
    @FucUrFrd *nix 进城内文件描述符的特点、产生与回收再利用可以看一下,数组对比下 map/rbt 的时间复杂度以及除了时间复杂度,那个复杂度的每次操作本身是包括哪些操作,都可以看下。
    abersheeran
        12
    abersheeran  
       257 天前 via Android
    @FucUrFrd 什么脑残玩意。见到这种嘴上说着无国界,却让人改用其他自然语言的人,我见一个骂一个。你最好也 block 我。
    关于   ·   帮助文档   ·   API   ·   FAQ   ·   我们的愿景   ·   广告投放   ·   感谢   ·   实用小工具   ·   2280 人在线   最高记录 5497   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 22ms · UTC 12:29 · PVG 20:29 · LAX 04:29 · JFK 07:29
    ♥ Do have faith in what you're doing.