跳过正文
  1. 面试题库/

08|Sync 相关

·4776 字·10 分钟
目录
Golang面试题库 - 这篇文章属于一个选集。
§ 8: 本文

1. 除了 mutex 以外还有哪些方式安全读写共享变量?
#

分析

考察 Go 语言中对数据竞争的解决方案,在 Go 语言中有锁、信号量还有 channel 三种方式实现,回答的时候要出现信号量以及 channel 关键字。

1. 用信号量实现互斥功能
#

package main

import (
    "context"
    "fmt"
    "sync"
    "time"

    "golang.org/x/sync/semaphore"
)

var num int

func doAdd() {
    num++
    fmt.Printf("num is %d\n", num)
    time.Sleep(50 * time.Millisecond)
}

const (
    Limit  = 1 // 同时并行运行的 goroutine 上限,要实现互斥的功能,这里要设置为 1
    Weight = 1 // 每个 goroutine 获取信号量资源的权重
)

func main() {
    s := semaphore.NewWeighted(Limit)
    var w sync.WaitGroup
    w.Add(10)

    for i := 0; i < 10; i++ {
        go func() {
            s.Acquire(context.Background(), Weight) // 获取信号量
            doAdd()
            s.Release(Weight) // 释放信号量
            w.Done()
        }()
    }
    w.Wait()

    fmt.Printf("num=%d\n", num)
}

程序输出:

num is 1
num is 2
num is 3
num is 4
num is 5
num is 6
num is 7
num is 8
num is 9
num is 10
num=10

2. 用 channel 实现互斥功能
#

package main

import (
    "fmt"
    "sync"
    "time"
)

var num int

func doAdd() {
    num++
    fmt.Printf("num is %d\n", num)
    time.Sleep(50 * time.Millisecond)
}

func main() {
    chanLock := make(chan struct{}, 1) // 实现互斥,channel 的容量要设置为 1
    var wg sync.WaitGroup             // WaitGroup 来保证子 goroutine 完成任务之前,主协程不会退出
    wg.Add(10)

    for i := 0; i < 10; i++ {
        go func() {
            chanLock <- struct{}{}
            doAdd()
            <-chanLock
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Printf("num=%d\n", num)
}

程序输出:

num is 1
num is 2
num is 3
num is 4
num is 5
num is 6
num is 7
num is 8
num is 9
num is 10
num=10

3. mutex 实现互斥
#

package main

import (
    "fmt"
    "sync"
    "time"
)

var num int

func doAdd() { // 模拟抓取任务的执行
    num++
    fmt.Printf("num is %d\n", num)
    time.Sleep(50 * time.Millisecond)
}

func main() {
    mu := sync.Mutex{}
    var wg sync.WaitGroup // WaitGroup 来保证子 goroutine 完成任务之前,主协程不会退出。
    wg.Add(10)

    // lock.Lock() 互斥锁的实现方式

    for i := 0; i < 10; i++ {
        go func() {
            mu.Lock()
            doAdd()
            mu.Unlock()
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Printf("num=%d\n", num)
}

程序输出:

num is 1
num is 2
num is 3
num is 4
num is 5
num is 6
num is 7
num is 8
num is 9
num is 10
num=10

回答

  • 将共享变量的读写放到一个 goroutine 中,其它 goroutine 通过 channel 进行读写操作。
  • 可以用个数为 1 的信号量(semaphore)实现互斥。

2. Go 如何实现原子操作?
#

分析

主要考察对 Go 语言中原子操作基本方法是否了解,是否使用过。在回答的时候,要突出 Go 语言实现的原子操作在 sync/atomic 包下面实现,提供了 StoreAdd 等方法。

回答

原子操作是一组不可中断的指令序列,由底层硬件支持。Go 语言的原子操作由 sync/atomic 包提供,主要提供下面的一些方法:

func AddT(addr *T, delta T) (new T)
func StoreT(addr *T, val T)
func LoadT(addr *T) (val T)
func SwapT(addr *T, new T) (old T)
func CompareAndSwapT(addr *T, old, new T) (swapped bool)

T 的类型是 int32int64uint32uint64uintptr 中的任意一种。

3. 原子操作和锁的区别
#

分析

原子操作和锁都可以用来保证线程安全,但是二者在实现原理以及使用方式上都存在着很大的区别。有哪些区别呢?要回答这个问题,可以从二者的实现方式、作用范围、使用场景,以及锁类型等几个方面说来分析。

回答

原子操作由底层硬件支持,而锁是基于原子操作和信号量完成的。若实现相同的功能,前者通常会更有效率。

原子操作是单个指令的互斥操作;互斥锁/读写锁是一种数据结构,可以完成临界区(多个指令)的互斥操作,扩大原子操作的范围。

原子操作是无锁操作,属于乐观锁;说起锁的时候,一般属于悲观锁。

4. Mutex 是悲观锁还是乐观锁?悲观锁、乐观锁是什么?
#

分析

首先要明确什么是悲观锁,什么是乐观锁。

  • 乐观锁:乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
  • 悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。

然后再看 mutex 满足哪种性质,其实很明确,Mutex 并没有在操作的时候假定别人不会修改数据,然后在真正更新的时候去比对数据是否被修改,所以 Go 语言中的 mutex 是悲观锁。

回答

乐观锁和悲观锁其实是两种锁思想。乐观锁假定别人不会修改数据,在操作数据的时候查看一次数据,然后修改完真正生效的时候再查看一下数据有没有发生变化,如果发生变化,则认为数据被修改,有并发问题,放弃操作,否则执行操作。而悲观锁就是时时刻刻认为有其他操作者修改数据,每次操作数据的时候,都尝试把数据锁住,操作期间其他人不能修改数据,直至锁被释放。

Mutex 是悲观锁,Go sync 包提供了两种锁类型:互斥锁 sync.Mutex 和读写互斥锁 sync.RWMutex,都属于悲观锁。

5. 互斥锁 mutex 底层是怎么实现的?
#

分析

首先要熟悉 mutex 的底层定义:

type Mutex struct {
    state int32  // int32 类型,表示锁状态,例如是否被锁定
    sema  uint32 // 信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程
}

以及明确各个字段的含义及作用。

state 是 32 位的整型变量,内部实现是把它分成了四份,用来记录 Mutex 的四种状态。Mutex 的内部布局如下:

alt text

字段位数说明
Waiter29 bit表示阻塞等待锁的协程个数,协程解锁时根据此值来判断是否需要释放信号量。
Starving1 bit表示该 Mutex 是否处于饥饿状态,0:没有饥饿;1:饥饿状态,说明有协程阻塞超过 1ms
Woken1 bit表示是否有协程已被唤醒,0:没有协程唤醒;1:已有协程唤醒,正在加锁过程中。
Locked1 bit表示该 Mutex 是否已被锁定,0:没有锁定;1:已被锁定。

sema 是一个 uint32 类型的整数,用于协程排队和唤醒。协程阻塞等待该信号量,解锁的协程释放信号量,从而唤醒等待这个信号量的协程。

回答

mutex 底层是通过原子操作加信号量来实现的,通过 atomic 包中的一些原子操作来实现锁的锁定,通过信号量来实现协程的阻塞与唤醒。

6. Mutex 有几种模式?
#

分析

这个题目可以看作是上一个问题的补充问题。在明白了锁的实现原理之后,通过 state 字段的倒数第三位 Starving 可以判断出锁是否处于饥饿模式,所以锁可以处于两种不同模式:正常模式和饥饿模式。在不同的模式下,锁的获取方式存在差别。

在正常模式下,锁的等待者会按照先进先出的顺序获取锁。但是刚被唤起的 Goroutine 与新创建的 Goroutine 竞争时,大概率会获取不到锁。在这种情况下,这个被唤醒的 Goroutine 会加入到等待队列的前面。如果一个等待的 Goroutine 超过 1ms 没有获取锁,那么它将会把锁转变为饥饿模式。

在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁,也不会进入自旋状态,它们只会在队列的末尾等待。如果一个 Goroutine 获得了互斥锁并且它是在队列的末尾,或者它获得锁之前等待的时间少于 1ms,那么当前的互斥锁就会切换回正常模式。

回答

两种:正常模式和饥饿模式。

模式描述公平性
正常模式正常模式下所有的 goroutine 按照 FIFO 的顺序进行锁获取,被唤醒的 goroutine 和新请求锁的 goroutine 同时进行锁获取,通常新请求锁的 goroutine 更容易获取锁。
饥饿模式饥饿模式下所有尝试获取锁的 goroutine 进行等待排队,新请求锁的 goroutine 不会进行锁获取,而是加入队列尾部等待获取锁。

7. 在 Mutex 上自旋的 goroutine 会占用太多资源吗?
#

分析

首先要明白什么是 goroutine 的自旋状态。goroutine 自旋是指当一个线程在获取锁的时候,如果锁已经被其他协程获取,那么该协程将循环等待,然后不断地判断是否能够被成功获取,直到获取到锁才会退出循环。

从这里就可以看出 goroutine 的自旋状态会消耗 CPU 资源,导致 CPU 一定时间的空转。所以长时间处于自旋状态肯定是不合理的,所以自旋状态一定要满足一定的条件。

比如次数不能过多,锁不能处于饥饿模式,不然其他 goroutine 将很难获取到锁,以及处理器的个数。比如在单核下自旋是没有意义的,因为同一时间只有一个线程可以运行,要获取锁只能等当前线程释放,自旋自然没有意义。所以,在回答的时候可以从以上几个方面来考虑。

回答

goroutine 自旋要满足一定的条件:

  1. 还没自旋超过 4 次。
  2. 锁已被占用,并且锁不处于饥饿模式。
  3. 多核处理器。
  4. GOMAXPROCS > 1
  5. P 上本地 goroutine 队列为空。

mutex 会让当前的 goroutine 去空转 CPU,在空转完后再次调用 CAS 方法去尝试性地占有锁资源,直到不满足自旋条件,则最终会加入到等待队列里,结束自旋。综上所述,自旋条件下其实是不会占用太多资源的,首先不在饥饿模式,而且资源有一定的次数限制,超过 4 次就会结束自旋。

8. 读写锁底层是怎么实现的
#

分析

首先回顾一下读写锁的底层定义:

type RWMutex struct {
    w           Mutex  // 复用互斥锁
    writerSem   uint32 // 信号量,用于写等待读
    readerSem   uint32 // 信号量,用于读等待写
    readerCount int32  // 当前正在执行的读操作 goroutine 数量
    readerWait  int32  // 写协程等待完成的读协程的数量
}

RWMutex 的定义可以看出,读写锁里面有一个互斥锁 mutex 存在,所以在实现上一定会基于 mutex,但是多了其余的一些字段,用于记录读锁加锁次数、当前正在读的 goroutine 数量,以及写阻塞时等待完成的读 goroutine 数量。

加锁过程:

  • 加读锁:若锁处于空闲状态,那么直接获取读锁;若有协程持有写锁,那么无法获取读锁,当前 goroutine 休眠。
  • 加写锁:获取写锁要用到 mutexreaderWait。首先获取 mutex,获取成功之后,若 readerWait 大于 0,此时有 goroutine 占用了读锁,那么加写锁阻塞;若没有 goroutine 占用读锁,加写锁成功。

解锁过程:

  • 释放读锁:直接释放读锁;若有 goroutine 等待加写锁,则在释放读锁之后会将 readerWait 减 1,当 readerWait 减到 0 时就唤醒阻塞的写操作 goroutine。
  • 释放写锁:修改 readerCount 值为正,解除互斥,然后唤醒所有的读 goroutine,最后释放互斥锁 mutex

回答

读写锁的底层是基于互斥锁实现的,这个互斥锁被读写共享,但是通过 readerCountreaderWait 进行控制。readerWait 大于 0 会阻塞加写锁,当 readerCount 为负的时候,锁处于互斥状态。

  • 写锁需要阻塞写锁:一个协程拥有写锁时,其他协程写锁定需要阻塞。
  • 写锁需要阻塞读锁:一个协程拥有写锁时,其他协程读锁定需要阻塞。
  • 读锁需要阻塞写锁:一个协程拥有读锁时,其他协程写锁定需要阻塞。
  • 读锁不能阻塞读锁:一个协程拥有读锁时,其他协程也可以拥有读锁。

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 的实现原理,首先看一下 waitgroup 的结构定义:

type WaitGroup struct {
    noCopy noCopy

    // 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
    // 64-bit atomic operations require 64-bit alignment, but 32-bit
    // compilers only guarantee that 64-bit fields are 32-bit aligned.
    // For this reason on 32 bit architectures we need to check in state()
    // if state1 is aligned or not, and dynamically "swap" the field order if
    // needed.
    state1 uint64
    state2 uint32
}

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 次?
#

分析

可以直接查看 sync.Once 的源码,了解其实现原理。sync.Once 的源码很简单,代码会很精简:

type Once struct {
    done uint32 // 标识位
    m    Mutex
}

func (o *Once) Do(f func()) {
    // 原子加载标识值,判断是否已经被执行过
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    // 还没执行过函数
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1) // 原子操作:修改标识值
        f() // 执行函数
    }
}

可以看到,sync.Once 主要是通过一个标识位来判断逻辑是否已经执行过。

回答

内部维护了一个标识位,当它 == 0 时表示还没执行过函数,此时会加锁修改标识位,然后执行对应函数。后续再执行时发现标识位 != 0,则不会再执行后续动作了。

Golang面试题库 - 这篇文章属于一个选集。
§ 8: 本文