MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go 语言映射(Map)的并发读写问题与解决方案

2024-03-136.2k 阅读

Go 语言映射(Map)的基础介绍

在 Go 语言中,映射(Map)是一种无序的键值对集合。它类似于其他语言中的字典或哈希表。通过使用键(Key)可以快速定位到对应的值(Value)。其声明和初始化方式如下:

package main

import "fmt"

func main() {
    // 声明一个空的 map
    var m1 map[string]int
    // 初始化 map
    m1 = make(map[string]int)
    m1["one"] = 1

    // 简短声明并初始化
    m2 := map[string]int{"two": 2}

    fmt.Println(m1["one"])
    fmt.Println(m2["two"])
}

在上述代码中,首先声明了一个空的 map m1,类型为 map[string]int,即键为字符串类型,值为整数类型。然后使用 make 函数对其进行初始化,之后可以向其中添加键值对。m2 则是通过简短声明并初始化的方式创建了一个 map

Go 语言并发编程基础

Go 语言以其出色的并发编程支持而闻名。通过 goroutine 可以轻松创建轻量级的并发执行单元。例如:

package main

import (
    "fmt"
    "time"
)

func printNumbers() {
    for i := 1; i <= 5; i++ {
        fmt.Println("Number:", i)
        time.Sleep(time.Millisecond * 500)
    }
}

func printLetters() {
    for i := 'a'; i <= 'e'; i++ {
        fmt.Printf("Letter: %c\n", i)
        time.Sleep(time.Millisecond * 500)
    }
}

func main() {
    go printNumbers()
    go printLetters()

    time.Sleep(time.Second * 3)
}

在这个例子中,通过 go 关键字启动了两个 goroutine,分别执行 printNumbersprintLetters 函数。这两个函数会并发执行,最后通过 time.Sleep 让主 goroutine 等待一段时间,确保其他 goroutine 有足够的时间执行。

Go 语言映射(Map)并发读写问题的产生

当在多个 goroutine 中同时对一个 map 进行读写操作时,就会出现问题。例如:

package main

import (
    "fmt"
)

var m = make(map[string]int)

func write(key string, value int) {
    m[key] = value
}

func read(key string) int {
    return m[key]
}

func main() {
    for i := 0; i < 10; i++ {
        go write(fmt.Sprintf("key%d", i), i)
    }

    for i := 0; i < 10; i++ {
        go func(n int) {
            value := read(fmt.Sprintf("key%d", n))
            fmt.Printf("Read key%d: %d\n", n, value)
        }(i)
    }

    // 简单等待,实际应用中不应这样处理
    select {}
}

在上述代码中,启动了 10 个 goroutine 进行写操作,同时又启动 10 个 goroutine 进行读操作。运行这段代码,可能会得到类似如下的错误:

fatal error: concurrent map read and map write

这是因为 Go 语言中的 map 本身不是线程安全的,当多个 goroutine 同时对其进行读写操作时,就会导致数据竞争,进而引发程序崩溃。

解决方案一:使用互斥锁(Mutex)

互斥锁(Mutex)是一种常用的同步工具,用于保护共享资源,确保在同一时间只有一个 goroutine 能够访问共享资源。在处理 map 的并发读写问题时,可以使用互斥锁来保护 map

package main

import (
    "fmt"
    "sync"
)

var (
    m    = make(map[string]int)
    mutex sync.Mutex
)

func write(key string, value int) {
    mutex.Lock()
    m[key] = value
    mutex.Unlock()
}

func read(key string) int {
    mutex.Lock()
    value := m[key]
    mutex.Unlock()
    return value
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            write(fmt.Sprintf("key%d", n), n)
        }(i)
    }

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            value := read(fmt.Sprintf("key%d", n))
            fmt.Printf("Read key%d: %d\n", n, value)
        }(i)
    }

    wg.Wait()
}

在这个改进的代码中,定义了一个 sync.Mutex 类型的变量 mutex。在 writeread 函数中,分别在对 map 进行操作前调用 mutex.Lock() 锁定互斥锁,操作完成后调用 mutex.Unlock() 解锁互斥锁。这样就保证了在同一时间只有一个 goroutine 能够对 map 进行读写操作,从而避免了数据竞争问题。

解决方案二:使用读写锁(RWMutex)

读写锁(RWMutex)是一种特殊的互斥锁,它允许在同一时间有多个读操作同时进行,但只允许一个写操作进行。当有写操作进行时,所有的读操作和其他写操作都会被阻塞。

package main

import (
    "fmt"
    "sync"
)

var (
    m    = make(map[string]int)
    rwmu sync.RWMutex
)

func write(key string, value int) {
    rwmu.Lock()
    m[key] = value
    rwmu.Unlock()
}

func read(key string) int {
    rwmu.RLock()
    value := m[key]
    rwmu.RUnlock()
    return value
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            write(fmt.Sprintf("key%d", n), n)
        }(i)
    }

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            value := read(fmt.Sprintf("key%d", n))
            fmt.Printf("Read key%d: %d\n", n, value)
        }(i)
    }

    wg.Wait()
}

在上述代码中,使用 sync.RWMutex 类型的变量 rwmu。写操作时调用 rwmu.Lock() 方法,读操作时调用 rwmu.RLock() 方法。这样,在大量读操作和少量写操作的场景下,使用读写锁可以提高程序的并发性能,因为多个读操作可以同时进行,而不会像互斥锁那样每次读操作都需要等待锁的释放。

解决方案三:使用 sync.Map

Go 1.9 引入了 sync.Map,它是一个线程安全的 map 实现。sync.Map 适用于高并发的读写场景,并且不需要用户手动加锁。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var m sync.Map

    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            m.Store(fmt.Sprintf("key%d", n), n)
        }(i)
    }

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            value, ok := m.Load(fmt.Sprintf("key%d", n))
            if ok {
                fmt.Printf("Read key%d: %d\n", n, value)
            }
        }(i)
    }

    wg.Wait()
}

在这个示例中,使用 sync.MapStore 方法进行写操作,Load 方法进行读操作。sync.Map 内部实现了锁机制,确保并发读写的安全性。Load 方法返回两个值,第一个是值本身,第二个是一个布尔值,表示键是否存在。

sync.Map 与传统 Map 加锁方案的性能对比

为了更直观地了解 sync.Map 和传统 map 加锁方案的性能差异,我们可以编写性能测试代码。

package main

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

// 传统 map 加互斥锁
var (
    m1    = make(map[string]int)
    mutex sync.Mutex
)

func write1(key string, value int) {
    mutex.Lock()
    m1[key] = value
    mutex.Unlock()
}

func read1(key string) int {
    mutex.Lock()
    value := m1[key]
    mutex.Unlock()
    return value
}

// sync.Map
var m2 sync.Map

func write2(key string, value int) {
    m2.Store(key, value)
}

func read2(key string) int {
    value, ok := m2.Load(key)
    if ok {
        return value.(int)
    }
    return 0
}

func main() {
    var wg sync.WaitGroup

    start := time.Now()
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            write1(fmt.Sprintf("key%d", n), n)
        }(i)
    }
    wg.Wait()
    elapsed1 := time.Since(start)

    start = time.Now()
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            write2(fmt.Sprintf("key%d", n), n)
        }(i)
    }
    wg.Wait()
    elapsed2 := time.Since(start)

    fmt.Printf("传统 map 加互斥锁写入时间: %v\n", elapsed1)
    fmt.Printf("sync.Map 写入时间: %v\n", elapsed2)

    // 读操作性能测试
    start = time.Now()
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            read1(fmt.Sprintf("key%d", n))
        }(i)
    }
    wg.Wait()
    elapsed3 := time.Since(start)

    start = time.Now()
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            read2(fmt.Sprintf("key%d", n))
        }(i)
    }
    wg.Wait()
    elapsed4 := time.Since(start)

    fmt.Printf("传统 map 加互斥锁读取时间: %v\n", elapsed3)
    fmt.Printf("sync.Map 读取时间: %v\n", elapsed4)
}

通过上述性能测试代码可以发现,在高并发的读写场景下,sync.Map 的性能通常会优于传统 map 加互斥锁的方案。这是因为 sync.Map 内部采用了更优化的锁机制,减少了锁的竞争。

实际应用场景分析

  1. 缓存场景:在实现一个缓存系统时,可能会有多个 goroutine 同时读取缓存数据,也可能有 goroutine 定期更新缓存。如果使用普通的 map,就需要使用互斥锁或读写锁来保证并发安全。而使用 sync.Map 则可以简化代码,并且在高并发场景下有更好的性能表现。
  2. 计数器场景:在统计一些事件发生的次数时,可能会有多个 goroutine 同时对计数器进行增加操作。如果使用普通 map 来存储计数器,就需要加锁保护。而 sync.Map 可以直接满足并发安全的需求。

注意事项

  1. sync.Map 的局限性sync.Map 不支持遍历操作。如果需要遍历 sync.Map 中的所有键值对,需要通过 Range 方法,并且 Range 方法在遍历过程中不保证顺序。
  2. 性能调优:在选择使用互斥锁、读写锁还是 sync.Map 时,需要根据实际的读写比例和并发量来进行性能测试和调优。不同的场景下,不同的方案可能会有不同的性能表现。
  3. 锁的粒度:在使用互斥锁或读写锁时,要注意锁的粒度。尽量缩小锁保护的代码块,以减少锁的竞争时间,提高并发性能。

通过以上对 Go 语言映射(Map)并发读写问题的分析和解决方案的介绍,希望能帮助开发者在实际项目中更好地处理 map 的并发操作,编写出更加健壮和高效的并发程序。无论是选择传统的锁机制还是使用 sync.Map,都需要根据具体的业务场景和性能需求进行权衡和选择。