MST

星途 面试题库

面试题:Go切片在并发场景下的使用及数据竞争问题

在Go语言的并发编程场景中,有多个goroutine需要同时读写一个切片。请设计一个方案来避免数据竞争问题,编写相关代码,并阐述你所采用方案的原理以及其在性能和可扩展性方面的考量。同时说明如果不使用锁机制,是否还有其他方法来解决该问题。
20.1万 热度难度
编程语言Go

知识考点

AI 面试

面试题答案

一键面试

方案设计与代码实现

可以使用sync.RWMutex来解决这个问题。sync.RWMutex允许有多个读操作并发执行,但写操作必须是独占的。

package main

import (
    "fmt"
    "sync"
)

var (
    data  []int
    mu    sync.RWMutex
    wg    sync.WaitGroup
)

func reader(id int) {
    defer wg.Done()
    mu.RLock()
    fmt.Printf("Reader %d reading: %v\n", id, data)
    mu.RUnlock()
}

func writer(id int, value int) {
    defer wg.Done()
    mu.Lock()
    data = append(data, value)
    fmt.Printf("Writer %d wrote: %d\n", id, value)
    mu.Unlock()
}

func main() {
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go writer(i, i*10)
    }

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go reader(i)
    }

    wg.Wait()
}

方案原理

  1. 读锁(RLock):多个读操作可以同时获取读锁,这意味着多个goroutine可以同时读取切片,因为读操作本身不会修改数据,所以不会产生数据竞争。
  2. 写锁(Lock):写操作必须获取写锁,写锁是独占的。当一个goroutine获取了写锁,其他goroutine无论是读还是写操作,都必须等待写锁释放。这样就保证了写操作的原子性,避免了数据竞争。

性能和可扩展性考量

  1. 性能
    • 读性能:由于多个读操作可以并发执行,在高读低写的场景下,性能表现良好。读锁的获取和释放开销相对较小。
    • 写性能:写操作是独占的,在高写场景下,可能会导致其他goroutine长时间等待,从而影响整体性能。
  2. 可扩展性
    • 读扩展性:随着读goroutine数量的增加,系统的读处理能力可以相应提升,因为读操作可以并发执行。
    • 写扩展性:写操作的独占性限制了写操作的并发度,在高并发写场景下,可扩展性较差。

不使用锁机制的其他方法

  1. 通道(Channel)
    • 原理:通过通道在goroutine之间传递数据,而不是直接读写共享切片。一个goroutine负责接收来自其他goroutine的数据,并写入切片,其他goroutine只负责向通道发送数据。这样可以避免数据竞争,因为所有的写操作都由一个goroutine统一处理。
    • 示例代码
package main

import (
    "fmt"
    "sync"
)

var (
    data  []int
    ch    = make(chan int)
    wg    sync.WaitGroup
)

func writer() {
    defer wg.Done()
    for value := range ch {
        data = append(data, value)
        fmt.Printf("Writer wrote: %d\n", value)
    }
}

func main() {
    wg.Add(1)
    go writer()

    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            ch <- id * 10
        }(i)
    }

    close(ch)
    wg.Wait()
    fmt.Println("Final data:", data)
}
  • 优点:避免了锁带来的开销,在某些场景下性能更好,并且更符合Go语言的“不要通过共享内存来通信,而要通过通信来共享内存”的理念。
  • 缺点:增加了代码的复杂性,特别是在需要处理复杂的读写逻辑时。同时,通道的缓冲管理也需要额外注意。
  1. 不可变数据结构
    • 原理:每次写操作都创建一个新的切片,而不是修改原来的切片。读操作始终读取旧的切片,直到新的切片创建完成并替换旧的切片。这种方式避免了数据竞争,因为读操作不会受到写操作的影响。
    • 示例代码
package main

import (
    "fmt"
    "sync"
)

var (
    currentData []int
    wg          sync.WaitGroup
)

func writer(id int, value int) {
    defer wg.Done()
    newData := append([]int{}, currentData...)
    newData = append(newData, value)
    currentData = newData
    fmt.Printf("Writer %d wrote: %d\n", id, value)
}

func reader(id int) {
    defer wg.Done()
    fmt.Printf("Reader %d reading: %v\n", id, currentData)
}

func main() {
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go writer(i, i*10)
    }

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go reader(i)
    }

    wg.Wait()
}
  • 优点:不需要锁机制,简单直观。
  • 缺点:每次写操作都创建新的切片,可能会导致内存开销较大,特别是在数据量较大或者写操作频繁的场景下。