Go 并发编程

Go 并发编程

1、Mutex 几种状态

  • mutexLocked — 表示互斥锁的锁定状态;

  • mutexWoken — 表示从正常模式被从唤醒;

  • mutexStarving — 当前的互斥锁进入饥饿状态;

  • waitersCount — 当前互斥锁上等待的 Goroutine 个数;

2、Mutex 正常模式和饥饿模式

正常模式(非公平锁)

正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。唤醒 的 goroutine 不会直接拥有锁,而是会和新请求 goroutine 竞争锁。新请求的 goroutine 更容易抢占:因为它正在 CPU 上执行,所以刚刚唤醒的 goroutine有很大可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 会加入 到等待队列的前面。

饥饿模式(公平锁)

为了解决了等待 goroutine 队列的长尾问题 饥饿模式下,直接由 unlock 把锁交给等待队列中排在第一位的 goroutine (队 头),同时,饥饿模式下,新进来的 goroutine 不会参与抢锁也不会进入自旋状 态,会直接进入等待队列的尾部。这样很好的解决了老的 goroutine 一直抢不 到锁的场景。

饥饿模式的触发条件:当一个 goroutine 等待锁时间超过 1 毫秒时,或者当前 队列只剩下一个 goroutine 的时候,Mutex 切换到饥饿模式。

总结

对于两种模式,正常模式下的性能是最好的,goroutine 可以连续多次获取 锁,饥饿模式解决了取锁公平的问题,但是性能会下降,这其实是性能和公平 的一个平衡模式。

3、Mutex 允许自旋的条件

  • 锁已被占用,并且锁不处于饥饿模式。

  • 积累的自旋次数小于最大自旋次数(active_spin=4)。

  • CPU 核数大于 1。

  • 有空闲的 P。

  • 当前 Goroutine 所挂载的 P 下,本地待运行队列为空。

4、RWMutex 实现

通过记录 readerCount 读锁的数量来进行控制,当有一个写锁的时候,会将读 锁数量设置为负数 1<<30。目的是让新进入的读锁等待之前的写锁释放通知读锁。同样的当有写锁进行抢占时,也会等待之前的读锁都释放完毕,才会开始进行后续的操作。 而等写锁释放完之后,会将值重新加上 1<<30, 并通知刚才 新进入的读锁(rw.readerSem),两者互相限制。

5、RWMutex 注意事项

  • RWMutex 是单写多读锁,该锁可以加多个读锁或者一个写锁

  • 读锁占用的情况下会阻止写,不会阻止读,多个 Goroutine 可以同时获取读锁

  • 写锁会阻止其他 Goroutine(无论读和写)进来,整个锁由该 Goroutine 独占

  • 适用于读多写少的场景

  • RWMutex 类型变量的零值是一个未锁定状态的互斥锁

  • RWMutex 在首次被使用之后就不能再被拷贝

  • RWMutex 的读锁或写锁在未锁定状态,解锁操作都会引发 panic

  • RWMutex 的一个写锁去锁定临界区的共享资源,如果临界区的共享资源已 被(读锁或写锁)锁定,这个写锁操作的 goroutine 将被阻塞直到解锁

  • RWMutex 的读锁不要用于递归调用,比较容易产生死锁

  • RWMutex 的锁定状态与特定的 goroutine 没有关联。一个 goroutine 可 以 RLock(Lock),另一个 goroutine 可以 RUnlock(Unlock)

  • 写锁被解锁后,所有因操作锁定读锁而被阻塞的 goroutine 会被唤醒,并 都可以成功锁定读锁

  • 读锁被解锁后,在没有被其他读锁锁定的前提下,所有因操作锁定写锁而 被阻塞的 Goroutine,其中等待时间最长的一个 Goroutine 会被唤醒

6、Cond 是什么

Cond 实现了一种条件变量,可以使用在多个 Reader 等待共享资源 ready 的场 景(如果只有一读一写,一个锁或者 channel 就搞定了)每个 Cond 都会关联一个 Lock(*sync.Mutex or *sync.RWMutex),当修改条 件或者调用 Wait 方法时,必须加锁,保护 condition。

以下摘自ChatGPT:

在 Go 语言中,Cond(条件变量)是一种用于实现多个 goroutine 之间等待和通知的同步机制。Cond 可以用于解决多个 goroutine 之间协作的问题,例如生产者和消费者问题、读写锁问题等。

Cond 通常与 Mutex 一起使用,它可以让等待锁的 goroutine 临时释放锁,等待某个条件变量满足之后再重新获取锁。

Cond 包含三个主要的方法:

  • Wait():等待条件变量满足,同时释放锁,直到另外一个 goroutine 调用 Signal() 或 Broadcast() 来通知条件变量已经满足,该方法才会返回,并重新获取锁。

  • Signal():通知等待条件变量的一个 goroutine,条件变量已经满足。

  • Broadcast():通知所有等待条件变量的 goroutine,条件变量已经满足。

当一个 goroutine 调用 Wait() 方法时,它会将当前的 goroutine 添加到条件变量的等待队列中,并且临时释放锁。当另外一个 goroutine 调用 Signal() 或 Broadcast() 方法时,它会通知条件变量中等待队列中的 goroutine,条件变量已经满足,这些 goroutine 会被唤醒并重新获取锁,然后继续执行。

通过使用 Cond,多个 goroutine 可以协作地等待和通知条件变量的状态变化,从而实现更加高效和灵活的并发编程。

例子

一个简单的例子就是使用 Cond 实现生产者-消费者模式。

假设有一个队列,多个生产者可以往队列中添加数据,多个消费者可以从队列中取出数据。由于多个 goroutine 同时对队列进行操作,需要使用 Mutex 保证队列的线程安全。同时,为了避免消费者不断地进行无用的轮询,可以使用 Cond 来实现消费者等待新的数据添加到队列中的功能。

下面是使用 Cond 实现生产者-消费者模式的代码示例:

package main
​
import (
 "fmt"
 "sync"
)
​
type Queue struct {
 data []int
 mutex sync.Mutex
 cond *sync.Cond
}
​
func NewQueue() *Queue {
 q := &Queue{
 data: make([]int, 0),
 }
 q.cond = sync.NewCond(&q.mutex)
 return q
}func (q *Queue) Put(v int) {
 q.mutex.Lock()
 defer q.mutex.Unlock()
​
 q.data = append(q.data, v)
 q.cond.Signal() // 通知消费者
}func (q *Queue) Get() int {
 q.mutex.Lock()
 defer q.mutex.Unlock()for len(q.data) == 0 {
 q.cond.Wait() // 等待新的数据添加到队列中
 }
​
 v := q.data[0]
 q.data = q.data[1:]
 return v
}
​
func main() {
 q := NewQueue()// 启动多个生产者
 for i := 0; i < 3; i++ {
 go func(i int) {
 for j := 0; j < 5; j++ {
 v := i * 10 + j
 q.Put(v)
 fmt.Printf("producer %d put %d\n", i, v)
 }
 }(i)
 }// 启动多个消费者
 for i := 0; i < 2; i++ {
 go func(i int) {
 for j := 0; j < 7; j++ {
 v := q.Get()
 fmt.Printf("consumer %d get %d\n", i, v)
 }
 }(i)
 }// 等待所有生产者和消费者结束
 var wg sync.WaitGroup
 wg.Add(5 + 7)
 wg.Wait()
}

在上面的代码中,生产者使用 Put() 方法往队列中添加数据,消费者使用 Get() 方法从队列中取出数据。当队列中没有数据时,消费者调用 Wait() 方法等待新的数据添加到队列中,当生产者往队列中添加数据时,会调用 Signal() 方法通知等待的消费者。

通过使用 Mutex 和 Cond,可以保证多个生产者和消费者能够安全地协作地操作队列,从而实现了生产者-消费者模式。

7、Broadcast 和 Signal 区别

func (c *Cond) Broadcast()

Broadcast 会唤醒所有等待 c 的 goroutine。 调用 Broadcast 的时候,可以加锁,也可以不加锁。

func (c *Cond) Signal()

Signal 只唤醒 1 个等待 c 的 goroutine。 调用 Signal 的时候,可以加锁,也可以不加锁。

8、Cond 中 Wait 使用

func (c *Cond) Wait()

Wait()会自动释放 c.L 锁,并挂起调用者的 goroutine。之后恢复执行,

Wait()会在返回时对 c.L加锁。

除非被 Signal 或者 Broadcast 唤醒,否则 Wait()不会返回。

由于 Wait()第一次恢复时,C.L并没有加锁,所以当 Wait 返回时,调用者通常 并不能假设条件为真。如下代码:

取而代之的是, 调用者应该在循环中调用 Wait。(简单来说,只要想使用 condition,就必须加锁。)

c.L.Lock()
for !condition() {
 c.Wait()
}
... make use of condition ...
c.L.Unlock()

9、WaitGroup 用法

一个 WaitGroup 对象可以等待一组协程结束。使用方法是:

  1. main 协程通过调用 wg.Add(delta int) 设置 worker 协程的个数,然后创 建 worker 协程;

  2. worker 协程执行结束以后,都要调用 wg.Done();

  3. main 协程调用 wg.Wait() 且被 block,直到所有 worker 协程全部执行结束 后返回。

10、WaitGroup 实现原理

  • WaitGroup 主要维护了 2 个计数器,一个是请求计数器 v,一个是等待计数 器 w,二者组成一个 64bit 的值,请求计数器占高 32bit,等待计数器占低 32bit。

  • 每次 Add 执行,请求计数器 v 加 1,Done 方法执行,等待计数器减 1,v 为 0 时通过信号量唤醒 Wait()。

11、什么是 sync.Once

  • Once 可以用来执行且仅仅执行一次动作,常常用于单例对象的初始化场景

  • Once 常常用来初始化单例资源,或者并发访问只需初始化一次的共享资 源,或者在测试的时候初始化一次测试资源。

  • sync.Once 只暴露了一个方法 Do,你可以多次调用 Do 方法,但是只有第 一次调用 Do 方法时 f 参数才会执行,这里的 f 是一个无参数无返回值 的函数。

以下摘自ChatGPT:

sync.Once 是 Go 语言中的一个同步原语,用于保证一个函数只被执行一次。

Once 类型有一个 Do 方法,该方法接受一个函数作为参数,只有在第一次调用 Do 方法时,才会执行该函数。后续调用 Do 方法会直接返回,不会再次执行传入的函数。

使用 sync.Once 可以很方便地实现单例模式,例如在需要使用单例的场景下,我们可以将单例的初始化操作放在 sync.Once 的 Do 方法中,保证只有在第一次使用单例时进行初始化操作,后续使用直接返回已经初始化过的单例实例。

下面是一个使用 sync.Once 实现单例模式的简单示例代码:

package main
​
import (
 "fmt"
 "sync"
)
​
type Singleton struct {
 name string
}var (
 instance *Singleton
 once     sync.Once
)
​
func GetInstance() *Singleton {
 once.Do(func() {
 instance = &Singleton{name: "singleton instance"}
 })
 return instance
}
​
func main() {
 // 第一次调用 GetInstance 会进行初始化操作
 singleton1 := GetInstance()
 fmt.Println(singleton1.name)// 再次调用 GetInstance 不会进行初始化操作
 singleton2 := GetInstance()
 fmt.Println(singleton2.name)// 两次调用返回的是同一个实例
 fmt.Println(singleton1 == singleton2)
}

在上面的示例中,GetInstance() 函数使用 sync.Once 来保证 Singleton 类型的实例只被创建一次,后续的调用都直接返回已经创建的实例。这样就可以方便地实现单例模式,避免了多次创建相同的对象,节省了系统资源。

12、什么操作叫做原子操作

原子操作即是进行过程中不能被中断的操作,针对某个值的原子操作在被进行 的过程中,CPU 绝不会再去进行其他的针对该值的操作。为了实现这样的严谨 性,原子操作仅会由一个独立的 CPU 指令代表和完成。原子操作是无锁的,常 常直接通过 CPU 指令直接实现。 事实上,其它同步技术的实现常常依赖于原 子操作。

13、原子操作和锁的区别

原子操作由底层硬件支持,而锁则由操作系统的调度器实现。

锁应当用来保护一段逻辑,对于一个变量更新的保护。

原子操作通常执行上会更有效率,并且更能利用计算机多核的优势,如果要更新的是一个复合对象,则应当使用 atomic.Value封装好的实现。

14、什么是 CAS

CAS 的全称为Compare And Swap,直译就是比较交换。是一条 CPU 的原子指 令,其作用是让 CPU 先进行比较两个值是否相等,然后原子地更新某个位置的 值,其实现方式是给予硬件平台的汇编指令,在 intel 的 CPU 中,使用的 cmpxchg 指令,就是说 CAS 是靠硬件实现的,从而在硬件层面提升效率。

简述过程是这样:

假设包含 3 个参数内存位置(V)、预期原值(A)和新值(B)。V 表示要更新变量的 值,E 表示预期值,N表示新值。仅当 V值等于E值时,才会将V的值设为N, 如果 V 值和E值不同,则说明已经有其他线程在做更新,则当前线程什么都不 做,最后 CAS 返回当前 V的真实值。CAS 操作时抱着乐观的态度进行的,它总 是认为自己可以成功完成操作。基于这样的原理,CAS 操作即使没有锁,也可 以发现其他线程对于当前线程的干扰。

15、sync.Pool 有什么用

对于很多需要重复分配、回收内存的地方,sync.Pool 是一个很好的选择。频 繁地分配、回收内存会给 GC 带来一定的负担,严重的时候会引起 CPU 的毛 刺。而 sync.Pool 可以将暂时将不用的对象缓存起来,待下次需要的时候直 接使用,不用再次经过内存分配,复用对象的内存,减轻 GC 的压力,提升系 统的性能。

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!