-
Notifications
You must be signed in to change notification settings - Fork 157
nbio设计与实现
-
- 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)]
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。
最初只是写着玩玩,但随着逐渐有人关注并提出需求,逐步完善了对 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 |
为了避免压测不同框架(包括标准库 net)期间机器本身的细微差异,我进行了多轮测试,得到的压测结果是 nbio 不输于其他框架(多轮测试过程中,多数是nbio性能最佳,偶尔其他某框架最佳)。因为也有看到其他同类项目的一些压测数据,但是我自己环境压测结论与某些项目给出的测试结果不符,这期中可能存在软硬件环境的差异,为了避免误导或者自吹嫌疑,我的压测仓库没有给出压测结论数据。 我的压测代码在这里:go-net-benchmark,有兴趣的可以在自己环境跑下测试、以自己得出的结果为准,当然也可以自己实现测试代码来进行测试。
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_1m,websocket_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 跳跃,运行不稳定 |
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)
}
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,更多例子请参考:
简介先到这里,后续的文章会做更多介绍,敬请关注。