Go:实现基于mutex的环形缓冲区

3 阅读13分钟

最近在学Go,由于想要实践一下,所以实现一个基于mutex的环形缓冲区。

什么是环形缓冲区

环形缓冲区是一种固定容量的数据结构,它在逻辑上使一段线性内存头尾相连,形成一个“环”。

环形缓冲区的组成一般是一个固定大小的数组,一个写指针tail,和一个读指针head
写入时,tail向前移动;读取时,head向前移动。
当某个指针到达结尾时,通过取模运算,让它回到数组起点,模拟“循环”的效果,从而实现对这段内存的反复利用。

环形缓冲区的优势在于,队列容量在初始化时一次性分配,后续的每次入队/出队操作都不会触发任何内存分配,没有GC压力;同时,如果以数组作为实现的数据结构,内存一般连续分配,且读写时间复杂度为O(1),速度较快;当在实际应用中,写速度超过读速度时,也可将环形缓冲区作为缓冲。

基于Mutex的环形缓冲区设计

缓冲区结构体

type Message struct {
	ID      uint64
	Type    string
	Payload []byte
}

// 环形缓冲区
type RingBuffer struct {
	mu     sync.Mutex
	buffer []Message
	head   int
	tail   int
	len    int
	cap    int
}

这里定义了一个具体的类型Mesaage作为数据载体,主要是为了代码更直观,能聚焦于环形缓冲区的实现。

同时,使用了Mutex而非RWMutex的原因是,虽然后者允许多个goroutine同时读取,但是这也需要一个前提:读操作不修改共享状态。

但是这里不论是入队还是出队,都不是单纯的只读操作,此时RWMutex的并发读优势并不能完全发挥,反而引入了额外的内部开销。

Type用string而不用枚举

Message中的Type其实就是消息的类型,本来也就是随手写的一个字段,但是却突然给我带来这个疑问。
其实这严格来说与缓冲区实现没啥关系,只是想到了,就稍微探究一下。

Go中并没有提供enum关键字,一般枚举的实现可能是通过const+itoa来实现的。
用法大概是:

type Animal int 

const (
    Dog        Animal = iota    // 0,自动递增
    Cat                         // 1
    Rabbit                      // 2
    Pig                         // 3
)

如果Type用Go的枚举,实际上就是int,那么如果生产者和消费者是不同编程语言写的,今天生产者的Type加了一个类型或者顺序变了,那么消费者的Type也要实时同步,容易出错。

用string的话,不管在什么编程语言里都是这几个字母,出错的概率比较小。而且string类型对人友好,信息清晰、比较易读,方便后续Debug。

观察Go的net/http标准库里,涉及逻辑分发时,也倾向于用字符串常量,如const (MethodGet = "GET"……)等。

Payload用[]byte

这个问题本质上和上述Type为什么用string而不用枚举一样,就是灵光一闪但是和环形缓冲区实现没啥关系的东西,但是既然于这个疑问了,就稍微思考了一下。

在Go中,byte是uint8的别名,它们在底层是完全等价的:都占用8比特(1字节)的内存空间,取值范围为0-255。uint8强调它是一个数字;byte强调它是一个原始数据单位,是8位二进制。

不管是字符、图片,在计算机底层都是统一的0和1。而对Message的定位就是承载消息的底层数据结构,所以它并不需要知道Payload传的究竟是什么类型的数据,那么就比较适合使用[]byte。同时,[]byte传得也快,也不用操心类型转换。

而且,在Go中,[]byte(切片)本质是一个24字节的头部,当把一个切片赋值给另一个变量时,拷贝的知识这个头部,而不拷贝底层存储数据的数组本身。
所以,当payload的大小在一定范围内,入队时的拷贝成本完全相同。
但需注意:两个切片可能共享一块底层数组,在数据方面可能需要注意数据竞争或内存泄漏。

被弃用的SliceHeader

[]byte,在Go中,原与一个名为SliceHeader的结构体有关。这个结构体它在 64 位系统上占用 24 字节,包含三个关键字段。

type SliceHeader struct {
   // 指向底层数组起始位置的指针
	Data uintptr
   // 长度
	Len  int
   // 容量
	Cap  int
}

优点是当把[]byte传给一个函数时,只会拷贝这24字节的SliceHeader,而不会拷贝底层的原始数据。所以处理大量数据会比较快。

但是因为它存储地址的uintptr,在Go中只是一个能保存指针地址值的整数类型,而非指针。如果指针指向了内存中的某一个地址,GC会追踪,哪怕没用也不会收回这块地址;但是uintptr,在GC中只是一个普通的数字,虽然可以用来计算内存偏移,但没用会直接收回。

这就导致了,如果手动创建了一个SliceHeader,把一个变量的地址转成uintptr存进去。如果此时这个变量没有在其他地方被引用,那么GC直接把它回收。那么下次通过SliceHeader访问内存时,程序会崩溃或数据出错。

因为这个原因,SliceHeader已被弃用,转而用unsafe.Sliceunsafe.String

同时标准库提供了slices.Clone,它会创建一个真正的数据副本。虽然会增加拷贝成本,但解决了共享内存带来的安全隐患。
还有clear关键字,可以快速清空切片中的元素,但保持底层数组不释放。

不可变的string

因为StringHeader,位置在Go源码中,就在SliceHeader的上面,就顺便提一提。
同样的,它也被弃用了。

type StringHeader struct {
	Data uintptr
	Len  int
}

string的结构中没有Cap,这也就表示string并没有扩容的概念。
可以将它理解成只读的,不可变的。

string和[]byte的转换,我觉得Go处理得也挺好的,具体在slicebytetostringstringtoslicebyte两个函数里。

构造函数

func New(capacity int) *RingBuffer {
	if capacity <= 0 {
		panic(("capacity must be positive"))
	}
	return &RingBuffer{
		buffer: make([]Message, capacity),
		cap:    capacity,
	}
}

出错时返回panic而非error,是因为不可恢复的运行时异常用前者,可恢复的用后者。
capacity <= 0代表着队列容量小于等于0的情况,这是不应该也不可能发生的。

同时,由于sync.Mutex是一个互斥锁,它在首次使用后绝不能被复制,构造函数返回指针。

而如果构造函数返回的是值(结构体本身),那么将它赋值给某个变量时,会发生一次内存拷贝,导致新复制的锁的状态可能与原来的不一致。
同时锁的复制体和原件指向的不是同一个信号量,可能导致各个协程持有自己的锁,造成死锁风险。

而如果返回指针,那么所有的变量将会指向同一块内存,共享一把锁,资源状态的唯一性得到保证。

定义错误

var (
	ErrFull  = errors.New("buffer full")
	ErrEmpty = errors.New("buffer empty")
)

尝试一下哨兵错误。

它不是在函数内部临时通过errors.New创建,而是作为全局/包级别变量预先定义好的,即它只在包加载时创建一次。
而如果每次比较错误时,都用errors.New()来创建一个新对象,那么它每次被调用时都会在堆上分配一个新对象,对性能不利。

同时,Go的错误比较是是由内存地址决定的,而不是错误的字符串提示内容决定的。

由于哨兵错误只创建一次,那么判断时可以使用指针地址,只需一条CPU命令,速度极快。
但是普通错误每次都New出了一个新对象,那么它们的指针地址不同,只能使用err.Error() == "ringbuffer full"这样的方法来对比错误,涉及字符串拷贝和逐字符对比,性能极差且容易出错。

但由于,定义这个错误的是var,是变量而不是无法更改的常量,如果错误的内容在运行中不小心被改了,那么也容易埋雷,是个需要注意的地方。
同时,由于errors.New()无法格式化消息,所以比较适合处理存在于预期中的错误,否则不利于错误排查。
并且,由于上述错误被设计成了一个全局变量,需要依赖import,项目复杂了容易耦合。但是在这里目前应该不需要担心这个问题。

缓冲区操作实现

// 写入一条消息,满时返回ErrFull
func (mq *RingBuffer) Push(msg Message) error {
	mq.mu.Lock()
	defer mq.mu.Unlock()

	if mq.len == mq.cap {
		return ErrFull
	}

	mq.buffer[mq.tail] = msg
	mq.tail = (mq.tail + 1) % mq.cap
	mq.len++
	return nil
}

// 取出一条消息,空时返回ErrEmpty
func (mq *RingBuffer) Pop() (Message, error) {
	mq.mu.Lock()
	defer mq.mu.Unlock()

	if mq.len == 0 {
		return Message{}, ErrEmpty
	}

	msg := mq.buffer[mq.head]

	mq.buffer[mq.head] = Message{}
	mq.head = (mq.head + 1) % mq.cap
	mq.len--
	return msg, nil

}

主要是入队和出队操作。

(mq.tail + 1) % mq.cap实现环形队列,当tail到达队列的长度后,取模运算会让它自动变为0。
出队同理。

mq.buffer[mq.head] = Message{},这一步清空当前buffer里对应的值,而在调用Pop()函数之后,这个值就没有用了。
然而Go的赋值是值拷贝,而非转移所有权,即使已经赋值出去,只要这个队列不销毁,该值没被覆盖,且其中有指针,那么就不会被回收。
清空值有助于垃圾回收,防止内存泄漏;同时也可以避免无用旧数据的干扰。

查询状态

// 返回当前消息数量
func (mq *RingBuffer) Len() int {
	mq.mu.Lock()
	defer mq.mu.Unlock()

	return mq.len
}

// 返回容量
func (q *RingBuffer) Cap() int {
	return q.cap
}

不对Cap()加锁的原因是,自从创建成功后,这个变量不会再改变,没有必要加锁,徒增开销。

测试部分

Go中的测试

Go标准库自带了强大的测试功能,并不需要调用第三方库。

在Go中,要编写测试,要遵守以下三条基本规则:

  1. 测试的文件名必须以_test.go结尾
  2. 测试的函数名必须以Test开头,且首字母要大写
  3. 必须接受t *testing.T作为唯一参数

但有时,当测试用例增多时,大量的条件与语句会让代码变得臃肿。 这时可以将测试用的数据提取成表格,将测试流程模板化、使之可复用。这种方法叫做表格驱动测试,也是被广泛采用的写法。

同时,Go也提供了性能压力测试的写法,这同样需要遵守三条规则:

  1. 测试的函数名必须以Benchmark开头,且首字母要大写
  2. 参数为b *testing.B
  3. 必须在循环中使用b.N

b.N指的是测试函数的循环次数,它的值会一直动态增长直到循环运行的时间满足了设定的测试函数运行的时长。

在接下来的测试部分中一些基础的功能测试,在这里就不列出来了。
下面的测试主要是用Benchmark来看看这个环形缓冲区的性能如何。

测试环境

测试环境为Apple M1(arm64),Go运行时GOMAXPROCS=8。

单操作测试

首先,需要知道,在理想的情况下,单操作(Pop/Push)的基础开销是多少,包括交替读写的开销,方便与后续做对比。

func BenchmarkPush(b *testing.B) {
	q := New(b.N + 1)
	msg := Message{ID: 1, Payload: []byte("bench")}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = q.Push(msg)
	}
}

在真正有效的测试代码执行之前,使用b.ResetTimer()排除初始化干扰。

同时,这里用q := New(b.N + 1)偷懒了,在每次框架尝试开启一个新循环时,都让队列容量永远比Push次数多1。

该部分的其余测试代码和这个写法类似。

运行命令为go test -bench=. -benchmem
-benchmem后缀表示开启内存分配统计。

结果如下:

| 操作 | 操作耗时 | 内存分配大小 | 内存分配次数 | | --- | --- || --- | --- | | Push | 26.64 ns/op | 0 B/op | 0 allocs/op | | Pop | 25.19 ns/op | 0 B/op | 0 allocs/op | | PushPop串行 | 27.78 ns/op | 0 B/op | 0 allocs/op |

这个结果表示,入队和出队操作都比较稳定,且没有什么额外的开销。
同时,由于是环形队列,所以也不分配内存也是在预期当中。

并发性能测试

然后,需要知道,在固定核数下,单次Push后Pop的操作耗时如何变化。

func BenchmarkParallelPushPop(b *testing.B) {
	q := New(1024)
	msg := Message{ID: 1, Payload: []byte("bench")}
	b.ResetTimer()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			if err := q.Push(msg); err == nil {
				_, _ = q.Pop()
			}
		}
	})
}

使用b.RunParallel可以模拟多个Goroutine同时调用函数,从而测试函数在并发环境下的性能。

运行命令为go test -bench=BenchmarkParallelPushPop -cpu=1,2,4,8
-cpu=1,2,4,8表示测试会分别在测试会在GOMAXPROCS=1,2,4,8的环境下运行。
由于Apple M1芯片一共拥有8个物理核心、8个线程,所以在这条命令下设置cpu>8来测试性能可能没有很大意义,因为测试结果可能会受到线程切换的影响,而不是更好地反映队列的性能。

结果如下:

| GOMAXPROCS数量 | 操作耗时 | 内存分配大小 | 内存分配次数 | | --- | --- || --- | --- | | 1 | 27.51 ns/op | 0 B/op | 0 allocs/op | | 2 | 131.3 ns/op | 0 B/op | 0 allocs/op | | 4 | 292.4 ns/op | 0 B/op | 0 allocs/op | | 6 | 269.5 ns/op | 0 B/op | 0 allocs/op | | 8 | 268.4 ns/op | 0 B/op | 0 allocs/op |

从1核到4核时,性能急剧下跌。
因为,多个goroutine在抢同一个mutex,导致了时间很多都浪费在了等待锁释放而非实际工作。

4核之后,虽然耗时略有减少,但也稳定在某个数值。
锁竞争已经进入了完全饱和的状态,由于等待者足够多,所以每个mutex被释放出来就会立刻被占用。
高密度竞争可能反而分摊了成本。

并发扩展测试

上面的测试中,每个goroutine即是生产者,也是消费者。
在这种情况下,我会想知道,如果把生产消费分离,结果会如何变化。

func BenchmarkConcurrentDegree1(b *testing.B) {
	for _, d := range []int{1, 2, 4, 8} {
		d := d
		b.Run(fmt.Sprintf("goroutines-%d", d), func(b *testing.B) {
			q := New(1024)
			msg := Message{ID: 1, Payload: []byte("bench")}

			var localID int64 

			b.SetParallelism(d)
			b.ResetTimer()

			b.RunParallel(func(pb *testing.PB) {
				id := atomic.AddInt64(&localID, 1)
				if id%2 == 0 {
					for pb.Next() {
						q.Push(msg)
					}
				} else {
					for pb.Next() {
						q.Pop()
					}
				}
			})
		})
	}
}

b.SetParallelism(d)决定了b.RunParallel运行时实际启动的goroutine数量。
计算规律为:

P=dGOMAXPROCSP=d*GOMAXPROCS

结果如下:

goroutines数量GOMAXPROCS=1GOMAXPROCS=2GOMAXPROCS=4GOMAXPROCS=8
113.87 ns/op75.68 ns/op130.7 ns/op128.2 ns/op
216.90 ns/op76.33 ns/op137.9 ns/op113.9 ns/op
418.05 ns/op75.47 ns/op124.8 ns/op115.5 ns/op
817.55 ns/op72.76 ns/op122.2 ns/op109.6 ns/op

与上面的测试一样,随着GOMAXPROCS数量的增加,不论goroutine的数量如何,都迅速恶化。
还是锁竞争的问题。

然而,每个操作耗时比上面的测试都少了一半左右,这是因为id%2将goroutine变成了纯生产者和纯消费者,一半groutine只管Push,另一半只管Pop。
与单个线程Push+Pop的混合模型相比,这个测试明显降低了锁竞争,更接近理想结果。

但不论如何,明显的是,锁竞争成为瓶颈。

改进与展望

通过测试已经发现,锁竞争限制了这个队列的并发速度。
接下来如果要改进的话,可以尝试把锁去掉。

而从并发扩展测试中,也能看出,如果让每个goroutine专注生产或者消费,也可以在某种程度上提升速度。

本来还想尝试一下,用Go的channel实现,毕竟这是Go的原生并发手段。
以后有时间可能会研究一下channel。