V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
Nazz
V2EX  ›  Go 编程语言

修改 go websocket server 启动方式, 内存占用立省 40% !

  •  1
     
  •   Nazz · 2023-04-27 14:03:20 +08:00 · 2085 次点击
    这是一个创建于 575 天前的主题,其中的信息可能已经有所发展或是发生改变。

    最近发布了 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

    38 条回复
    lysS
        1
    lysS  
       2023-04-27 16:17:03 +08:00
    第一种方式不用可以和 http 同时使用、复用 http 的路由、不影响已经存在的 http api ,同时支持多条 ws ,不用另开端口。
    lysS
        2
    lysS  
       2023-04-27 16:18:33 +08:00
    喔,不对,上面说的有问题
    Nazz
        3
    Nazz  
    OP
       2023-04-27 16:21:33 +08:00
    @lysS 缺点就是太费内存, 看官方什么时候修复吧
    lesismal
        4
    lesismal  
       2023-04-27 18:06:48 +08:00   ❤️ 2
    这种 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 ,说不定还有一些其他可以清理的
    lesismal
        5
    lesismal  
       2023-04-27 18:10:55 +08:00   ❤️ 1
    另外,upgrader.Accept 和 socket.Listen 这两个命名实在是有点难受,Accept 和 Listen 都与实际的行为意义不匹配,OP 啥时候改成和大家一致的比如也叫 upgrader.Upgrade ,另一个叫 ReadLoop()之类的,老接口可以保留,提供个新的命名就行。。。
    Nazz
        6
    Nazz  
    OP
       2023-04-27 18:18:12 +08:00 via Android
    @lesismal 这两个命名跟我想到一块去了👍🏻
    lesismal
        7
    lesismal  
       2023-04-27 18:18:19 +08:00
    还有一点,在 handler 里其实可以不用新开协程的,socket.Listen() 就可以了、不要 go ,这样就复用了 http server 原来的那个协程、避免了一次不必要的协程重建和一些变量逃逸。比如前面说到的 http conn 挂载的读写 buffer ,因为是默认 4k ,1.18 还是哪个版本之后来着、协程栈好像默认是 8k ,所以一般而言,复用了原来的协程,则挂载的这个读 buffer 至少不太涉及跨协程的逃逸,所以应该是能使用原来这个协程的栈空间,除非你要设置得很大 size
    lesismal
        8
    lesismal  
       2023-04-27 18:20:30 +08:00
    单就主帖内容,TCP 这个可能会误导一些人、以为可以替换了原来的方案了,所以特地来回复一下,顺便又 review 了几眼
    lesismal
        9
    lesismal  
       2023-04-27 18:27:39 +08:00
    @lesismal #7 我只是肉眼分析,实际差别应该不会特别大。可以实测对比下玩玩看,需要排除掉多次启动基础内存占用可能不同的一些差别、比如多跑几轮
    Nazz
        10
    Nazz  
    OP
       2023-04-27 18:36:01 +08:00 via Android
    @lesismal 能用于浏览器的,你试试. 实测 net.Buffer 稍快点,而且能省掉 bufio.Writer 的内存
    Nazz
        11
    Nazz  
    OP
       2023-04-27 18:38:21 +08:00 via Android
    @lesismal 我跑了好几次,结果都差不多的,截图里面跑了五六分钟
    Nazz
        12
    Nazz  
    OP
       2023-04-27 18:45:10 +08:00
    @lesismal demo 里面加 go 是因为我想让请求上下文被 gc 掉, 结果还是有副作用
    Nazz
        13
    Nazz  
    OP
       2023-04-27 18:51:44 +08:00
    @lesismal 还以为 net.Buffer 能减少一次拷贝呢, 没想到底层还是会拷贝. net/http 里的东西改不来, 理顺逻辑要费不少功夫, 已经给官方提 issue 了: https://github.com/golang/go/issues/59567
    Nazz
        14
    Nazz  
    OP
       2023-04-27 19:05:57 +08:00
    @lesismal WebSocket over TCP 是我在 github 写的 title , 没想到好的命名 😂
    lesismal
        15
    lesismal  
       2023-04-27 19:14:09 +08:00   ❤️ 2
    > 能用于浏览器的,你试试.

    刚看了下代码,这。。。

    我之前说不能用于浏览器是因为你这句 "支持从 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 复用协程也是挺给力而且不是高频行为,剩下的主要是逃逸
    Nazz
        16
    Nazz  
    OP
       2023-04-27 19:38:42 +08:00 via Android
    我做了个简单的 parser ,做了大 header 的防范,设置了 Deadline ,不知道还有没有其他风险,改天找个正经 http parser 看下
    lesismal
        17
    lesismal  
       2023-04-27 19:46:04 +08:00
    我又测了下 net.Buffers vs user buffer append:
    https://gist.github.com/lesismal/a40c420511252aa79b054cbd2acc896e

    我的机器上还是 user buffer append 性能略好,而且内存更优,你可以自己环境跑下看看不同环境是否有差异
    Nazz
        18
    Nazz  
    OP
       2023-04-27 19:54:39 +08:00 via Android
    @lesismal 我回家之后测一下看看
    Nazz
        19
    Nazz  
    OP
       2023-04-27 20:47:46 +08:00 via Android
    @lesismal user buffer append 确实更优些,gws 1000 connections iops 峰值从 1200 提高到了 1400
    lesismal
        20
    lesismal  
       2023-04-27 21:12:02 +08:00
    @Nazz #19 我又测了下 bufio.Writer ,跟 buffer append 性能差不多,但 bufio.Writer 是在线连接长期占用这个,buffer append 的方式是当前有写才会占用下、可以复用 pool ,而且并发写有 mutex 的、一个 conn 同时最多也就占一个,总数量<= conn num ,而且核心数没那么多、并发多数时候没有那么多协程达到并行,所以实际应该是小于 conn num ,所以 buffer append 可能更好点
    lesismal
        21
    lesismal  
       2023-04-27 21:12:46 +08:00
    @Nazz #19 就是因为这些各种原因,很多地方我手撸 buffer ,好累:joy: 。。。
    Nazz
        22
    Nazz  
    OP
       2023-04-27 21:26:01 +08:00 via Android
    @lesismal 抠细节心累
    Nazz
        23
    Nazz  
    OP
       2023-04-27 21:27:11 +08:00 via Android
    @lesismal bytes.Buffer 应该比手撸的更快
    lesismal
        24
    lesismal  
       2023-04-27 22:08:40 +08:00
    > bytes.Buffer 应该比手撸的更快

    并不是。首先它一样的是挂载在 conn 上需要持续占用大段 buffer ,其次它源码你看下就知道了,还是那些个 buffer 的操作、只是封装些常用方法方便用户罢了。buffer 操作这种事情并没有什么性能提升的秘诀,就是谁的逻辑代码消耗更少、拷贝更少之类的
    Nazz
        25
    Nazz  
    OP
       2023-04-27 22:24:10 +08:00 via Android
    @lesismal 我记得即使把 bytes.Buffer 源码复制出来,IO 速度也会变慢
    lesismal
        26
    lesismal  
       2023-04-27 22:38:11 +08:00
    @Nazz 得实测看看,正常情况狂不应该有这个问题。差异不大可能是测试稳定性的问题可以忽略。如果差异很大,代码发我瞧瞧
    Nazz
        27
    Nazz  
    OP
       2023-04-27 22:52:06 +08:00 via Android
    @lesismal 明天再看看吧
    Nazz
        28
    Nazz  
    OP
       2023-04-28 10:26:54 +08:00
    @lesismal 现在改成用户态拼接 buffer 的方式了, bytes.Buffer 没有 Discard 方法, 压缩那块写得有点丑
    lysS
        29
    lysS  
       2023-04-28 10:29:29 +08:00
    @lesismal 浏览器的 ws ,默认都是先发个 http 头,然后服务器 Upgrade 吗?
    Nazz
        30
    Nazz  
    OP
       2023-04-28 10:31:39 +08:00
    @lysS request header 和 http/1.1 是一模一样的
    Nazz
        31
    Nazz  
    OP
       2023-04-28 13:39:56 +08:00
    @Nazz 忽然发现 next 就相当于 Discard :)
    lesismal
        32
    lesismal  
       2023-04-28 14:56:56 +08:00
    @lysS 跟是不是浏览器应该没关系,而是 ws 协议就是这么规定的,go 的 client 也是要发这个握手的 request 的。
    lesismal
        33
    lesismal  
       2023-04-28 15:00:45 +08:00
    > 但毕竟不是 c free ,不能立即释放

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

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

    #15 更正,单就 upgrade 这个 request 而言,可能相比于上下文的代价、新 go 代价更大一点
    lesismal
        34
    lesismal  
       2023-04-28 15:05:10 +08:00
    > 现在改成用户态拼接 buffer 的方式了, bytes.Buffer 没有 Discard 方法, 压缩那块写得有点丑
    > 忽然发现 next 就相当于 Discard :)

    @Nazz 主要是你 write frame 的场景,就一 head+body 拼接,太简单了,pool+append 足矣
    Nazz
        35
    Nazz  
    OP
       2023-04-28 16:00:12 +08:00
    @lesismal copy 比 append 更快些
    Nazz
        36
    Nazz  
    OP
       2023-04-28 16:12:48 +08:00
    @lesismal 不开新 goroutine 好点, 反正 http 包里面很多东西 gc 不了.
    lesismal
        37
    lesismal  
       2023-04-28 18:24:08 +08:00
    > copy 比 append 更快些

    恩。
    前提是得比较明确 size cap 这些,很多地方是 buffer size 可能不够,即使 copy 也是得先 append 扩容。
    预先知道 size 并且分配了足够 size 的 buffer ,我也是 copy:
    https://github.com/lesismal/nbio/blob/master/nbhttp/websocket/conn.go#L263
    lesismal
        38
    lesismal  
       2023-04-28 18:25:37 +08:00
    > 不开新 goroutine 好点, 反正 http 包里面很多东西 gc 不了.

    是。标准库让人又爱又恨的
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2091 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 16:13 · PVG 00:13 · LAX 08:13 · JFK 11:13
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.