go 高性能编程EP11 lock-free 实践.zh-cn

 

在[上一篇](013 两个有用的 Golang 无锁编程技巧.zh-cn)文章中,我们讨论了两个无锁编程思想。总结就是:通过精心的设计消除数据共享或者消除并发访问同一个数据。尽管我们一直想追求完全的无锁编程,但事实是这基本不可能。一般来说,我们讨论look-free,是因为想要避免加锁,那么使用CAS是一种不错的选择。如果CAS 都不能够满足要求,那么我们可以尽量将Mutex 的粒度缩小,或者使用RWmutex 替换Mutex等。
lock-free的优势:

  • 减少线程阻塞和等待时间
  • 避免线程的优先级反转
  • 提高并发性能
  • 消除竞态条件(race condition)、死锁、饿死等潜在问题
  • 代码更加清晰易懂
    本文我们将使用Go实践一下几种lock-free编程的方案。

channel

Share Memory By Communicating 是Go语言官方推荐的共享内存方案:

不要通过共享内存进行通信;相反,通过通信来共享内存。

channel实现 lock-free 的 原因是

  1. channel 将数据所有全交出去了
  2. channel 内部使用了锁
    在官方博客的例子中
1
2
3
4
5
6
7
8
9
type Resource string

func Poller(in, out chan *Resource) {
    for r := range in {
        // poll the URL
        // send the processed Resource to out
        out <- r
    }
}

数据通过 in,out 两个channel 流转,不存在同一个时间有多个goroutine 同时操作一份数据,所以这就是lock-free

CAS

现在几乎所有的 CPU 指令都支持 CAS 的原子操作,
CAS (compare and swap) 是原子操作的一种,用于在多线程中实现原子数据交换,避免多线程同时改写某一共享数据时,由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。我们可以直接用 CAS 来实现 lock free的数据结构。
如果我们要实现一个stack 数据结构,最常用的方案就是使用slice+ RWmutex ,保证并发安全, 同时,我们也可以使用CAS来实现 这个数据结构,完整的 代码如下:

通过简单的benchmark 测试, lock-free 版本的 stack 要比 slice+ RWmutex 快大约20%,如果是在并发场景下,lock-free 的性能表现可能还会更好。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func BenchmarkConcurrentPushLockFree(b *testing.B) {  
    stack := NewStackByLockFree()  
    for i := 0; i < b.N; i++ {  
       stack.Push(i)  
    }  
}  
  
func BenchmarkConcurrentPushSlice(b *testing.B) {  
    stack := NewStackBySlice()  
    for i := 0; i < b.N; i++ {  
       stack.Push(i)  
    }  
}
1
2
3
4
➜  lock-free git:(main) ✗ go test --bench=. 

BenchmarkConcurrentPushLockFree-10      22657522                49.66 ns/op
BenchmarkConcurrentPushSlice-10         29135671                59.04 ns/op

Struct Copy

从本质上讲Struct Copy 是一种空间换时间的策略。如下面的代码所示:

SingleBuffer 是一个共享资源,外部代码每次使用它的时候需要加锁, 但是如果用一个BufferWrapper 将它包裹起来,让每个goroutine 持有一个单独的BufferWrapper,那么是不是不用去考虑dataracing了? (使用sync.pool是为了减少内存分配)

性能测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func BenchmarkSingleBuffer_print(b *testing.B) {  
  
    for i := 0; i < b.N; i++ {  
       run_buff_bench(singleBuff)  
    }  
}  
  
func BenchmarkBufferWrapper_print(b *testing.B) {  
    bufferWrapper := NewBufferWrapper()  
    for i := 0; i < b.N; i++ {  
       run_buff_bench(bufferWrapper)  
    }  
}  
  
func run_buff_bench(buff printId) {  
    wg := sync.WaitGroup{}  
    f := func(id int) {  
       defer wg.Done()  
       for j := 0; j < 10000; j++ {  
          buff.print(id)  
       }  
    }  
    for j := 0; j < 100; j++ {  
       wg.Add(1)  
       go f(j)  
    }  
    wg.Wait()  
}
1
2
BenchmarkSingleBuffer_print-10                 4         261640427 ns/op
BenchmarkBufferWrapper_print-10               67          18320110 ns/op

fastjsonParser 也是如此做的, 我们在很多性能优化场景下都能见到这种操作。

总结

本篇文章中,我们探讨了三种实现 lock-free 编程的方法,并通过实际的代码和性能测试验证了它们的有效性。尽管完全实现无锁编程是一个艰难的目标,但通过合理的设计和技术选型,可以显著减少锁的使用,提高系统的并发性能。在实际开发中,应该根据具体场景选择合适的方案,比如在性能敏感的场景下,可以优先考虑 CAS 和 Struct Copy;在数据流转清晰、操作简单的场景中,Channel 是一种非常自然的选择。

Licensed under CC BY-NC-SA 4.0
最后更新于 Sep 09, 2024 09:46 CST
使用 Hugo 构建
主题 StackJimmy 设计