深入了解GWS (Go WebSocket Server & Client)

790 阅读3分钟

我是 gws 作者「闰土的猹」, 今天向大家介绍下 gws 的使用和设计. gws 是一个高性能低开销的 WebSocket 框架, 提供IO多路复用, 广播, 代理等诸多特性, 为您的实时通信业务提供强大助力.

快速开始

gorilla/websocket 在用户层代码循环读取消息不同, gws 封装了这一过程, 提供 WebSocket Event API, 内部做好了连接生命周期管理以及错误处理, 所有 gws.Conn 导出的方法里面错误都是可忽略的.

package main

import (
	"github.com/lxzan/gws"
)

func main() {
	gws.NewServer(new(Handler), nil).Run(":6666")
}

type Handler struct{}

func (c *Handler) OnOpen(socket *gws.Conn) {}

func (c *Handler) OnClose(socket *gws.Conn, err error) {}

func (c *Handler) OnPong(socket *gws.Conn, payload []byte) {}

func (c *Handler) OnPing(socket *gws.Conn, payload []byte) {}

func (c *Handler) OnMessage(socket *gws.Conn, message *gws.Message) {}

主要特性

  • 并发安全
  • 代理拨号
  • IO多路复用
  • 广播
并发安全

我们不假设用户会使用 chan 写消息, 提供的 Write 系列方法支持并发调用.

代理

使用代理拨号是非常简单的, 只需要加一个 NewDialer 配置覆盖默认的直接拨号, 支持 wss.

package main

import (
	"crypto/tls"
	"github.com/lxzan/gws"
	"golang.org/x/net/proxy"
	"log"
)

func main() {
	socket, _, err := gws.NewClient(new(gws.BuiltinEventHandler), &gws.ClientOption{
		Addr:      "wss://example.com/connect",
		TlsConfig: &tls.Config{InsecureSkipVerify: true},
		NewDialer: func() (gws.Dialer, error) {
			return proxy.SOCKS5("tcp", "127.0.0.1:1080", nil, nil)
		},
	})
	if err != nil {
		log.Println(err.Error())
		return
	}
	socket.ReadLoop()
}
IO多路复用

在易用性和可维护性方面, IO多路复用远胜于连接池. gws 提供了任务队列, 在单个连接上串行读取消息, 并行处理消息.

// 是否开启异步读, 开启的话会并行调用OnMessage
// Whether to enable asynchronous reading, if enabled OnMessage will be called in parallel
ReadAsyncEnabled bool

// 异步读的最大并行协程数量
// Maximum number of parallel concurrent processes for asynchronous reads
ReadAsyncGoLimit int

内置读/写两个任务队列, 并行度分布是 8 (可修改)和 1 (不可修改). 无线缓存的任务队列是本仓库最巧妙的一个数据结构, 它基于有锁队列实现, 不会像 chan 一样产生常驻的 goroutine, 非常的轻量, 即使每个连接上带上俩也不会增加太多内存开销.

type (
	workerQueue struct {
		mu             sync.Mutex // 锁
		q              []asyncJob // 任务队列
		maxConcurrency int32      // 最大并发
		curConcurrency int32      // 当前并发
	}

	asyncJob func()
)

// newWorkerQueue 创建一个任务队列
func newWorkerQueue(maxConcurrency int32) *workerQueue {
	c := &workerQueue{
		mu:             sync.Mutex{},
		maxConcurrency: maxConcurrency,
		curConcurrency: 0,
	}
	return c
}

// 获取一个任务
func (c *workerQueue) getJob(delta int32) asyncJob {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.curConcurrency += delta
	if c.curConcurrency >= c.maxConcurrency {
		return nil
	}
	if len(c.q) == 0 {
		return nil
	}
	var result = c.q[0]
	c.q = c.q[1:]
	c.curConcurrency++
	return result
}

// 循环执行任务
func (c *workerQueue) do(job asyncJob) {
	for job != nil {
		job()
		job = c.getJob(-1)
	}
}

// Push 追加任务, 有资源空闲的话会立即执行
func (c *workerQueue) Push(job asyncJob) {
	c.mu.Lock()
	c.q = append(c.q, job)
	c.mu.Unlock()
	if job := c.getJob(0); job != nil {
		go c.do(job)
	}
}
广播

广播方案也是基于任务队列实现, 比 chan 方案要简单易用许多. 相比常规广播方案 , Broadcaster 只会压缩一次消息, 节省大量内存和CPU开销.

func Broadcast(conns []*gws.Conn, opcode gws.Opcode, payload []byte) {
	var b = gws.NewBroadcaster(opcode, payload)
	defer b.Release()
	for _, item := range conns {
		_ = b.Broadcast(item)
	}
}

可以看到, 压缩消耗十分巨大, CPU开销是不开启压缩的几十倍

goos: darwin
goarch: arm64
pkg: github.com/lxzan/gws
BenchmarkConn_WriteMessage/compress_disabled-8         	 7280246	       137.7 ns/op	       0 B/op	       0 allocs/op
BenchmarkConn_WriteMessage/compress_enabled-8          	  144850	      8012 ns/op	     234 B/op	       0 allocs/op
BenchmarkConn_ReadMessage/compress_disabled-8          	 5141690	       236.2 ns/op	     120 B/op	       3 allocs/op
BenchmarkConn_ReadMessage/compress_enabled-8           	  220413	      5009 ns/op	     290 B/op	       4 allocs/op
PASS
ok  	github.com/lxzan/gws	9.695s

性能压测

性能方面, 在众多基于标准网络库的 WebSocket 实现里面, gws 属于第一梯队, 做一个简单的小测试, 结果如下:

  • GOMAXPROCS = 4
  • Connection = 1000
  • Compress Disabled

image.png

压测工具: wsbench

gorilla, nhooyr未使用流式API