golang面经——sync相关
1.除了 mutex 以外还有那些方式可以安全读写共享变量?
分析:
考察go语言中对数据竞争的解决方案,在go语言中有锁,信号量还有channel三种方式实现,回答的时候要出现信号量以及channel关键字
- 用信号量实现互斥功能;
- 用channel实现互斥功能;
- Mutex实现互斥;
回答:
1.将共享变量的读写放到一个 goroutine 中,其它 goroutine 通过 channel 进行读写操作。
2.可以用个数为1的信号量(semaphore)实现互斥。
信号量的使用示例如下:
package mainimport ("context""fmt""golang.org/x/sync/semaphore""time"
)func main() {// 创建一个最多允许 3 个并发的信号量sem := semaphore.NewWeighted(3)// 启动 10 个任务for i := 0; i < 10; i++ {go func(id int) {// 请求获取一个执行“名额”sem.Acquire(context.Background(), 1)// 模拟任务执行fmt.Printf("Task %d is running\n", id)time.Sleep(2 * time.Second)fmt.Printf("Task %d is done\n", id)// 释放“名额”sem.Release(1)}(i)}// 等待所有任务完成time.Sleep(25 * time.Second)
}/*
Task 0 is running
Task 1 is running
Task 2 is running
...
(2秒后)
Task 0 is done
Task 3 is running
...*/
2.Go 如何实现原子操作?
分析:
主要考察对go语言中原子操作基本方法是否了解,是否使用过,在回答的时候,要突出go语言实现的原子操作在sync/atomic包下面实现,提供了store,add等方法。
回答:
原子操作是一组不可中断的指令序列,由底层硬件支持,go语言的原子操作由sync/atomic包提供。
1.19之后可以使用原子类型
// 1.19之前
package mainimport ("fmt""sync""sync/atomic"
)func main() {var counter int64 // 必须是 int64 类型var wg sync.WaitGroupfor i := 0; i < 1000; i++ {wg.Add(1)go func() {defer wg.Done()atomic.AddInt64(&counter, 1) // 原子增加}()}wg.Wait()fmt.Println("Counter:", counter) // 输出一定是 1000
}// 1.19之后
var counter atomic.Int64var wg sync.WaitGroup
for i := 0; i < 1000; i++ {wg.Add(1)go func() {defer wg.Done()counter.Add(1) // 直接使用 .Add(),无需传指针}()
}wg.Wait()
fmt.Println("Counter:", counter.Load()) // 输出 1000
3.原子操作和锁的区别
分析:
原子操作和锁都是可以用来保证线程安全,但是二者在实现原理以及使用方式上都存在着很大的区别,有哪些区别呢?要回答这个问题,可以从二者的实现方式,作用范围,还有使用场景,以及锁类型等几个方面说来分析。
回答:
原子操作由底层硬件支持,而锁是基于原子操作+信号量完成的。若实现相同的功能,前者通常会更有效率原子操作是单个指令的互斥操作;
互斥锁/读写锁是一种数据结构,可以完成临界区(多个指令)的互斥操作,扩大原子操作的范围。
原子操作是无锁操作,属于乐观锁;说起锁的时候,一般属于悲观锁。
4.Mutex 是悲观锁还是乐观锁?悲观锁、乐观锁是什么?
分析:
首先要明确什么是悲观锁,什么是乐观锁。
乐观锁:乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。然后再看mutex满足哪种性质,其实很明确,Mutex并没有在操作的时候假定别人不会修改数据,然后在真正更新的时候去比对数据是否被修改,所以go语言中的mutex是悲观锁。
回答:
乐观锁和悲观锁其实是两种锁思想,乐观锁假定别人不会修改数据,在操作数据的时候,查看一次数据,然后修改完真正生效的时候在查看一下数据有没有发生变化,如果发生变化,则认为数据被修改,有并发问题,放弃操作,否则执行操锁。
而悲观锁就是时时刻刻认为有其他操作者修改数据,每次操作数据的时候,都尝试把数据锁住,操作期间其他人不能修改数据,直至锁被释放。
Mutex是悲观锁,Go sync包提供了两种锁类型:互斥锁sync.Mutex 和 读写互斥锁sync.RWMutex,都属于悲观锁。
乐观锁通常的使用场景举例:
这是乐观锁最常见的使用场景。尤其是在使用 ORM 框架(如 GORM)时,可以通过添加 version 字段来实现。
type Product struct {ID uint `gorm:"primaryKey"`Name stringPrice float64Stock intVersion int64 `gorm:"version"` // 用于乐观锁的版本号字段
}// 并发更新商品库存
func UpdateProductStock(db *gorm.DB, id uint, delta int) error {var product Producterr := db.First(&product, id).Errorif err != nil {return err}// 修改库存product.Stock += delta// GORM 会在更新时自动检查 version 字段err = db.Save(&product).Errorif err != nil {if errors.Is(err, gorm.ErrRecordNotFound) {return fmt.Errorf("更新失败:数据已被其他协程修改")}return err}return nil
}
⚠️ 原理:
每次更新时,GORM 会生成类似 UPDATE ... WHERE id = ? AND version = ? 的 SQL。
如果 version 不匹配(说明数据已被其他人修改),则更新失败。
5.互斥锁mutex底层是怎么实现的?
分析:
首先要熟悉mutex的底层定义以及明确各个字段的含义及作用。sync.Mutex 结构体只包含一个字段:
type Mutex struct {state int32sema uint32
}
state:用于表示锁的状态,包括是否加锁、是否有等待者、是否处于饥饿模式等。
sema:信号量,用于协程排队和唤醒,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待这个信号量的协程。
state 是32位的整型变量,内部实现是把它分成了四份,用来记录 Mutex 的四种状态。
回答:
mutex底层是通过原子操作加信号量来实现的,通过atomic 包中的一些原子操作来实现锁的锁定,通过信号量来实现协程的阻塞与唤醒。
6.读写锁底层是怎么实现的?
分析:
从RWMutex的定义可以看出,读写锁里面有一个互斥锁mutex存在,所以在实现上一定会基于mutex,但是多了其余的一些字段,用于记录读锁加锁次数,当前正在读的goroutine数量,以及写阻塞时等待完成的读goroutine数量。
加锁过程:
加读锁:若锁处于空闲状态,那么直接获取读锁,若有协程持有写锁,那么无法获取读锁,当前goroutine休眠。
加写锁:获取写锁要用到mutex和readerWait,首先获取mutex,获取成功之后,若readerWait大于0,此时有goroutine占用了读锁,那么加写锁阻塞,若没有goroutine占用读锁,加写锁成功。
解锁过程:
释放读锁:直接释放读锁;若有goroutine等待加写锁,则在释放读锁之后会将readerWait减1,当readerWait减到0时就唤醒被阻塞的写操作的goroutine了。
释放写锁:修改readerCount值为正,解除互斥,然后唤醒所有的读goroutine,最后释放互斥锁mutex。
readerCount的解释:
readerWait的解释:
回答:
加读锁时,当发现readerCount为负时,证明有写锁占用,则readerCount+1休眠;如果不为负,直接readerCount +1,读锁加成功。
释放读锁时,readerCount 会 -1。
加写锁时,写协程先获取内部互斥锁(mutex),然后:
1)将 readerCount 减去一个极大值(rwmutexMaxReaders)使其变为负数,阻止新读锁进入
2)将原始的读锁数量(readerCount + rwmutexMaxReaders)赋值给 readerWait
3)如果 readerWait > 0,则阻塞等待
4)每个释放的读锁会将 readerWait--,当 readerWait 归零时唤醒写锁。
释放写锁时:
1)将 readerCount 恢复为正数(readerCount + rwmutexMaxReaders)
2)唤醒所有阻塞的读协程
3)释放内部互斥锁(mutex)
7.Mutex 有几种模式?
分析:
明白了锁的实现原理之后,通过state字段的倒数第三位Starving可以判断出锁是否处于饥饿模式,所以锁可以处于两种不同模式,正常模式和饥饿模式。在不同的模式下,所得获取方式存在差别。
在正常模式下,锁的等待者会按照先进先出的顺序获取锁。但是刚被唤起的 Goroutine 与新创建的 Goroutine 竞争时,大概率会获取不到锁,在这种情况下,这个被唤醒的 Goroutine 会加入到等待队列的前面。 如果一个等待的Goroutine 超过1ms 没有获取锁,那么它将会把锁转变为饥饿模式。在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。如果一个 Goroutine 获得了互斥锁并且它是处在队列的未尾或者它获得锁之前等待的时间少于 1ms,那么当前的互斥锁就会切换回正常模式。
回答:
两种,正常模式和饥饿模式。
正常模式:
饥饿模式:
它们是 sync.Mutex 内部根据运行时情况自动切换的。
8.在Mutex上自旋的goroutine 会占用太多资源吗?
分析:
首先要明白什么是goroutine的自旋状态,goroutine自旋是指当一个线程在获取锁的时候,如果锁已经被其他协程获取,那么该协程将循环等待,然后不断地判断是否能够被成功获取,直到获取到锁才会退出循环。从这里就可以看出goroutine的自旋状态会消耗cpu资源,导致cpu一定时间的空转。所以长时间处于自旋状态肯定是不合理的。所以自旋状态一定要满足一定的条件比如次数不能过多,锁要不能处于饥饿模式,不然其他goroutine将很难获取到锁,以及处理器的个数,比如在单核下自旋是没有意义的,因为同一只有一个线程可以运行,要获取锁只能等当前线程释放,自旋自然没有意义,所以,在回答的时候可以从以上几个方面来考虑。
回答:
goroutine自旋要满足一定的条件:
1.还没自旋超过 4 次,
2.锁已被占用,并且锁不处于饥饿模式,
3.多核处理器,
4.GOMAXPROCS >1,
5.p上本地 goroutine 队列为空。
mutex 会让当前的 goroutine 去空转 CPU,在空转完后再次调用 CAS 方法去尝试性的占有锁资源,直到不满足自旋条件,则最终会加入到等待队列里,结束自旋。综上所述,自旋条件下其实是不会占用太多资源的,首先不在饥饿模式,而且资源有一定的次数限制,超过4次就会结束自旋。
9.Mutex 已经被一个 Goroutine 获取了,其它等待中的 Goroutine 们只能一直等待。那么等这个锁释放后,等待中的 Goroutine 中哪一个会优先获取 Mutex 呢?
分析:
本题可以看作是上面第7题的补充问题,通过第7题的分析我们知道,在正常模式和饥饿模式下获取锁的策略是不同的,所以在回答的时候也要分两种情形来回答。
在饥饿模式下新加入的goroutine不会获取锁,而是会加获取锁的goroutine队列排队,所以排在最前面的goroutine会优先获取锁。
在正常模式下,则是新请求锁的goroutine更容易获取锁,为什么呢?可以联想到资源占用,新请求的goroutine正在cpu上运行,占用着cpu资源,更容易抢锁成功。
回答:
正常情况下,当一个 Goroutine 获取到锁后,其他的 Goroutine 开始进入自旋转(为了持有CPU)或者进入沉睡阻塞状态(等待信号量唤醒).但是这里存在一个问题,新请求的 Goroutine 进入自旋时是仍然拥有 CPU 的,所以比等待信号量唤醒的 Goroutine 更容易获取锁,用官方话说就是,新请求锁的 Goroutine具有优势,它正在CPU上执行,而且可能有好几个,所以刚刚唤醒的 Goroutine 有很大可能在锁竞争中失败而在饥饿模式下,新加入的goroutine不参与抢锁,会加获取锁的goroutine队列末尾排队,所以是排在最前面的goroutine会优先获取锁。
10.waitgroup 是怎样实现协程等待?
分析:
考察waitgroup 的实现原理,
state1 :64位值,高32位为计数器,就是协程组中运行着的协程的个数,低32位为等待者计数,即等待者的个数比如我们一般在主协程种执行wait()函数,那么等待者计数就为1
state2:信号量,用于协程排队和唤醒。
waitgroup 对外提供了三个方法,Add(int),Done()和Wait()。
Add:用来设置 WaitGroup 的计数值;
Done:用来将 WaitGroup 的计数值减一,其实就是调用了 Add(-1);
Wait:其实就是检查WaitGroup 的计数值,如果大于0,就阻塞等待,直到 WaitGroup 的计数值变成0,进入下一步。
主要通过这三个方法的配合使用来实现线程等待。
回答:
waitgroup 内部维护了一个计数器,当调用 wg.Add(1)方法时,就会增加对应的数量;当调用 wg.Done()时,计数器就会减一。直到计数器的数量减到 0 时,就会调用runtime Semrelease 唤起之前因为 wg.Wait() 而阻塞住的 goroutine。
11.sync.once 的原理,是怎样保证代码段只执行1次?
分析:
在 Go 语言中,sync.Once 是一个非常有用的同步原语,用于确保某个操作只执行一次,即使在多个 goroutine 并发调用时也是如此。
sync.Once 内部通过一个 done 标志位 + Mutex(或原子操作)实现:
第一次调用 .Do(func()) 时,执行传入的函数,并将 done 设为 1。
后续调用 .Do() 时,直接返回,不再执行函数。
package mainimport ("fmt""sync"
)var once sync.Oncefunc initialize() {fmt.Println("初始化资源...")// 模拟资源初始化逻辑,如数据库连接、加载配置等
}func worker(id int, wg *sync.WaitGroup) {defer wg.Done()fmt.Printf("Worker %d: 尝试初始化\n", id)once.Do(initialize)fmt.Printf("Worker %d: 初始化完成\n", id)
}func main() {var wg sync.WaitGroupfor i := 1; i <= 5; i++ {wg.Add(1)go worker(i, &wg)}wg.Wait()
}/*
Worker 3: 尝试初始化
初始化资源...
Worker 3: 初始化完成
Worker 1: 尝试初始化
Worker 1: 初始化完成
Worker 2: 尝试初始化
Worker 2: 初始化完成
Worker 4: 尝试初始化
Worker 4: 初始化完成
Worker 5: 尝试初始化
Worker 5: 初始化完成
*/
回答:
内部维护了一个标识位,当它等于0时表示还没执行过函数,此时会加锁修改标识位,然后执行对应函数。后续再执行时发现标识位不等于0,则不会再执行后续动作了。