Golang Go语言修改websocket server启动方式 内存占用立省40%

最近发布了 gws v1.4.7 更新, 支持从 tcp conn 直接解析 websocket 协议, 降低内存占用. 大部分人使用 go websocket server, 都是复用的 http server, 这种劫持http连接升级的方式, 最大的弊端就是浪费内存. 由于 go http hijack 的缺陷, 一些内存一直得不到释放, 大概每个连接 10KB. 测试 10000 个连接的场景, 换用 Demo2 方式, 内存占用立省 42.86%!

Demo 1: hijack

package main

import ( “github.com/lxzan/gws” “log” “net/http” )

func main() { upgrader := gws.NewUpgrader(new(gws.BuiltinEventHandler), nil)

http.HandleFunc("/connect", func(writer http.ResponseWriter, request *http.Request) {
	socket, err := upgrader.Accept(writer, request)
	if err != nil {
		log.Printf(err.Error())
		return
	}
	go socket.Listen()
})

if err := http.ListenAndServe(":3000", nil); err != nil {
	log.Fatalf(err.Error())
}

}

Demo 2: direct

package main

import ( “github.com/lxzan/gws” “log” )

func main() { srv := gws.NewServer(new(gws.BuiltinEventHandler), nil)

if err := srv.Run(":3001"); err != nil {
	log.Panicln(err.Error())
}

}

memory


Golang Go语言修改websocket server启动方式 内存占用立省40%

更多关于Golang Go语言修改websocket server启动方式 内存占用立省40%的实战教程也可以访问 https://www.itying.com/category-94-b0.html

39 回复

第一种方式不用可以和 http 同时使用、复用 http 的路由、不影响已经存在的 http api ,同时支持多条 ws ,不用另开端口。

更多关于Golang Go语言修改websocket server启动方式 内存占用立省40%的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


喔,不对,上面说的有问题

缺点就是太费内存, 看官方什么时候修复吧

这种 over TCP 的不能用于浏览器相关的领域,对于绝大多数 Websocket 用户是与 Web 浏览器的前段交互,所以绝大多数用户都不能用这种直接 over TCP 的 Websocket ,所以这样对比其实本身就没什么意义。

之前也有人在我这问基于 TCP 的,开放了一些字段,现在随便哪里的数据传递给 Websocket 解析器就可以了,基于 TCP/Unix/QUIC/KCP 或者随便什么协议都可以:
https://github.com/lesismal/nbio/issues/240#issuecomment-1304804444

如果是游戏、App 之类的用 TCP 通常也都是自家封装的协议、更简单更定制化。

内存占用的问题,标准库 HTTP 这块确实是,conn 上面挂载的读、写 buffer ,Hijack 的时候又新建了个写 buffer 传给用户,我看 OP 代码里是把新建的这个写 buffer=nil 释放了,写的时候是用 net.Buffers ,但这样不一定是最佳:
1. 标准卡库 Hijack 时创建的写 Buffer 虽然一瞬间又销毁了、但毕竟不是 c free ,不能立即释放。但正常业务 Upgrade 并不是超级频繁的动作,所以影响也不算大
2. net.Buffers 调用 TCPConn 这种,最后是 syscall.Writev ,以前做测试用 net.Buffers 性能比应用层自己拼接 buffer 然后 Write 要稍微差一点点。syscall.Writev 的内核 c 实现也是创建大 buffer 拷贝上去然后在 write ,但是用 syscall.Writev 可能消耗更多的内核资源和时间、而内核是整个系统共用资源时间比较宝贵且竞争,所以可能反倒不如让应用层来做这种划算,所以或许直接复用 Hijack 传过来的那个 Writer 也是相当于应用层的 Writev ,性能也还好。但是我很久没再做这个 syscall.Writev 与应用层自己 Writev 的对比了,不知道现在新版本如何。我自己的库一些实现是避免使用 net.Buffers 的,但是不管怎么用,性能差别应该不大

标准库 HTTP 的解析有些地方也是比较浪费的,重复拷贝、循环之类的,之前想去 pr 一波来着,但是标准库的实现太复杂了、功能也多、并发流也多,有的地方是一个连接可能多个协程处理的。pr 流程也很麻烦,要想 pr 成功太耗费精力了,所以放弃了
OP 可以试下去把标准库 Hijack()这块的代码优化下,Hijack 之后就把 conn 自己身上挂载的读写 buffer=nil ,说不定还有一些其他可以清理的

另外,upgrader.Accept 和 socket.Listen 这两个命名实在是有点难受,Accept 和 Listen 都与实际的行为意义不匹配,OP 啥时候改成和大家一致的比如也叫 upgrader.Upgrade ,另一个叫 ReadLoop()之类的,老接口可以保留,提供个新的命名就行。。。

这两个命名跟我想到一块去了👍🏻

还有一点,在 handler 里其实可以不用新开协程的,socket.Listen() 就可以了、不要 go ,这样就复用了 http server 原来的那个协程、避免了一次不必要的协程重建和一些变量逃逸。比如前面说到的 http conn 挂载的读写 buffer ,因为是默认 4k ,1.18 还是哪个版本之后来着、协程栈好像默认是 8k ,所以一般而言,复用了原来的协程,则挂载的这个读 buffer 至少不太涉及跨协程的逃逸,所以应该是能使用原来这个协程的栈空间,除非你要设置得很大 size

单就主帖内容,TCP 这个可能会误导一些人、以为可以替换了原来的方案了,所以特地来回复一下,顺便又 review 了几眼

#7 我只是肉眼分析,实际差别应该不会特别大。可以实测对比下玩玩看,需要排除掉多次启动基础内存占用可能不同的一些差别、比如多跑几轮

能用于浏览器的,你试试. 实测 net.Buffer 稍快点,而且能省掉 bufio.Writer 的内存

我跑了好几次,结果都差不多的,截图里面跑了五六分钟

demo 里面加 go 是因为我想让请求上下文被 gc 掉, 结果还是有副作用

还以为 net.Buffer 能减少一次拷贝呢, 没想到底层还是会拷贝. net/http 里的东西改不来, 理顺逻辑要费不少功夫, 已经给官方提 issue 了: https://github.com/golang/go/issues/59567

WebSocket over TCP 是我在 github 写的 title , 没想到好的命名 😂

> 能用于浏览器的,你试试.

刚看了下代码,这。。。

我之前说不能用于浏览器是因为你这句 “支持从 tcp conn 直接解析 websocket 协议” ,以为你是和我发的那个类似、直接 tcp 上的数据解析 websocket ,但其实不是,你这个仍然是先解析 http request 然后 upgrade ,只是没用标准库的 http 、而是自己实现了解析 http request 这步。
所以准确说,是使用自实现的 http request 解析进行 ws upgrade 。
但这个解析不够完备、没有非法字符之类的协议规范的判断,我不确定会不会有一些风险。

> 实测 net.Buffer 稍快点,而且能省掉 bufio.Writer 的内存

可能你对比的是 bufio.Writer ,我之前对比的是 buffer append

> net.Buffer 能减少一次拷贝呢, 没想到底层还是会拷贝

这个可以看下代码,跟进去,TCPConn 这种就是调用的 syscall.Writev 了。
内核 c 语言实现的 writev 也看下就知道了

> demo 里面加 go 是因为我想让请求上下文被 gc 掉, 结果还是有副作用

单就 upgrade 这个 request 而言,可能上下文的代价比新 go 更大一点,但实际应该也差不了太多,runtime 复用协程也是挺给力而且不是高频行为,剩下的主要是逃逸

我做了个简单的 parser ,做了大 header 的防范,设置了 Deadline ,不知道还有没有其他风险,改天找个正经 http parser 看下

我又测了下 net.Buffers vs user buffer append:
https://gist.github.com/lesismal/a40c420511252aa79b054cbd2acc896e

我的机器上还是 user buffer append 性能略好,而且内存更优,你可以自己环境跑下看看不同环境是否有差异

我回家之后测一下看看

user buffer append 确实更优些,gws 1000 connections iops 峰值从 1200 提高到了 1400

#19 我又测了下 bufio.Writer ,跟 buffer append 性能差不多,但 bufio.Writer 是在线连接长期占用这个,buffer append 的方式是当前有写才会占用下、可以复用 pool ,而且并发写有 mutex 的、一个 conn 同时最多也就占一个,总数量<= conn num ,而且核心数没那么多、并发多数时候没有那么多协程达到并行,所以实际应该是小于 conn num ,所以 buffer append 可能更好点

#19 就是因为这些各种原因,很多地方我手撸 buffer ,好累:joy: 。。。

抠细节心累

bytes.Buffer 应该比手撸的更快

> bytes.Buffer 应该比手撸的更快

并不是。首先它一样的是挂载在 conn 上需要持续占用大段 buffer ,其次它源码你看下就知道了,还是那些个 buffer 的操作、只是封装些常用方法方便用户罢了。buffer 操作这种事情并没有什么性能提升的秘诀,就是谁的逻辑代码消耗更少、拷贝更少之类的

我记得即使把 bytes.Buffer 源码复制出来,IO 速度也会变慢

得实测看看,正常情况狂不应该有这个问题。差异不大可能是测试稳定性的问题可以忽略。如果差异很大,代码发我瞧瞧

明天再看看吧

现在改成用户态拼接 buffer 的方式了, bytes.Buffer 没有 Discard 方法, 压缩那块写得有点丑

浏览器的 ws ,默认都是先发个 http 头,然后服务器 Upgrade 吗?

request header 和 http/1.1 是一模一样的

忽然发现 next 就相当于 Discard :)

跟是不是浏览器应该没关系,而是 ws 协议就是这么规定的,go 的 client 也是要发这个握手的 request 的。

> 但毕竟不是 c free ,不能立即释放

#4 更正,c free 也不一定是立即归还的、而且多数时候不是立即归还,要看分配器实际情况了

> 单就 upgrade 这个 request 而言,可能上下文的代价比新 go 更大一点

#15 更正,单就 upgrade 这个 request 而言,可能相比于上下文的代价、新 go 代价更大一点

> 现在改成用户态拼接 buffer 的方式了, bytes.Buffer 没有 Discard 方法, 压缩那块写得有点丑
> 忽然发现 next 就相当于 Discard :)

主要是你 write frame 的场景,就一 head+body 拼接,太简单了,pool+append 足矣

copy 比 append 更快些

不开新 goroutine 好点, 反正 http 包里面很多东西 gc 不了.

> copy 比 append 更快些

恩。
前提是得比较明确 size cap 这些,很多地方是 buffer size 可能不够,即使 copy 也是得先 append 扩容。
预先知道 size 并且分配了足够 size 的 buffer ,我也是 copy:
https://github.com/lesismal/nbio/blob/master/nbhttp/websocket/conn.go#L263

> 不开新 goroutine 好点, 反正 http 包里面很多东西 gc 不了.

是。标准库让人又爱又恨的

在Go语言中优化WebSocket服务器的启动方式以减少内存占用是一个值得探讨的话题。以下是一些建议,帮助你通过修改启动方式来显著节省内存(可达40%或更多):

  1. 延迟初始化:避免在服务器启动时立即初始化所有资源。使用按需加载或懒加载策略,只在必要时分配内存。

  2. 连接池管理:优化WebSocket连接池的管理。设置合理的连接超时和最大连接数,及时关闭空闲连接,避免内存泄漏。

  3. 使用goroutine和channel:Go的goroutine和channel机制非常高效,但也需要谨慎使用。避免创建过多的goroutine,可以通过复用goroutine或使用带缓冲的channel来减少内存开销。

  4. 优化数据结构:选择适合业务场景的数据结构,如使用slice代替map在特定情况下可以节省内存。同时,注意避免不必要的拷贝,尽量使用指针传递。

  5. 内存分析工具:使用Go的内存分析工具(如pprof)来检测内存使用情况,找出内存占用高的代码段并进行优化。

  6. 编译优化:通过编译选项如-ldflags "-s -w"来减少可执行文件的大小,虽然这不会直接影响运行时内存占用,但有助于整体优化。

通过上述方法,结合具体的业务场景和需求,你可以有效地修改WebSocket服务器的启动方式,从而实现内存占用的显著降低。在实际操作中,建议逐步实施优化策略,并持续监控内存使用情况,以确保优化效果。

回到顶部