简约而不简单:WorkQueue的轻量级高效之道

我是 LEE,老李,一个在 IT 行业摸爬滚打 17 年的技术老兵。

事件背景

独立 WorkQueue 这个事情我犹豫了很久,一直没有真正的行动起来,直到有一天公司整个开发环境升级了,client-go 版本到了 1.28,而之前的我一直使用的 client-go 1.26,WorkQueue 包被迫升级了。你说这有什么关系呢?其实这个关系很大,因为 1.26 包含的的 WorkQueue 包基本停止更新快 1 年了,新版的 WorkQueue 多少有一些使用上的变化,而且我这边更多逻辑都是基于之前 WorkQueue 的版本来实现的,所以升级之后,我发现之前的项目基本都需要重弄一次。

之前一直希望有一个像 kubernetes 社区中 WorkQueue 项目出现,不要依赖其他第三方库。但是一直没有找到,所以我就想自己写一个。但是这次升级 client-go 版本之后,我发现我必须要做这个事情了,因为我不想重复的去修改之前的代码,而且我也想做一个开源项目,所以就有了这个项目。

接下来我会花一些时间向大家介绍一下这个项目设计思路。 主要会从分几期文章来介绍:

  • 聚焦项目的整体设计思路、目标和架构设计。
  • 针对 Queue 和 Simple Queue 模块的实现、使用示例和代码分析。
  • 介绍 Delaying Queue 和 Priority Queue 模块,包括使用实例和代码讲解。
  • 深入 RateLimiting Queue 模块的实现、使用案例和代码解析。

项目简介

WorkQueue github.com/shengyanli1...

WorkQueue 实际是一个 kubernetes 社区中 client-go 项目中 workqueue 包的复刻品,但是我并没有直接将 client-go 中的代码拿过来,而是重新实现了一遍。主要是因为 client-go 中的代码实现比较复杂,而且依赖了自身的第三方库,分离度不高。我希望能够做一个更加轻量级和干净的项目,有类似 client-go 项目中 workqueue 包的功能,但是不依赖其他第三方库,而且代码实现上也要更加简单易懂。

架构设计

整体代码结构上也仿造了 kubernetes 社区中 client-go 项目中 workqueue 包的设计结构,采用分层设计。

基础 Queue 作为最底层的数据队列,提供了基本的队列操作,包括 Add, Get, Len, Stop 等接口。 Delaying QueuePriority QueueQueue 派生,分别实现了延迟队列和优先级队列的功能。

RateLimiting QueueDelaying Queue 派生,实现了一个速率限制队列,主要是为了控制队列的速率。

Simple Queue 功能类似 Queue,也可以作为最底层的数据队列,Simple QueueQueue 的区别在于 Simple Queue 不会对存储在队列中的数据进行去重,而 Queue 会对存储在队列中的数据进行去重。所以 Simple Queue 的性能会比 Queue 要高一些。

结构图如下:

接口设计

参考了 kubernetes 社区中 client-go 项目中 workqueue 包中接口设计,结合我日常工作中使用习惯,同时考虑到未来的通用性,少许做了一些修改。

方法接口

用于操作队列内的元素,所有的队列都需要实现这些方法接口。特殊的队列,比如 Delaying QueuePriority Queue,除了需要实现这些方法接口之外,还需要实现其他接口,后续文章将跟大家分享。

go 复制代码
// 队列方法接口
// Queue interface
type Interface interface {
	Add(element any) error  // 添加元素到队列
	Len() int // 队列长度
	Get() (element any, err error) // 获取队列元素
	GetWithBlock() (element any, err error) // 阻塞获取队列元素
	Done(element any) // 标记完成队列元素
	Stop() // 停止队列
	IsClosed() bool // 队列是否关闭
}

回调接口

用于队列中元素在不同操作阶段,需要额外触发的回调函数。比如元素添加到队列中时候,需要触发添加回调函数;元素从队列中获取出来时候,需要触发取回回调函数;元素从队列中标记完成时候,需要触发完成回调函数。

go 复制代码
// 队列的回调接口
// Callback interface
type Callback interface {
	OnAdd(any) // 添加元素到队列时候的回调函数
	OnGet(any) // 获取元素时候的回调函数
	OnDone(any) // 标记完成元素时候的回调函数
}

性能比对

既然要做 kubernetes 社区中 client-go 项目中 workqueue 包的平替,那么当然就要看在实际情况下的性能表现了。所以我写了一个简单的 demo 程序,来对 client-goWorkQueue 的差别。

这次参赛选手如下:

  • client-go: v1.26.12
  • WorkQueue: v0.1.3

Go 版本go1.20.11 darwin/amd64

bash 复制代码
$ go test -benchmem -run=^$ -bench ^Benchmark* github.com/shengyanli1982/workqueue/bennchmark
goos: darwin
goarch: amd64
pkg: github.com/shengyanli1982/workqueue/bennchmark
cpu: Intel(R) Xeon(R) CPU E5-4627 v2 @ 3.30GHz
BenchmarkClientgoAdd-8          	 2363436	       466.3 ns/op	     171 B/op	       1 allocs/op
BenchmarkClientgoGet-8          	 4062987	       332.9 ns/op	       7 B/op	       0 allocs/op
BenchmarkClientgoAddAndGet-8    	 2547846	       468.0 ns/op	      53 B/op	       1 allocs/op
BenchmarkWorkqueueAdd-8         	 1600765	       670.0 ns/op	      95 B/op	       2 allocs/op
BenchmarkWorkqueueGet-8         	 3805994	       334.0 ns/op	      25 B/op	       0 allocs/op
BenchmarkWorkqueueAddAndGet-8   	 1633266	       793.1 ns/op	      83 B/op	       1 allocs/op
PASS
ok  	github.com/shengyanli1982/workqueue/bennchmark	17.377s

眼光犀利的你一定发现了,WorkQueue 的性能比 client-go 的性能要差一些,这是为什么呢?主要是因为 WorkQueue 底层数据存储使用 DoubleLinkedList 实现,而 client-go 使用的是 slice 实现,所以 WorkQueue 的性能会比 client-go 的性能要差一些,实际已经通过优化将差距缩小到了几乎差不多,可以做到跟 client-go 一样的性能。

当然在代码开发之初也使用过 channelslice,最后通过大量的复杂场景测试,发现 DoubleLinkedList 的综合能力要比 channelslice 要好一些,所以最终选择了 DoubleLinkedList 作为底层数据存储。 下面的章节就会对这部分的内容做一个详细的解析。

STL 解析

如果写过 c++ 的小伙伴一定不陌生,STLStandard Template Library 的缩写,中文名叫做标准模板库,是 c++ 标准模板库的一部分,是 c++ 标准模板库的核心,包括容器、算法和迭代器。STL 的设计思想是将数据结构和算法分离,使得算法独立于具体的数据结构,从而能够实现算法的复用。

当然 Go 语言中也有 STL,只不过 Go 语言中的 STL 只包含了部分的数据结构和算法,比如 container/listcontainer/heapcontainer/ringcontainer/stackcontainer/vector 等。Go 语言中的 STL 也是将数据结构和算法分离,使得算法独立于具体的数据结构,从而能够实现算法的复用。

类型对比

由于 WorkQueue 底层数据存储需要使用 STL 中的部分算法,为了将性能最大化,不得不手动实现了 STLDoubleLinkedListQuadrupleHeap

这里主要讲解下当初选用 DoubleLinkedList 作为底层数据存储的原因。

DoubleLinkedList (双向链表)

基于双向链表的队列复杂度

动作 平均复杂度 最坏复杂度
Push O(1) O(1)
Pop O(1) O(1)

优点 基于双向链表的队列 Push/Pop 操作为恒定时间,即复杂度为 O(1)。这是因为双向链表的 Push/Pop 操作只需要修改链最外部元素的指针,不需要移动任何元素。

缺点 每个元素都需要开辟额外空间存储指向上一个、下一个结点的指针,且每次创建元素时,都需要分配内存,这会导致内存碎片化。

注意因素 适用于长时间运行的程序,比如服务端程序,因为长时间运行的程序,内存的利用率比较重要,所以需要考虑内存的利用率。同时长时间运行的程序,内存碎片化的问题也比较严重,所以需要考虑内存碎片化的问题。

Slice (数组切片)

基于数组的队列复杂度

动作 平均复杂度 最坏复杂度
Push O(1) O(n)
Pop O(1) O(n)

优点 基于数组的队列 Pop 操作为恒定时间,即复杂度为 O(1) 。这是因为数组的 Pop 操作只需要返回第一个元素,不需要移动任何元素。 但是 Push 操作的复杂度也是 O(1) ,因为每次 Push 操作都需要在 slice 的末尾添加元素,不需要移动任何元素。 但是如果在第一个元素前插入元素,就需要移动所有元素,复杂度为 O(n)

缺点 数组填满时需扩容,扩容后占用很多空闲空间。扩容时需要重新分配内存,将数组原来元素复制过来,每次扩容时容量都会翻倍。即便扩容发生的频率并不高,但是随着时间推移,它占用空间可能越来越大。

注意因素 需要考虑内存在长期使用下,扩容导致内存的浪费,以及大量对象需要复制和迁移导致的性能问题。

对比总结

SliceDoubleLinkedList 在做 FIFO 的 Queue 的时候,Push/Pop 的复杂度都是 O(1) ,但是 Slice 在 数组填满时需扩容,扩容后占用很多空闲空间。扩容时需要重新分配内存,将数组原来元素复制过来,每次扩容时容量都会翻倍。而 DoubleLinkedList 且每次创建元素时,都需要分配内存,这会导致内存碎片化。

如何优化

最终 WorkQueue 项目采用了 DoubleLinkedList 作为底层数据存储,主要是考虑到长时间运行的程序,内存的利用率比较重要,所以需要考虑内存的利用率。同时长时间运行的程序,内存碎片化的问题也比较严重,所以需要考虑内存碎片化的问题。

那么如何解决 DoubleLinkedList 的内存碎片化问题呢?主要是通过 sync.Pool 来解决的,sync.PoolGo 语言提供的一个对象池,可以用来存储临时对象,以减少内存分配,降低 GC 压力。sync.Pool 会对对象的生命周期进行管理,不再使用的对象会被自动移除,而不会造成内存泄漏。

所以在 DoubleLinkedList 代码实现时候,我将 DoubleLinkedList 的元素对象放入到 sync.Pool 中,这样就可以解决 DoubleLinkedList 的内存碎片化问题。

结构图如下:

参考代码:

github.com/shengyanli1...

go 复制代码
type ListNodePool struct {
	bp sync.Pool // 同步池 (sync pool)
}

func NewListNodePool() *ListNodePool {
	return &ListNodePool{
		bp: sync.Pool{
			New: func() any {
				return NewNode(nil)
			},
		},
	}
}

func (p *ListNodePool) Get() *Node {
	return p.bp.Get().(*Node) // 从池中获取 ListNode 对象 (get ListNode object from the pool)
}

func (p *ListNodePool) Put(b *Node) {
	if b != nil {
		b.Reset()
		p.bp.Put(b) // 将 ListNode 对象放回池中 (put ListNode object back into the pool)
	}
}

github.com/shengyanli1...

go 复制代码
// 添加元素到队列
// Add element to queue
func (q *Q) Add(element any) error {
	...
	n := q.nodepool.Get()
	n.SetData(element)
	...
}

// 从队列中获取一个元素, 如果队列为空,不阻塞等待
// Get an element from the queue.
func (q *Q) Get() (element any, err error) {
	...
	q.nodepool.Put(n)
	...
}

使用 Add 添加元素到队列时候,会从 sync.Pool 中获取 ListNode 对象,当使用 Get 获取元素时候,会将 ListNode 对象放回到 sync.Pool 中,当 AddGet 速度达到平衡的时候,sync.Pool 中的 ListNode 对象数量会保持在一个比较稳定的状态,不会出现内存碎片化的问题。

最后

感谢你能看到这里,本文章是 WorkQueue 项目的第一篇文章,主要是对项目的整体设计思路、目标和架构设计做了一个简单的介绍。

对于 WorkQueue 项目中的 STL 模块选择和对比做了一个详细的介绍,主要是为了让大家了解为什么会选择 DoubleLinkedList 作为底层数据存储,而不是 Slice。同时也介绍了如何通过 sync.Pool 来解决 DoubleLinkedList 的内存碎片化问题。

后续文章将会对项目中的 QueueSimple Queue 模块的实现、使用示例和代码分析做一个详细的介绍。

相关推荐
码农派大星。16 分钟前
Spring Boot 配置文件
java·spring boot·后端
杜杜的man1 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*1 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu1 小时前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s1 小时前
Golang--协程和管道
开发语言·后端·golang
为什么这亚子1 小时前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算
想进大厂的小王1 小时前
项目架构介绍以及Spring cloud、redis、mq 等组件的基本认识
redis·分布式·后端·spring cloud·微服务·架构
customer082 小时前
【开源免费】基于SpringBoot+Vue.JS医院管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·开源·intellij-idea
2402_857589362 小时前
SpringBoot框架:作业管理技术新解
java·spring boot·后端
一只爱打拳的程序猿2 小时前
【Spring】更加简单的将对象存入Spring中并使用
java·后端·spring