MST

星途 面试题库

面试题:Go语言中Mutex锁与读写锁在复杂业务场景的选择与优化

在一个Go语言开发的系统中,有一个业务场景:多个协程会频繁读取一个复杂的结构体数据(包含多个字段,部分字段之间存在逻辑关联),同时偶尔会有协程对这个结构体进行更新操作。请分析使用Mutex锁和读写锁(sync.RWMutex)在这种场景下的优缺点,并阐述如何选择合适的锁机制以及可能的优化方案。若选择读写锁,给出具体的代码实现,包括读操作、写操作以及如何处理读写锁嵌套等问题。
25.0万 热度难度
编程语言Go

知识考点

AI 面试

面试题答案

一键面试

Mutex锁

  • 优点
    • 实现简单,使用方便,能有效防止数据竞争。对于任何类型的操作(读或写),都只需要加锁和解锁即可,代码逻辑相对清晰。
  • 缺点
    • 性能问题,因为Mutex锁是互斥的,同一时间只允许一个协程访问临界区。即使只有读操作,也会被锁限制,导致在高并发读场景下,性能较低。例如,多个协程同时需要读取结构体数据,但由于Mutex锁,它们只能一个一个进行读取,造成不必要的等待。

读写锁(sync.RWMutex)

  • 优点
    • 适合读多写少的场景。读操作可以并发执行,只要没有写操作在进行,多个读协程可以同时获取读锁并访问临界区,大大提高了读操作的并发性能。比如在上述场景中,频繁的读操作可以同时进行,提高系统整体性能。
  • 缺点
    • 实现相对复杂,需要注意读写锁的嵌套使用等问题。写操作时会独占锁,若写操作频繁,可能导致读操作长时间等待。例如,当一个写操作获取写锁后,所有读操作和其他写操作都要等待该写操作完成释放锁。

锁机制的选择

由于是多个协程频繁读取,偶尔更新操作,即读多写少的场景,读写锁(sync.RWMutex)更合适。这样能充分利用读操作的并发优势,提高系统性能。

优化方案

  1. 减少锁的粒度:如果结构体中有部分字段是相互独立的,可以将结构体拆分成多个小的结构体,对不同的小结构体使用不同的锁,这样可以提高并发度。例如,对于结构体中相互独立的字段A和字段B,分别使用不同的读写锁。
  2. 延迟写操作:可以将写操作缓存起来,批量执行,减少写锁的获取次数。比如,使用一个队列来存储写操作,达到一定数量或一定时间间隔后,一次性获取写锁执行所有写操作。

读写锁代码实现

package main

import (
    "fmt"
    "sync"
)

// 定义复杂结构体
type ComplexStruct struct {
    Field1 int
    Field2 string
    // 其他字段...
    sync.RWMutex
}

// 读操作
func (cs *ComplexStruct) Read() {
    cs.RLock()
    defer cs.RUnlock()
    // 读取结构体字段
    fmt.Printf("Read: Field1 = %d, Field2 = %s\n", cs.Field1, cs.Field2)
}

// 写操作
func (cs *ComplexStruct) Write(field1 int, field2 string) {
    cs.Lock()
    defer cs.Unlock()
    // 更新结构体字段
    cs.Field1 = field1
    cs.Field2 = field2
    fmt.Printf("Write: Field1 = %d, Field2 = %s\n", cs.Field1, cs.Field2)
}

func main() {
    var wg sync.WaitGroup
    cs := ComplexStruct{Field1: 1, Field2: "initial"}

    // 模拟多个读操作
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            cs.Read()
        }()
    }

    // 模拟写操作
    wg.Add(1)
    go func() {
        defer wg.Done()
        cs.Write(2, "updated")
    }()

    wg.Wait()
}

处理读写锁嵌套问题

  1. 避免不必要的嵌套:尽量设计代码逻辑,避免出现锁的嵌套。例如,将需要嵌套锁的操作拆分,在不同的函数或方法中完成,在调用外层函数前先获取外层锁,调用内层函数时获取内层锁,这样锁的层次更清晰。
  2. 遵循固定顺序:如果必须嵌套锁,要遵循固定的获取锁顺序。比如先获取读锁,再获取写锁(在允许的情况下),释放锁时按相反顺序。以代码为例,在已经获取读锁的函数中,如果需要更新数据,先释放读锁,获取写锁进行更新,然后再获取读锁继续后续操作。但要注意,这种情况可能会导致死锁,所以要仔细考虑业务逻辑和锁的释放时机。例如,在两个函数中分别获取不同顺序的锁可能会导致死锁,如下:
package main

import (
    "fmt"
    "sync"
)

var (
    mu1 sync.RWMutex
    mu2 sync.RWMutex
)

func func1() {
    mu1.RLock()
    defer mu1.RUnlock()
    mu2.Lock()
    defer mu2.Unlock()
    fmt.Println("func1")
}

func func2() {
    mu2.RLock()
    defer mu2.RUnlock()
    mu1.Lock()
    defer mu1.Unlock()
    fmt.Println("func2")
}

在这种情况下,两个函数获取锁的顺序不同,可能导致死锁。所以要确保所有涉及锁嵌套的地方,获取锁的顺序是一致的。