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 对象可以等待一组协程结束。使用方法是:
main 协程通过调用 wg.Add(delta int) 设置 worker 协程的个数,然后创 建 worker 协程;
worker 协程执行结束以后,都要调用 wg.Done();
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 协议》,转载必须注明作者和本文链接