Golang 并发控制:原语、实现与优化
并发编程是 Golang 中的一大优势。通过 Goroutines 和各种并发原语,Golang 让我们能够编写高效且易于管理的并发程序。在这次分享中,我们将探讨 Golang 并发控制的常见原语,它们的底层实现,以及如何选择最合适的原语来优化程序性能。
1. Mutex(互斥锁)
概述
Mutex 是 Golang 提供的最基础的并发控制原语之一,确保同一时刻只有一个 goroutine 能访问共享资源。它通过锁机制防止多个 goroutine 同时修改共享数据,避免数据竞争。
底层实现
Golang 的 sync.Mutex 基于 atomic 操作实现。实际上,Mutex 在底层会通过操作系统的原生锁(如 pthread mutex)来控制并发访问。当一个 goroutine 调用 Lock 时,如果该锁已被其他 goroutine 占用,当前 goroutine 会被阻塞,直到锁被释放。
- 锁竞争:如果多个 goroutine 同时请求同一把锁,系统会处理锁竞争情况,但这种竞争会导致上下文切换,从而影响性能。
// A Mutex is a mutual exclusion lock.
// The zero value for a Mutex is an unlocked mutex.
//
// A Mutex must not be copied after first use.
//
// In the terminology of [the Go memory model],
// the n'th call to [Mutex.Unlock] “synchronizes before” the m'th call to [Mutex.Lock]
// for any n < m.
// A successful call to [Mutex.TryLock] is equivalent to a call to Lock.
// A failed call to TryLock does not establish any “synchronizes before”
// relation at all.
type Mutex struct {
_ noCopy // go vet 静态检查
mu isync.Mutex
}
// A Mutex is a mutual exclusion lock.
//
// See package [sync.Mutex] documentation.
type Mutex struct {
state int32
sema uint32
}
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota
// 转换为饥饿模式的超时时间 1ms
starvationThresholdNs = 1e6
// 普通模式和饥饿模式区别:是否直接把锁给对应的等待队列的第一个
// 转换条件:
// 转换为饥饿模式:等待时间超过1ms
// 转换为普通模式:等待时间少于1ms,或者 等待队列的最后一个
)
// Lock locks m.
// See package [sync.Mutex] documentation.
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}
适用场景
- 共享资源访问:当多个 goroutine 需要读写共享数据时,可以使用
Mutex来保护数据。 - 需要阻塞某些 goroutine,直到临界区操作完成的情况。
示例代码
var mu sync.Mutex
var counter int// 增加计数器值
mu.Lock()
counter++
mu.Unlock()
性能考虑
在高并发环境下,Mutex 锁的性能可能会成为瓶颈,尤其是当多个 goroutine 频繁竞争同一把锁时,会导致线程切换的开销。因此,尽量减少锁的持有时间,并避免长时间锁定。
注意事项
- 避免死锁:使用
Mutex代码块简洁 - 及时解锁:使用 defer 保证解锁的逻辑始终执行
- 减少锁持有时间:将不必要的代码移除加锁的范围之内
源码地址:
cs.opensource.google/go/go/+/mas…
2. Atomic(原子操作)
概述
Atomic 是 Golang 提供的低级并发控制工具,用于执行一些原子操作(即不可分割的操作),例如原子加法、原子交换等。它通过无锁的方式直接操作内存,避免了使用锁的开销。
底层实现
Atomic 操作通常使用 CAS(Compare-and-Swap) 指令,它是硬件级别的原子操作,可以确保在更新变量时不会发生数据竞争。CAS 操作会比较目标内存位置的当前值和预期值,如果相同,则更新目标值,否则返回失败。
- CAS(Compare-And-Swap) :CAS 是一种无锁同步机制,通过硬件指令原子地比较并交换内存值,避免了传统锁的性能开销。
不同架构/指令集,对应不同的原子指令
- x86-64:使用
CMPXCHG原子指令。 - ARM:使用
LDREX和STREX原子指令。 - PowerPC:使用
lwarx和stwcx.原子指令。 - RISC-V:使用
LR.W和SC.W原子指令。 - 其他架构:可能会使用回退的原子操作(如加锁或重试机制)
适用场景
- 计数器:例如原子加法和减法。
- 状态更新:多个 goroutine 需要对某个状态变量进行并发更新时,使用
atomic操作可以避免使用锁。 - 无锁数据结构:在高性能应用中,利用原子操作来实现无锁的数据结构,如队列和栈。
示例代码
var counter int32// 使用原子操作增加计数器
atomic.AddInt32(&counter, 1)
性能考虑
Atomic 操作非常高效,但它也有局限性。因为它是基于硬件指令实现的,如果在高并发下频繁使用,仍然可能导致一些性能瓶颈(比如频繁的 CAS 失败和重试)。因此,它适用于高频率更新的场景,但在某些复杂数据结构的场景中,可能需要配合其他并发原语使用。
源码(汇编):
cs.opensource.google/go/go/+/mas…
扩展:
总线锁和缓存一致性
3. Channel(通道)
概述
Channel 是 Golang 中用于 goroutines 之间通信的原语,它不仅可以传递数据,还能在并发程序中进行同步。通过 channel,goroutine 可以发送和接收数据,保证并发安全,同时避免了共享内存的直接访问。
底层实现
Channel 使用内部的 缓冲区 来存储数据,并通过 mutex 来确保数据的一致性和安全性。在无缓冲的 channel 中,发送者会被阻塞,直到接收者开始接收数据;而在有缓冲的 channel 中,发送者可以直接写入缓冲区,直到缓冲区满时才会阻塞。
- 缓冲区:有缓冲区的 channel 允许在不被接收者及时处理的情况下,缓存数据,增加了并发度和吞吐量。
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
synctest bool // true if created in a synctest bubble
closed uint32
timer *timer // timer feeding this chan
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
type waitq struct {
first *sudog
last *sudog
}
type sudog struct {
// The following fields are protected by the hchan.lock of the
// channel this sudog is blocking on. shrinkstack depends on
// this for sudogs involved in channel ops.
g *g
next *sudog
prev *sudog
elem unsafe.Pointer // data element (may point to stack)
// The following fields are never accessed concurrently.
// For channels, waitlink is only accessed by g.
// For semaphores, all fields (including the ones above)
// are only accessed when holding a semaRoot lock.
acquiretime int64
releasetime int64
ticket uint32
// isSelect indicates g is participating in a select, so
// g.selectDone must be CAS'd to win the wake-up race.
isSelect bool
// success indicates whether communication over channel c
// succeeded. It is true if the goroutine was awoken because a
// value was delivered over channel c, and false if awoken
// because c was closed.
success bool
// waiters is a count of semaRoot waiting list other than head of list,
// clamped to a uint16 to fit in unused space.
// Only meaningful at the head of the list.
// (If we wanted to be overly clever, we could store a high 16 bits
// in the second entry in the list.)
waiters uint16
parent *sudog // semaRoot binary tree
waitlink *sudog // g.waiting list or semaRoot
waittail *sudog // semaRoot
c *hchan // channel
}
适用场景
- Goroutine 通信:最常见的用法是通过 channel 在多个 goroutine 之间传递消息,避免使用全局变量。
- 同步控制:通过向 channel 发送空值来控制 goroutine 的执行顺序和同步。
示例代码
ch := make(chan int)
go func() {
ch <- 1// 发送数据
}()
data := <-ch // 接收数据
mt.Println(data)
性能考虑
Channel 是一种非常灵活的并发控制方式,但其性能受限于缓冲区的大小和内存分配。频繁的 channel 操作会增加内存分配和垃圾回收的负担,特别是在使用无缓冲 channel 时,可能会导致 goroutine 的频繁阻塞,影响整体并发性能。
源码:
cs.opensource.google/go/go/+/mas…
4. Singleflight
概述
Singleflight 是 Golang 提供的一个并发控制工具,主要用于避免重复执行同一个操作。当多个 goroutine 同时请求相同的资源时,Singleflight 保证只有一个 goroutine 会执行该操作,其他 goroutine 会等待该操作结果。
底层实现
Singleflight 的底层实现是通过 Mutex 来管理对某个资源的独占访问,确保同一时刻只有一个 goroutine 进行计算或请求。其原理是通过唯一的标识符来区分操作,保证同一标识符的操作只会执行一次。
// call is an in-flight or completed singleflight.Do call
type call struct {
wg sync.WaitGroup
// These fields are written once before the WaitGroup is done
// and are only read after the WaitGroup is done.
val any
err error
// These fields are read and written with the singleflight
// mutex held before the WaitGroup is done, and are read but
// not written after the WaitGroup is done.
dups int
chans []chan<- Result
}
// Group represents a class of work and forms a namespace in
// which units of work can be executed with duplicate suppression.
type Group struct {
mu sync.Mutex // protects m
m map[string]*call // lazily initialized
}
// Result holds the results of Do, so they can be passed
// on a channel.
type Result struct {
Val any
Err error
Shared bool
}
适用场景
- 避免重复请求:在进行网络请求或长时间计算时,如果多个 goroutine 请求相同的资源,使用
Singleflight可以避免重复计算或请求,提高性能。
示例代码
var g singleflight.Group
result, err, shared := g.Do("key", func() (interface{}, error) {
return doWork()
})
性能考虑
Singleflight 的核心优势是通过减少重复操作来提升性能,特别适用于处理昂贵的网络请求或复杂计算的场景。在高并发环境下,可以显著减少资源的浪费。
局限性:根据key进行限制,并且依赖于同时请求;串行的请求无效;
源码:
cs.opensource.google/go/go/+/mas…
5. WaitGroup(等待组)
概述
WaitGroup 是一种同步原语,用于等待一组 goroutine 执行完成。通过调用 Add 增加计数器,调用 Done 减少计数器,直到计数器为零,Wait 方法才会返回。
底层实现
WaitGroup 的实现基于 atomic 操作,它维护了一个计数器,保证 goroutine 执行完毕后再继续执行主线程。当调用 Wait 方法时,如果计数器不为零,主线程会被阻塞。
适用场景
- 多个并发任务的同步:当你启动多个 goroutine 进行并发操作,并希望等待所有操作完成时,使用
WaitGroup可以方便地同步。
示例代码
var wg sync.WaitGroup
// 启动多个 goroutine
for i := 0; i < 5; i++ {
wg.Add(1)
gofunc(i int) {
defer wg.Done()
fmt.Println(i)
}(i)
}
// 等待所有 goroutine 完成
wg.Wait()
性能考虑
WaitGroup 的性能取决于 goroutine 的数量和等待时间。它通过原子操作来控制 goroutine 的完成状态,因此在高并发时能有效减少上下文切换带来的性能损失。
源码:
cs.opensource.google/go/go/+/mas…
6. ErrGroup(错误处理等待组)
概述
ErrGroup 与 WaitGroup 相似,主要用于等待多个 goroutine 执行完成,并且能够在执行过程中收集和传播错误。
底层实现
ErrGroup 底层依赖于 WaitGroup 和 Channel,通过 Channel 收集每个任务的错误信息,并且如果任何一个 goroutine 返回错误,ErrGroup 会立即终止其他任务。
type Group struct {
cancel func(error)
wg sync.WaitGroup
sem chan token // 控制并发goroutine的数量
errOnce sync.Once // 控制返回唯一的
err error
}
适用场景
- 并发任务的错误收集:当多个并发任务可能返回错误时,
ErrGroup可以帮助我们同时等待任务完成并收集错误。
示例代码
var g errgroup.Group
g.Go(func()error {
return doTask()
})
if err := g.Wait(); err != nil {
log.Fatal(err)
}
源码:
cs.opensource.google/go/x/sync/+…
7. Once(只执行一次)
概述
Once 是一个确保某个操作只执行一次的同步原语,通常用于初始化或单例模式的实现。
底层实现
Once 使用 Mutex 来确保函数体内的代码只执行一次。无论调用多少次,只有第一次调用会执行实际操作,后续调用会被忽略。
适用场景
- 单例模式:当某些初始化操作必须只执行一次时,
Once是一个非常有效的工具。
示例代码
go
var once sync.Once
once.Do(func() {
fmt.Println("Initialized!")
})
源码:
cs.opensource.google/go/go/+/mas…
8. sync.Cond
概述
它的主要功能就是通过一个条件来实现阻塞和唤醒一组需要协作的 goroutine
底层实现
sync.Cond 的底层通过 notifyList 管理 goroutine 的等待和唤醒,核心依赖 Go runtime 提供的信号量机制来挂起和唤醒 goroutine。
使用场景
- 等待某个条件成立的同步机制(如消费者等待生产者生产)
- 唤醒单个或多个 goroutine 协调工作(如通知多个消费者继续工作)
示例代码
func main() {
c := sync.NewCond(&sync.Mutex{})
condition := false // 定义一个条件变量
go func() { // 启动一个子 goroutine 进行等待
fmt.Println("wait before")
c.L.Lock()
for !condition { // 通过循环检查条件是否满足
c.Wait() // 阻塞并等待通知
}
fmt.Println("condition met, continue execution")
c.L.Unlock()
fmt.Println("wait after")
}()
time.Sleep(time.Second)
fmt.Println("signal before")
c.L.Lock()
condition = true // 改变条件变量的状态
c.L.Unlock()
c.Signal() // 通知唤醒一个阻塞的 goroutine
fmt.Println("signal after")
time.Sleep(time.Second) // 确保子 goroutine 执行完成再退出
}
源码:
cs.opensource.google/go/go/+/mas…
总结
Golang 提供了丰富的并发控制原语,每种工具都有其独特的实现和适用场景。在开发高并发系统时,了解它们的底层原理和适用场景,能够帮助我们在实际应用中做出更好的选择,提升系统的性能和可靠性。希望通过今天的分享,大家能够对 Golang 的并发机制有更深刻的理解,并能在项目中更高效地应用这些工具。