在Golang的并发编程场景中,多个goroutine同时访问共享资源时,为了保证数据一致性通常会使用锁机制,但锁的不合理使用会引发锁竞争,导致程序性能下降。锁竞争指的是多个goroutine同时尝试获取同一把锁,其中部分goroutine需要等待锁释放才能继续执行,这个过程会产生上下文切换开销,拖慢整体执行速度。

锁竞争的常见产生原因
锁竞争的出现通常和锁的使用方式有关,常见的原因有以下几种:
- 锁的粒度过大,一把锁保护了过多的共享资源,导致不同业务逻辑的goroutine都需要竞争同一把锁
- 锁的持有时间过长,在锁内部执行了耗时的操作,比如IO操作、复杂计算,延长了其他goroutine的等待时间
- 没有根据场景选择合适的锁类型,读多写少的场景下使用了互斥锁,导致读操作之间也产生不必要的竞争
sync包中锁类型的选择
Golang的sync包提供了多种锁类型,合理选择可以减少不必要的竞争:
sync.Mutex互斥锁
互斥锁是最基础的锁类型,同一时间只能有一个goroutine持有锁,适用于写操作较多的场景,或者读写操作都需要严格互斥的场景。
package main
import (
"fmt"
"sync"
"time"
)
var (
count int
mu sync.Mutex
)
// 使用互斥锁修改共享变量
func addWithMutex() {
mu.Lock()
defer mu.Unlock()
count++
}
func main() {
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
addWithMutex()
}()
}
wg.Wait()
fmt.Printf("使用互斥锁最终结果:%d,耗时:%vn", count, time.Since(start))
}
sync.RWMutex读写锁
读写锁区分读锁和写锁,多个goroutine可以同时持有读锁,但写锁是互斥的,读锁和写锁之间也互斥。适合读多写少的场景,可以大幅提升读操作的并发性能。
package main
import (
"fmt"
"sync"
"time"
)
var (
count int
rwMu sync.RWMutex
)
// 读操作使用读锁
func readWithRWMutex() int {
rwMu.RLock()
defer rwMu.RUnlock()
return count
}
// 写操作使用写锁
func writeWithRWMutex() {
rwMu.Lock()
defer rwMu.Unlock()
count++
}
func main() {
var wg sync.WaitGroup
start := time.Now()
// 模拟90%读操作,10%写操作
for i := 0; i < 9000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
readWithRWMutex()
}()
}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
writeWithRWMutex()
}()
}
wg.Wait()
fmt.Printf("使用读写锁最终结果:%d,耗时:%vn", count, time.Since(start))
}
原子操作替代锁的场景
如果共享资源是简单的数值类型(比如int32、int64、指针等),且操作是简单的增减、赋值、比较交换,那么可以使用sync/atomic包的原子操作替代锁。原子操作是硬件级别的原子指令,不需要加锁,没有锁竞争的开销,性能远高于锁。
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var count int64
// 使用原子操作修改共享变量
func addWithAtomic() {
atomic.AddInt64(&count, 1)
}
func main() {
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
addWithAtomic()
}()
}
wg.Wait()
fmt.Printf("使用原子操作最终结果:%d,耗时:%vn", atomic.LoadInt64(&count), time.Since(start))
}
减少锁竞争的其他优化技巧
- 缩小锁的粒度:只把需要保护共享资源的代码放在锁内部,耗时的操作比如日志打印、IO操作放到锁外部执行
- 使用分段锁:如果共享资源可以拆分,把大锁拆成多个小锁,不同段的数据用不同的锁保护,减少竞争概率
- 避免锁嵌套:尽量不要在一个锁内部获取另一个锁,否则容易产生死锁,也会增加锁的持有时间
- 优先使用channel:Golang的channel是内置的并发原语,很多场景下可以用channel传递数据替代共享变量加锁的方式,更符合Golang的并发设计理念
不同方案的性能对比
我们用10000次并发增减操作的场景,对比三种方案的性能:
| 方案 | 适用场景 | 大致耗时(参考值) |
|---|---|---|
| sync.Mutex互斥锁 | 写多读少,复杂操作 | 约1.2ms |
| sync.RWMutex读写锁 | 读多写少 | 约0.8ms |
| atomic原子操作 | 简单数值操作 | 约0.3ms |
实际开发中需要根据具体的业务场景选择合适的方案,不是所有场景都要追求无锁,保证数据正确性的前提下再优化性能才是合理的做法。