目录
1.Mutex有几种状态
未锁定(Unlocked):
锁定(Locked):
饥饿(Starvation):
2.Mutex的模式
正常模式(Normal Mode)
饥饿模式(Starvation Mode)
3.Mutex 自旋锁
4.RWMutex 实现原理
1. 双重锁机制
2. 读锁(RLock)操作
3. 写锁(Lock)操作
4. 读锁(RUnlock)和写锁(Unlock)操作
5. 饥饿保护
总结
5.RWMutex 注意事项
读写分离:
避免死锁:
避免读写锁升级:
适时释放锁:
避免过度使用写锁:
监控锁竞争:
并发安全的初始化:
并发安全的结构体嵌入:
6.sync.Cond 是什么,有什么作用,使用示例
是什么
作用
示例
7.Broadcast 和 Signal 区别
8.sync.Cond 中 Wait
基本行为
9.WaitGroup 用法
基本用法
初始化
增加计数wg.Add()
启动 Goroutine,调用 Done()
等待所有 Goroutine 完成,调用 Wait() 方法阻塞
10.WaitGroup 底层原理
结构体
noCopy
state atomic.Uint64
sema uint32
11.什么是 sync.Once
12.什么是原子操作
13.什么是CAS
14.原子操作和锁的区别
15.sync.Pool 有什么用
1.Mutex有几种状态
Go 语言中的 sync.Mutex
(互斥锁)是一种用于保护共享资源免受并发访问的同步原语。Mutex
有以下几种状态:
-
未锁定(Unlocked):
- 初始状态或刚刚解锁后的状态,表示没有任何 Goroutine 正在持有锁。
- 在此状态下,多个 Goroutine 可以同时尝试获取锁,只有一个 Goroutine 能成功获得锁并进入锁定状态。
-
锁定(Locked):
- 当一个 Goroutine 成功调用
Mutex.Lock()
方法后,Mutex
进入锁定状态。 - 在此状态下,该 Goroutine 拥有对受保护资源的独占访问权,其他尝试获取锁的 Goroutine 将被阻塞,直到锁被解锁。
- 锁定状态下的
Mutex
只能由持有锁的 Goroutine 解锁,即通过调用Mutex.Unlock()
方法。
- 当一个 Goroutine 成功调用
-
饥饿(Starvation):
- 在某些情况下,如果有多个 Goroutine 长时间争抢同一把锁,且每次都是同一个 Goroutine 获得锁,可能导致其他 Goroutine 长时间等待而得不到锁,这种情况被称为“锁饥饿”。
- Go 语言的
sync.Mutex
实现包含了一种饥饿模式(starvation mode),当检测到可能存在锁饥饿现象时,会切换到公平调度模式,优先让等待时间最长的 Goroutine 获取锁,以缓解饥饿问题。 - 饥饿模式是
Mutex
内部的一种特殊状态,对应用程序而言是透明的,但会影响锁的调度策略。
需要注意的是,上述状态是逻辑上的描述,实际的 sync.Mutex
结构体内部并不直接存储这些状态标识,而是通过特定的标志位和算法来实现这些状态的管理。例如,通过一个整型变量(通常称为 state
或类似名称)的低位来表示锁定状态,高位可能用于存储饥饿状态和其他内部信息。
总结来说,Go 语言 sync.Mutex
主要有三种状态:未锁定、锁定和饥饿。这些状态反映了互斥锁在不同时间段对共享资源的控制情况以及对并发 Goroutine 访问的管理状态。通过正确使用 Lock()
和 Unlock()
方法,可以确保在多线程环境下对共享资源的访问是有序且互斥的,避免数据竞争和同步问题。当遇到锁饥饿问题时,Go 语言的 Mutex
实现会自动切换到公平调度模式,尽可能减少长时间等待的 Goroutine 的等待时间。
2.Mutex的模式
Go 语言中的 sync.Mutex
(互斥锁)有两种模式:
-
正常模式(Normal Mode)
在正常模式下,当多个 Goroutine 同时尝试获取
Mutex
时,它们会被安排在一个隐式的等待队列中。尽管 Go 语言文档和源码注释提到Mutex
尝试遵循“先进先出”(FIFO)原则来决定哪个 Goroutine 最终获得锁,但实际上,这种“FIFO”并非严格意义上的队列顺序,而是近似的。这是因为锁的争用发生在操作系统层面,实际的调度可能受到操作系统的调度策略和硬件影响,新到来的 Goroutine 仍有可能“插队”先获得锁。因此,正常模式下的Mutex
被认为是非公平锁。正常模式适用于大多数短时、轻量级的锁竞争场景,其设计倾向于在多数情况下提供较好的性能,允许新到来的 Goroutine 快速获得锁,从而减少整体的锁等待时间。
-
饥饿模式(Starvation Mode)
当
Mutex
发现存在锁饥饿现象(即某个 Goroutine 长时间未能获得锁)时,会自动切换到饥饿模式。在饥饿模式下,Mutex
会优先让等待时间最长的 Goroutine 获得锁,以缓解饥饿问题。这种模式更接近公平锁,即等待时间最长的 Goroutine 有更高的概率先获得锁,从而减少了长时间等待的 Goroutine 被持续忽略的可能性。饥饿模式主要适用于存在长时、重度锁竞争,或者需要避免特定 Goroutine 长期等待锁的场景,有助于避免锁饥饿问题,保证等待时间较长的 Goroutine 能够获得锁,从而改善系统的响应时间和公平性。
触发条件:当一个 Goroutine 在连续多次尝试获取锁的过程中始终未能成功,并且其等待时间超过一定阈值(目前为大约 1 毫秒)时,
Mutex
会判断可能存在锁饥饿,进而切换到饥饿模式。退出条件:当一个 Goroutine 在饥饿模式下成功获取并释放锁后,
Mutex
通常会回到正常模式。这是因为一旦长期等待的 Goroutine 获得了锁,锁饥饿问题被认为暂时得到了缓解。
总结来说,Go 语言 sync.Mutex
提供了正常模式和饥饿模式两种操作模式。正常模式在一般情况下提供较好的性能,但可能造成锁饥饿;而饥饿模式则在检测到锁饥饿时介入,优先让等待时间最长的 Goroutine 获得锁,以保证公平性和响应时间。这两种模式的切换由 Mutex
内部自动管理,对开发者来说是透明的。在编写并发程序时,应合理设计锁的使用,避免过度竞争和长时间等待,以充分利用 Mutex
的正常模式。当确实需要避免锁饥饿时,Go 语言的 Mutex
实现能够自动调整到饥饿模式,确保系统的稳定性和公平性。
3.Mutex 自旋锁
在 Go 语言中,sync.Mutex
(互斥锁)的实现并不直接支持自旋(spin),即在无法获得锁时,goroutine 不会持续地循环检查锁状态,而是会主动进入休眠状态,等待被唤醒。这是与某些其他编程语言(如 C++、Java 等)中的自旋锁有所区别的地方。
Go 语言的 sync.Mutex
在遇到锁竞争时,goroutine 会经历以下过程:
-
尝试获取锁:goroutine 首先尝试通过原子操作直接获取锁。如果此时锁未被其他 goroutine 持有,goroutine 可以立即获得锁并继续执行。
-
阻塞等待:如果锁已被其他 goroutine 持有,goroutine 不会进行自旋,而是直接调用 Go 运行时的系统调用(如
runtime.futex
等)将自己置于休眠状态,并加入到锁的等待队列中。此时,goroutine 不再消耗 CPU 时间,等待被操作系统的调度器唤醒。 -
被唤醒并重新尝试获取锁:当持有锁的 goroutine 释放锁时,会唤醒等待队列中的一个或多个 goroutine。被唤醒的 goroutine 重新尝试获取锁,如果此时锁仍未被其他 goroutine 持有,goroutine 就能成功获得锁并继续执行。
综上所述,Go 语言标准库中的 sync.Mutex
不允许自旋。goroutine 在无法立即获得锁时,会立即进入休眠状态,而不是持续循环检查锁状态。这样做可以避免在锁竞争激烈时,大量 goroutine 无谓地消耗 CPU 资源,有助于提升系统的整体效率和资源利用率。
如果你需要实现类似自旋锁的功能,可以使用 Go 语言标准库中的 sync/atomic
包提供的原子操作,或者使用第三方库提供的自旋锁实现。但请注意,自旋锁在某些特定场景(如持有锁的代码执行时间非常短,且预期锁竞争不严重)下可以提高效率,但滥用自旋锁可能导致 CPU 使用率过高、能源浪费以及系统响应性下降等问题,应谨慎使用。在大多数情况下,Go 语言推荐使用其内置的同步原语(如 sync.Mutex
、sync.RWMutex
、sync.WaitGroup
等)来管理并发。
package mainimport ("runtime""sync/atomic""time"
)type SpinLock uint32// Lock 尝试获取锁,如果锁已经被持有,则会自旋等待直到锁释放
func (sl *SpinLock) Lock() {for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {runtime.Gosched() // 不要占满整个CPU,让出时间片}
}// Unlock 释放锁
func (sl *SpinLock) Unlock() {atomic.StoreUint32((*uint32)(sl), 0)
}// NewSpinLock 创建一个自旋锁
func NewSpinLock() *SpinLock {return new(SpinLock)
}func main() {lock := NewSpinLock()lock.Lock()// 临界区time.Sleep(1 * time.Second) // 模拟临界区操作lock.Unlock()
}
4.RWMutex 实现原理
sync.RWMutex
(读写互斥锁)是 Go 语言标准库提供的一个并发控制工具,它允许多个读取者同时访问受保护的资源,但同一时刻只允许一个写入者访问。RWMutex
的实现原理基于以下几点:
1. 双重锁机制
RWMutex
内部包含两个锁:一个用于写操作的互斥锁(Mutex),一个用于读操作的计数器(readerCount)。写锁(Mutex)确保同一时刻只有一个写入者,而读锁(readerCount)记录当前持有读锁的 Goroutine 数量。
2. 读锁(RLock)操作
当 Goroutine 调用 RLock()
方法获取读锁时,执行以下步骤:
-
递增读者计数:使用原子操作(如
atomic.AddInt32
)递增readerCount
,表示一个新读取者加入。 -
检查写锁状态:如果此时没有写入者持有锁(即写锁未被锁定),读取者可以直接返回,开始读取操作。
-
等待写锁释放:如果有写入者正在持有锁,读取者会被阻塞,加入到等待队列中,直到写锁被释放。
3. 写锁(Lock)操作
当 Goroutine 调用 Lock()
方法获取写锁时,执行以下步骤:
-
获取写锁:尝试锁定写锁(Mutex),如果此时没有其他写入者或读取者持有锁,写入者可以直接获得锁,开始写入操作。
-
等待读锁释放:如果有读取者正在持有锁,写入者会被阻塞,等待所有读取者释放其读锁。当最后一个读取者释放读锁时,
readerCount
会降为 0,写入者可以继续执行。 -
阻塞其他读写者:一旦写入者获得锁,所有后续的读取者和写入者都会被阻塞,直到写入者完成写入操作并释放锁。
4. 读锁(RUnlock)和写锁(Unlock)操作
-
释放读锁:调用
RUnlock()
方法时,使用原子操作递减readerCount
,表示一个读取者完成读取并释放锁。 -
释放写锁:调用
Unlock()
方法时,直接释放写锁(Mutex),同时唤醒等待队列中的下一个读取者或写入者。
5. 饥饿保护
如同 sync.Mutex
,RWMutex
实现也包含了饥饿模式的逻辑。当检测到存在长时间等待的写入者时,会切换到饥饿模式,优先让等待时间最长的写入者获得锁,防止读取者过度霸占锁而导致写入者饥饿。
总结
sync.RWMutex
的实现原理主要基于双重锁机制,通过一个互斥锁(Mutex)控制写操作的独占性,一个计数器(readerCount)管理读取者的并发访问。读取者可以共享读锁,写入者则独占写锁,同时写入者会阻塞所有读取者。读写锁的获取和释放操作均通过原子操作保证线程安全,同时包含饥饿保护机制,确保在高并发场景下既能高效利用资源,又能避免特定 Goroutine 长时间等待锁。
5.RWMutex 注意事项
使用 Go 语言中的 sync.RWMutex
(读写互斥锁)时,应注意以下事项以确保正确、高效地使用读写锁,并避免潜在的并发问题:
-
读写分离:
- 读操作:多个 Goroutine 可以同时进行读操作。当一个 Goroutine 持有读锁时,其他 Goroutine 仍可以获取读锁进行读取。读锁不会阻塞其他读锁的获取。
- 写操作:任何时候只能有一个 Goroutine 持有写锁。当一个 Goroutine 持有写锁时,其他 Goroutine(包括读操作)都无法获取任何锁,直到写锁被释放。
-
避免死锁:
- 避免循环依赖:确保 Goroutines 在获取锁时不会形成循环等待。例如,一个 Goroutine 持有读锁时不应尝试获取写锁,否则可能造成死锁。
- 合理顺序:如果多个锁需要按特定顺序获取,应遵守该顺序。例如,先获取写锁再获取读锁,或者先获取读锁再获取写锁,但不应在持有写锁时尝试获取读锁,反之亦然。
-
避免读写锁升级:
- 避免升级读锁为写锁:
sync.RWMutex
不支持锁升级,即一个 Goroutine 在持有读锁时不能直接升级为写锁。如果需要进行写操作,应先释放读锁,然后再获取写锁。
- 避免升级读锁为写锁:
-
适时释放锁:
- 在完成读写操作后立即释放锁:确保在读取或修改共享数据后及时调用
RUnlock()
或Unlock()
方法释放锁,避免长时间持有锁导致其他 Goroutines 阻塞。 - 在 defer 语句中释放锁:为了确保锁总能被正确释放,推荐在获取锁的同一作用域内使用
defer
语句安排锁的释放。
- 在完成读写操作后立即释放锁:确保在读取或修改共享数据后及时调用
-
避免过度使用写锁:
- 合理评估操作性质:如果一个操作只是读取数据,不需要修改,应使用读锁而非写锁。过度使用写锁会阻塞其他读操作,降低系统的并发性能。
- 批量操作:如果一系列操作可以视为一个原子的读或写操作,应尽量一次性获取相应类型的锁,避免在操作过程中反复切换锁类型。
-
监控锁竞争:
- 检测锁等待时间:通过性能分析工具(如
pprof
)监控锁的等待时间,如果发现等待时间过长,可能存在锁竞争问题,需要优化锁的使用或数据结构设计。
- 检测锁等待时间:通过性能分析工具(如
-
并发安全的初始化:
- 在使用前初始化:确保
sync.RWMutex
变量在任何 Goroutine 访问之前已经被正确初始化。未初始化的锁可能导致数据竞争和程序崩溃。
- 在使用前初始化:确保
-
并发安全的结构体嵌入:
- 避免嵌入锁的结构体被复制:如果一个结构体中嵌入了
sync.RWMutex
,并且该结构体被复制(如通过赋值或作为函数参数传递),复制后的结构体会带有独立的锁,可能导致数据不一致。应确保通过指针传递和操作包含锁的结构体。
- 避免嵌入锁的结构体被复制:如果一个结构体中嵌入了
总结来说,使用 sync.RWMutex
时应明确区分读写操作,避免死锁和锁升级,及时释放锁,合理使用读锁以提高并发性,并密切关注锁的竞争情况以进行性能调优。同时,要注意锁的初始化、结构体嵌入的并发安全问题。遵循这些注意事项,可以确保在多 Goroutine 环境下正确、高效地使用读写锁来保护共享资源。
6.sync.Cond 是什么,有什么作用,使用示例
是什么
sync.Cond
是 Go 语言中用于实现基于条件的同步的一种工具,它与互斥锁配合使用,使得 Goroutines 能够在满足特定条件时进行协作。通过Wait()
、Signal()
和Broadcast()
方法,Goroutines 可以在条件不满足时挂起等待,条件满足时被唤醒继续执行,有效地避免了忙等待,提高了并发环境下的资源利用率和程序性能。在实际编程中,条件变量常用于解决生产者-消费者、任务队列、事件通知等场景的同步问题。
作用
sync.Cond
的主要作用如下:
-
条件等待:当某个共享状态的条件不满足时,Goroutine 可以调用
Cond.Wait()
方法挂起自身,进入等待状态,直到其他 Goroutine 修改了共享状态,使得条件变为满足。 -
条件通知:当某个 Goroutine 修改了共享状态,使得某个等待条件变为满足时,可以调用
Cond.Signal()
或Cond.Broadcast()
方法,通知一个或所有等待该条件的 Goroutine,使它们从等待状态恢复并继续执行。
示例
下面是一个使用 sync.Cond
的简单示例,模拟了一个生产者-消费者模型,其中消费者等待队列非空时才消费数据:
package mainimport ("fmt""sync""time"
)var done = false// 读函数
func read(name string, c *sync.Cond) {//关联锁c.L.Lock()//先让读函数等待通知for !done {fmt.Printf("【%s】 来了,等在门外\n", name)c.Wait()}fmt.Printf("【%s】 入账 ====》\n", name)c.L.Unlock()
}// 写函数
func write(name string, c *sync.Cond) {c.L.Lock()fmt.Printf("【%s】入帐,作战计划制定中~~~~~ \n", name)time.Sleep(2 * time.Second)done = truec.L.Unlock()time.Sleep(2 * time.Second)fmt.Printf("======== 作战计划制定完毕 ========\n【%s】 大家进来开会吧! \n", name)c.Broadcast()
}func main() {cond := sync.NewCond(&sync.Mutex{})fmt.Println("========= 玄德公 升帐了 =============")for _, name := range []string{"关羽", "张飞", "赵云"} {go read(name, cond)}time.Sleep(time.Second * 1)go write("孔明", cond)time.Sleep(time.Second * 10)
}
7.Broadcast 和 Signal 区别
sync.Cond
的 Broadcast()
和 Signal()
方法的主要区别在于唤醒等待者数量的不同:
Broadcast()
唤醒所有等待的 Goroutines。Signal()
只唤醒一个等待的 Goroutine。
选择使用哪种方法取决于具体的应用场景和同步需求。如果需要通知所有等待者或发生了全局状态变化,使用 Broadcast()
更合适;如果只需要唤醒一个等待者或每次只有一个等待者的需求得到满足,使用 Signal()
更合适。在实际编程中,应根据实际的并发逻辑和资源管理需求,合理选择使用 Broadcast()
或 Signal()
,以确保正确的并发行为和高效的资源利用。
8.sync.Cond 中 Wait
sync.Cond
中的 Wait()
方法用于让当前 Goroutine 暂停执行并进入等待状态,直到收到 Signal()
或 Broadcast()
的通知。以下是关于 Wait()
方法的详细说明:
基本行为
-
释放锁:在调用
Wait()
之前,必须已经获得了与Cond
相关联的底层互斥锁(由创建Cond
时传入的Locker
提供)。调用Wait()
时,会自动释放底层互斥锁,使得其他 Goroutine 有机会获得锁并修改共享状态。 -
进入等待状态:释放锁后,当前 Goroutine 会进入等待状态,挂起执行,直到收到
Signal()
或Broadcast()
的通知。在此期间,当前 Goroutine 不会消耗 CPU 资源,而是等待被其他 Goroutine 唤醒。 -
重新获取锁:当收到
Signal()
或Broadcast()
的通知后,当前 Goroutine 会重新尝试获取底层互斥锁。一旦获取到锁,Wait()
调用会立即返回,使得当前 Goroutine 继续执行后续逻辑。
9.WaitGroup 用法
sync.WaitGroup
用于等待一组 Goroutine 完成其任务。使用时,首先初始化WaitGroup
,在启动每个 Goroutine 前调用Add()
增加计数,Goroutine 结束时调用Done()
通知完成,最后在需要等待所有 Goroutine 完成的地方调用Wait()
。使用过程中应注意避免计数器溢出、重复调用Done()
、确保Done()
被调用,以及避免在Wait()
后面的代码中修改共享状态。遵循这些规则,可以有效地使用sync.WaitGroup
实现 Goroutine 的同步等待。
基本用法
初始化
创建一个 sync.WaitGroup
变量。
var wg sync.WaitGroup
增加计数wg.Add()
在启动 Goroutine 之前,调用 Add()
方法增加计数,参数表示要启动的 Goroutine 数量。
wg.Add(3) // 假设要启动3个Goroutine
启动 Goroutine,调用 Done()
启动 Goroutine,执行所需任务。在 Goroutine 结束时,调用 Done()
方法通知 WaitGroup
任务已完成。
for i := 0; i < 3; i++ {go func(i int) {defer wg.Done() // 任务完成后通知WaitGroup// 执行任务代码}(i)
}
等待所有 Goroutine 完成,调用 Wait()
方法阻塞
在主线程或其他需要等待所有 Goroutine 完成的地方,调用 Wait()
方法阻塞,直到所有 Goroutine 调用 Done()
方法,计数器归零。
wg.Wait() // 等待所有Goroutine完成
后续操作:Wait()
返回后,所有 Goroutine 已经完成任务,可以进行后续操作。
10.WaitGroup 底层原理
结构体
// WaitGroup 等待一组 Goroutine 完成。
// 主 Goroutine 调用 Add 方法设置要等待的 Goroutine 数量,
// 然后每个 Goroutine 运行并在完成后调用 Done 方法。
// 同时,可以使用 Wait 方法阻塞,直到所有 Goroutine 完成。
//
// WaitGroup 在第一次使用后不能被复制。
//
// 根据 Go 内存模型的术语,Done 调用“同步于”任何它解除阻塞的 Wait 调用的返回。
type WaitGroup struct {noCopy noCopystate atomic.Uint64 // 高 32 位是计数器, 低 32 位是等待者数量(后文解释)。sema uint32
}
noCopy
首先是 noCopy,这个东西是为了告诉编译器,WaitGroup 结构体对象不可复制,即 wg2 := wg 是非法的。之所以禁止复制,是为了防止可能发生的死锁。但实际上如果我们对 WaitGroup 对象进行复制后,至少在 1.20 版本下,Go 的编译器只是发出警告,没有阻止编译过程,我们依然可以编译成功。
state atomic.Uint64
state
是WaitGroup
的核心,它是一个无符号的 64 位整型,并且用的是atomic
包中的Uint64
,所以state
本身是线程安全的。至于atomic.Uint64
为什么能保证线程安全,因为它使用了CompareAndSwap(CAS)
操作,而这个操作依赖于 CPU 提供的原子性指令,是 CPU 级的原子操作。
state
的高 32 位是计数器(counter),低 32 位是等待者数量(waiters)。其中计数器其实就是Add(int)
数量的总和,譬如Add(1)
后再Add(2)
,那么这个计数器就是 1 + 2 = 3;而等待数量就是现在有多少 goroutine 在执行Wait()
等待WaitGroup
被释放。
sema uint32
信号量
sync.WaitGroup
的底层原理基于计数器和等待队列。通过原子操作更新计数器,跟踪需要等待的 Goroutine 数量。Add()
方法增加或减少计数器,Done()
方法等同于减少计数器,Wait()
方法在计数器非零时让当前 Goroutine 进入等待状态,直到计数器归零时唤醒所有等待的 Goroutine。
参考:Golang WaitGroup 底层原理及源码解析_Golang_脚本之家