Skip to content

nbio设计与实现

_ksco edited this page Mar 27, 2023 · 9 revisions

目录/Content

  • 1. 简介

    • 1.1 项目背景
    • 1.2 功能特性
    • 1.3 性能
      • [1.3.1 TCP(Layer4)性能](#131-tcplayer4 性能)
      • [1.3.2 HTTP/Websocket(Layer7)性能](#132-httpwebsocketLayer7 性能)
    • 1.4 兼容性与易用性
      • [1.4.1 TCP(Layer4):实现net.Conn,易扩展](#141-tcplayer4 实现 netConn 易扩展)
      • [1.4.2 HTTP1.x(Layer7):基本兼容标准库](#142-http1xlayer7 基本兼容标准库)
    • 1.5 结语
  • [2. 实现篇(todo)]

    • [2.1 实现篇-Poller(todo)]
    • [2.2 实现篇-异步流解析器(TLS/HTTP1.x/Websocket)(todo)]
    • [2.3 实现篇-内存优化(todo)]
    • [2.4 实现篇-并发、时序与协程池(todo)]
  • [3. 实践篇(todo)]

    • [3.1 定制 Listener(todo)]
    • [3.2 实现一个自定义 TCP 协议的编解码(todo)]
    • [3.3 使用 Epoll ET(todo)]
    • [3.4 4 层与 7 层 keepalive 的不同(todo)]
    • [3.5 限制单个 Conn 的吞吐量(todo)]
    • [3.6 同一个 Engine 管理不同协议的客户端与服务端(todo)]
  • [4. 进阶篇(todo)]

    • [4.1 与其他框架对比,与 gorilla/websocket、melody 对比(todo)]
    • [4.2 与标准库方案共用——实现一个可分流的 listener(todo)]
    • [4.3 与 ARPC 打通,支持海量并发 RPC 与多种业务类型(todo)]
    • [4.4 golang 高并发模型与可控的协程池(todo)]

1. 简介

1.1 项目背景

Golang 指令速度虽然赶不上 c/cpp/rust,但作为编译型语言,指令性能整体上不输其他带有 runtime 的语言,比起脚本语言则指令和多核利用率的整体性能优势更加明显。 并且对于大多数场景,Golang 能允许我们使用同步逻辑的开发模式,让开发者可以从大多数业务场景的 callback hell 中解放出来,极大地降低了心智负担。

但在海量并发的场景,例如单进程百万连接,同步模式的方案需要为每个连接使用一个甚至多个 goroutine,这带来了巨大开销。 例如,Golang 不同版本的初始协程栈 2/4/8k 不等,百万连接基础内存占用就需要至少 2/4/8G,有的框架比如 websocket,每个连接需要 2 个协程(甚至 3 个),则基础内存占用需要 4/8/16G,OOM 风险增加。协程数量巨大,对应的调度成本、加上对象数量和 GC 消耗也是巨大的,STW会更明显、CPU 尖刺明显甚至持续飙高。 这种情况下,Golang 相比与其他语言的异步框架在性能消耗比上显得非常劣势,同步模式带来的优势已经不足以弥补高消耗带来的成本。

针对海量并发场景的优化,社区已经诞生了一批封装了 epoll/kqueue 的知名的框架,如 evio、easygo、gev、gnet,还有一些 websocket 百万连接相关的文章与框架如:1m、gobwas/ws,可能还有一些其他同类项目,这里不再一一列举。

感谢所有先行者们的探索与尝试,最初我便是想在这些已有的框架中选型并使用,但是学习一番后,发现目前这些框架还不够便利,比如对 TLS/HTTP/Websocket 的支持欠缺(一些框架自带了 HTTP 测试,但并未实现完整的 HTTP 功能,只是简单解析和响应固定数据,无法用于实际项目),没有实现 net.Conn 也不方便基础功能实现和扩展。

无聊之余,我自己开始实现一个新的 poller 框架:nbio

1.2 功能特性

最初只是写着玩玩,但随着逐渐有人关注并提出需求,逐步完善了对 TLS/HTTP1.x/Websocket 的支持,除了 epoll/kqueue,Windows 下也基于 net.Conn 封装实现了框架接口的跨平台支持、方便 Windows 用户开发调试。 这里有 nbio 跟其他框架的一些对比:

Features/Framework evio easygo gev gnet nbio
Implements net.Conn X X X X
Windows X X X X
Concurrent Write/Close X X X X
TLS X X X X
HTTP/HTTPS 1.x X X X X
Websocket X X √ Simple Implementation X Pass The Autobahn Test

1.3 性能

1.3.1 TCP(Layer4)性能

为了避免压测不同框架(包括标准库 net)期间机器本身的细微差异,我进行了多轮测试,得到的压测结果是 nbio 不输于其他框架(多轮测试过程中,多数是nbio性能最佳,偶尔其他某框架最佳)。因为也有看到其他同类项目的一些压测数据,但是我自己环境压测结论与某些项目给出的测试结果不符,这期中可能存在软硬件环境的差异,为了避免误导或者自吹嫌疑,我的压测仓库没有给出压测结论数据。 我的压测代码在这里:go-net-benchmark,有兴趣的可以在自己环境跑下测试、以自己得出的结果为准,当然也可以自己实现测试代码来进行测试。

1.3.2 HTTP/Websocket(Layer7)性能

nbio 的 poller io 部分是异步的,业务层可以使用数量合理的逻辑协程池,从而使用同步代码进行开发,nbio 默认使用这种搭配。由于这种搭配需要io协程与逻辑协程之间传递数据,对象和buffer的协程亲和性、生命周期和内存的池优化会更复杂,并且消息的异步流解析逻辑相对于基于标准库 net.Conn 的方案(如 net/http、gin、fasthttp)更复杂一些,在线量不是特别大的时候,nbio 的响应时间表现可能不如基于标准库 net.Conn 的方案,但海量连接数时,由于节省了大量的协程数量,相应的内存、调度、gc 等成本比基于标准库 net.Conn 的方案低得多,服务稳定性、响应性能更好。

对于非 io 消耗类业务,可以改变配置,不使用逻辑协程池,而是由 io 协程读到数据后自行处理逻辑来提高性能。

nbio 的 TLS/HTTP/Websocket 使用 buffer pool 优化,内存等开销主要是跟一定 qps/tps 下同时处理的请求所需的内存相关;而基于标准库 net.Conn 的方案由于每个 Conn 一个协程循环读取、buffer 和对象方便复用(未超出协程栈则只需要考虑协程栈大小),所以更主要是跟连接数相关。 这里有一些海量并发的测试代码:websocket_1mwebsocket_tls_1m

这里列举一些不同连接数的一些简单压测数据对比,如需更加完善的测试数据统计、对比,请自行测试:

env:

ubuntu@ubuntu:~/nbio-examples$ cat /etc/issue
Ubuntu 20.04 LTS \n \l
ubuntu@ubuntu:~/nbio-examples$ go version
go version go1.18 linux/amd64
ubuntu@ubuntu:~/nbio-examples$  cat /proc/cpuinfo | grep name | cut -f2 -d: | uniq -c
      8  AMD Ryzen 7 5800H with Radeon Graphics
ubuntu@ubuntu:~/nbio-examples$ free -h
              total        used        free      shared  buff/cache   available
Mem:          7.7Gi       868Mi       6.4Gi       0.0Ki       501Mi       6.6Gi
Swap:         4.0Gi       288Mi       3.7Gi

连接数不算太高时,nbio 没有优势:

websocket-10k cpu mem qps
nbio 300-350% 75M 160k
net-gorilla 300-350% 414M 250k
websocket-tls-10k cpu mem qps
nbio 250-300% 210M 140k
net-gorilla 300-350% 640M 180k

连接数超过临界时(需根据实际业务所需消耗判定临界值),nbio优势明显:

websocket-200k cpu mem qps
nbio 350-400% 580M 135k,运行稳定
net-gorilla 100-200% 3.5G 1-50k跳跃,运行不稳定
websocket-tls-100k cpu mem qps
nbio 300-350% 1.3G 130k,运行稳定
net-gorilla 150-250% 3.3G 1-80k 跳跃,运行不稳定

1.4 兼容性与易用性

1.4.1 TCP(Layer4):实现 net.Conn,易扩展

nbio.Conn 实现了 net.Conn,SetDeadline 等常用方法都支持,也支持并发 Write/Close,很方便业务封装和扩展。实际上,nbio 对 TLS/HTTP1.x/Websocket 的支持,都是以实现了 net.Conn 为基础的。

net.Conn 也可以转换为 nbio.Conn,使用用户自实现的 listener 或 dialer 得到的 net.Conn,可以用 nbio.Engine/Gopher 来管理,例如:

// server.go
g := nbio.NewGopher(nbio.Config{})
g.OnOpen(func(c *nbio.Conn) {
    log.Println("OnOpen:", c.RemoteAddr().String())
})

g.OnData(func(c *nbio.Conn, data []byte) {
    c.Write(append([]byte{}, data...))
})

err := g.Start()
if err != nil {
    log.Fatal(err)
    return
}
defer g.Stop()

time.AfterFunc(time.Second, func(){
    c, err := net.Dial("tcp", addr)
	if err != nil {
		log.Fatal(err)
	}
	g.AddConn(c)
})

ln, err := net.Listen("tcp", "localhost:8888")
if err != nil {
    log.Fatal(err)
}
for {
    conn, err := ln.Accept()
    if err != nil {
        log.Fatal(err)
        continue
    }
    g.AddConn(conn)
}

1.4.2 HTTP1.x(Layer7):基本兼容标准库

nbio 实现的 HTTP1.x 并且基本兼容标准库,很多基于标准库的框架可以改用 nbio 作为底层来支持海量并发业务而不需要改变原来的代码,例如:

gin-gonic/gin with nbio

router := gin.New()
router.GET("/hello", func(c *gin.Context) {
	c.String(http.StatusOK, "hello world")
})

engine := nbhttp.NewEngine(nbhttp.Config{
	Network: "tcp",
	Addrs:   []string{":8080"},
	Handler: router,
})
err := engine.Start()
if err != nil {
	log.Fatal(err)
}

labstack/echo with nbio

e := echo.New()
e.GET("/hello", func(c echo.Context) error {
	return c.String(http.StatusOK, "hello world")
})

engine := nbhttp.NewEngine(nbhttp.Config{
	Network: "tcp",
	Addrs:   []string{":8080"},
	Handler: e,
})
err := engine.Start()
if err != nil {
	log.Fatal(err)
}

涉及到 Websocket 时,由于基于标准库 net.Conn 的 Websocket 框架都是同步模式、需要至少一个协程循环读取,所以无法做到兼容,只能使用 nbio 的 Websocket,更多例子请参考:

1.5 结语

简介先到这里,后续的文章会做更多介绍,敬请关注。