Golang并发控制

624 阅读11分钟

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:使用 LDREXSTREX 原子指令。
  • PowerPC:使用 lwarxstwcx. 原子指令。
  • RISC-V:使用 LR.WSC.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(错误处理等待组)

概述

ErrGroupWaitGroup 相似,主要用于等待多个 goroutine 执行完成,并且能够在执行过程中收集和传播错误。

底层实现

ErrGroup 底层依赖于 WaitGroupChannel,通过 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 的并发机制有更深刻的理解,并能在项目中更高效地应用这些工具。