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

Go map的并发安全

2022-12-072.1k 阅读

Go map的并发安全基础概念

在Go语言中,map是一种无序的键值对集合,它在编程中被广泛用于存储和检索数据。然而,Go的map默认情况下不是并发安全的。这意味着当多个goroutine同时对map进行读写操作时,可能会导致数据竞争(data race),进而引发未定义行为,程序可能会崩溃或者产生难以调试的错误。

数据竞争指的是多个goroutine并发访问共享资源,并且至少有一个是写操作,且没有适当的同步机制。在Go语言中,当编译器或运行时检测到数据竞争时,会抛出fatal error: concurrent map read and map writefatal error: concurrent map writes这样的错误信息。

非并发安全的map操作示例

package main

import (
    "fmt"
)

func main() {
    m := make(map[string]int)
    go func() {
        m["key1"] = 1
    }()
    go func() {
        value := m["key1"]
        fmt.Println("Value:", value)
    }()
    select {}
}

在上述代码中,我们启动了两个goroutine,一个向map中写入数据,另一个从map中读取数据。运行这段代码,你很可能会看到类似fatal error: concurrent map read and map write的错误信息。这是因为在没有同步机制的情况下,多个goroutine同时对map进行读写操作,触发了数据竞争。

并发安全的实现方式

使用互斥锁(Mutex)

互斥锁是一种常用的同步机制,通过在对map进行读写操作前加锁,操作完成后解锁,来保证同一时间只有一个goroutine能够访问map,从而避免数据竞争。

package main

import (
    "fmt"
    "sync"
)

type SafeMap struct {
    sync.Mutex
    data map[string]int
}

func (sm *SafeMap) Set(key string, value int) {
    sm.Lock()
    defer sm.Unlock()
    if sm.data == nil {
        sm.data = make(map[string]int)
    }
    sm.data[key] = value
}

func (sm *SafeMap) Get(key string) (int, bool) {
    sm.Lock()
    defer sm.Unlock()
    if sm.data == nil {
        return 0, false
    }
    value, exists := sm.data[key]
    return value, exists
}

func main() {
    var wg sync.WaitGroup
    safeMap := SafeMap{}
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(index int) {
            defer wg.Done()
            key := fmt.Sprintf("key%d", index)
            safeMap.Set(key, index)
            value, exists := safeMap.Get(key)
            if exists {
                fmt.Printf("Key: %s, Value: %d\n", key, value)
            }
        }(i)
    }
    wg.Wait()
}

在上述代码中,我们定义了一个SafeMap结构体,它包含一个互斥锁Mutex和一个mapSetGet方法分别用于设置和获取map中的值,在方法内部通过加锁和解锁操作来保证并发安全。在main函数中,我们启动了10个goroutine对SafeMap进行读写操作,由于使用了互斥锁,不会再出现数据竞争的问题。

使用读写锁(RWMutex)

读写锁适用于读操作远多于写操作的场景。它允许多个goroutine同时进行读操作,但只允许一个goroutine进行写操作。当有写操作进行时,读操作会被阻塞。

package main

import (
    "fmt"
    "sync"
)

type SafeMapWithRWMutex struct {
    sync.RWMutex
    data map[string]int
}

func (sm *SafeMapWithRWMutex) Set(key string, value int) {
    sm.Lock()
    defer sm.Unlock()
    if sm.data == nil {
        sm.data = make(map[string]int)
    }
    sm.data[key] = value
}

func (sm *SafeMapWithRWMutex) Get(key string) (int, bool) {
    sm.RLock()
    defer sm.RUnlock()
    if sm.data == nil {
        return 0, false
    }
    value, exists := sm.data[key]
    return value, exists
}

func main() {
    var wg sync.WaitGroup
    safeMap := SafeMapWithRWMutex{}
    for i := 0; i < 10; i++ {
        if i%2 == 0 {
            wg.Add(1)
            go func(index int) {
                defer wg.Done()
                key := fmt.Sprintf("key%d", index)
                safeMap.Set(key, index)
            }(i)
        } else {
            wg.Add(1)
            go func(index int) {
                defer wg.Done()
                key := fmt.Sprintf("key%d", index)
                value, exists := safeMap.Get(key)
                if exists {
                    fmt.Printf("Key: %s, Value: %d\n", key, value)
                }
            }(i)
        }
    }
    wg.Wait()
}

在这个示例中,SafeMapWithRWMutex结构体使用了读写锁RWMutexSet方法在写操作时使用Lock方法加锁,而Get方法在读操作时使用RLock方法加读锁。这样在大量读操作的场景下,性能会比单纯使用互斥锁有所提升。

使用sync.Map

Go 1.9 引入了sync.Map,它是一个线程安全的map实现。与普通的map不同,sync.Map不需要初始化,可以直接使用。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    var syncMap sync.Map
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(index int) {
            defer wg.Done()
            key := fmt.Sprintf("key%d", index)
            syncMap.Store(key, index)
            value, exists := syncMap.Load(key)
            if exists {
                fmt.Printf("Key: %s, Value: %d\n", key, value.(int))
            }
        }(i)
    }
    wg.Wait()
}

在上述代码中,我们直接使用sync.MapStore方法来存储键值对,使用Load方法来获取值。sync.Map内部实现了复杂的同步机制,能够保证在并发环境下的安全使用。sync.Map适用于高并发读写的场景,尤其是在频繁进行插入、删除和查找操作时表现出色。不过,由于其内部实现的复杂性,在一些简单场景下,使用互斥锁或读写锁包装的map可能会有更好的性能。

sync.Map的内部实现剖析

sync.Map的设计旨在提供高性能的并发map操作。它内部使用了两个数据结构:一个是包含实际键值对的read,另一个是用于在read中找不到键时进行查找的dirty

read是一个atomic.Value类型,它存储了一个readOnly结构体。readOnly结构体包含一个指向实际map的指针和一个布尔值,用于标记dirty中是否有不在read中的键。

type readOnly struct {
    m       map[interface{}]interface{}
    amended bool
}

dirty是一个普通的map,它包含了read中不存在的键值对,以及在read中但被删除的键值对(标记为待删除状态)。

在进行读操作时,首先尝试从read中读取。如果read中不存在该键,且dirty不为空,则会从dirty中读取。如果dirty为空,说明所有键值对都在read中,此时直接返回nilfalse

在进行写操作时,如果键已经存在于read中,且read不是只读状态(即dirty不为空),则直接更新read中的值。否则,会先将键值对写入dirty,并标记read为需要更新的状态。

dirty中的键值对数量达到一定阈值(具体实现中是read中键值对数量的两倍)时,dirty会被提升为read,同时清空dirty

这种设计使得sync.Map在高并发读写场景下具有较好的性能。读操作大部分情况下可以直接从read中获取,避免了锁的竞争,而写操作则通过dirty的缓冲机制,减少了锁的持有时间。不过,由于dirty的存在以及状态的切换,sync.Map的实现相对复杂,在一些简单场景下可能会带来额外的开销。

性能对比测试

为了更直观地了解不同并发安全map实现方式的性能差异,我们可以进行一些性能测试。下面使用Go语言的testing包来进行性能测试。

package main

import (
    "sync"
    "testing"
)

type SafeMap struct {
    sync.Mutex
    data map[string]int
}

func (sm *SafeMap) Set(key string, value int) {
    sm.Lock()
    defer sm.Unlock()
    if sm.data == nil {
        sm.data = make(map[string]int)
    }
    sm.data[key] = value
}

func (sm *SafeMap) Get(key string) (int, bool) {
    sm.Lock()
    defer sm.Unlock()
    if sm.data == nil {
        return 0, false
    }
    value, exists := sm.data[key]
    return value, exists
}

type SafeMapWithRWMutex struct {
    sync.RWMutex
    data map[string]int
}

func (sm *SafeMapWithRWMutex) Set(key string, value int) {
    sm.Lock()
    defer sm.Unlock()
    if sm.data == nil {
        sm.data = make(map[string]int)
    }
    sm.data[key] = value
}

func (sm *SafeMapWithRWMutex) Get(key string) (int, bool) {
    sm.RLock()
    defer sm.RUnlock()
    if sm.data == nil {
        return 0, false
    }
    value, exists := sm.data[key]
    return value, exists
}

func BenchmarkSafeMap_Set(b *testing.B) {
    var wg sync.WaitGroup
    safeMap := SafeMap{}
    for n := 0; n < b.N; n++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            safeMap.Set("key1", 1)
        }()
    }
    wg.Wait()
}

func BenchmarkSafeMap_Get(b *testing.B) {
    var wg sync.WaitGroup
    safeMap := SafeMap{}
    safeMap.Set("key1", 1)
    for n := 0; n < b.N; n++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            safeMap.Get("key1")
        }()
    }
    wg.Wait()
}

func BenchmarkSafeMapWithRWMutex_Set(b *testing.B) {
    var wg sync.WaitGroup
    safeMap := SafeMapWithRWMutex{}
    for n := 0; n < b.N; n++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            safeMap.Set("key1", 1)
        }()
    }
    wg.Wait()
}

func BenchmarkSafeMapWithRWMutex_Get(b *testing.B) {
    var wg sync.WaitGroup
    safeMap := SafeMapWithRWMutex{}
    safeMap.Set("key1", 1)
    for n := 0; n < b.N; n++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            safeMap.Get("key1")
        }()
    }
    wg.Wait()
}

func BenchmarkSyncMap_Set(b *testing.B) {
    var wg sync.WaitGroup
    var syncMap sync.Map
    for n := 0; n < b.N; n++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            syncMap.Store("key1", 1)
        }()
    }
    wg.Wait()
}

func BenchmarkSyncMap_Get(b *testing.B) {
    var wg sync.WaitGroup
    var syncMap sync.Map
    syncMap.Store("key1", 1)
    for n := 0; n < b.N; n++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            syncMap.Load("key1")
        }()
    }
    wg.Wait()
}

通过上述性能测试代码,我们可以分别测试使用互斥锁、读写锁和sync.Map实现的并发安全map在写操作(Set)和读操作(Get)上的性能。运行测试命令go test -bench=.,可以得到如下类似的结果:

BenchmarkSafeMap_Set-8               100000             10450 ns/op
BenchmarkSafeMap_Get-8               200000              5462 ns/op
BenchmarkSafeMapWithRWMutex_Set-8    100000             10354 ns/op
BenchmarkSafeMapWithRWMutex_Get-8    300000              4362 ns/op
BenchmarkSyncMap_Set-8               300000              4325 ns/op
BenchmarkSyncMap_Get-8               500000              2562 ns/op

从测试结果可以看出,在写操作方面,sync.Map的性能明显优于使用互斥锁和读写锁的实现;在读操作方面,sync.Map同样表现出色,尤其是在高并发场景下。这是因为sync.Map的内部实现针对并发读写进行了优化,减少了锁的竞争。然而,在一些简单的低并发场景下,由于sync.Map的内部复杂性,其性能可能并不比使用简单锁机制的实现更好。

选择合适的并发安全map实现

在实际应用中,选择合适的并发安全map实现方式取决于具体的应用场景。

如果读写操作频率较为均衡,且并发量不是特别高,使用互斥锁包装的map是一个简单直接的选择。这种方式实现简单,易于理解和维护,对于一些对性能要求不是特别苛刻的场景能够满足需求。

当读操作远多于写操作时,读写锁是一个不错的选择。通过允许并发读操作,读写锁可以在一定程度上提高性能,同时保证写操作的原子性。

对于高并发读写的场景,尤其是在频繁进行插入、删除和查找操作时,sync.Map是最佳选择。虽然其内部实现复杂,但能够提供高性能的并发操作,减少锁的竞争,适用于大规模并发的生产环境。

另外,还需要考虑map的使用场景是否涉及到自定义的序列化、反序列化等操作。如果有这些需求,使用互斥锁或读写锁包装的map可能更容易进行扩展,因为可以在操作前后方便地添加自定义逻辑。而sync.Map由于其内部实现的封装性,在进行这些扩展时可能会受到一定限制。

总之,在选择并发安全map的实现方式时,需要综合考虑应用的并发量、读写比例、性能要求以及功能扩展性等多方面因素,以达到最佳的性能和开发效率平衡。

总结不同实现方式的适用场景及注意事项

  1. 互斥锁(Mutex)
    • 适用场景:适用于读写操作频率相对均衡,且并发量不是特别高的场景。例如一些简单的后台任务系统,其中对配置信息的map操作,并发量有限,使用互斥锁可以简单有效地保证并发安全。
    • 注意事项:由于互斥锁在读写操作时都需要加锁,在高并发读操作场景下,可能会导致性能瓶颈,因为读操作也会被锁阻塞。另外,在使用互斥锁时,要注意锁的粒度控制,避免不必要的锁竞争。如果锁的粒度过大,会导致其他goroutine等待时间过长,影响整体性能。
  2. 读写锁(RWMutex)
    • 适用场景:非常适合读操作远多于写操作的场景。比如一个在线游戏的排行榜系统,玩家频繁读取排行榜数据,但只有在特定条件下(如玩家得分更新)才会进行写操作。使用读写锁可以让多个玩家同时读取排行榜,而写操作时会暂时阻塞读操作,保证数据一致性。
    • 注意事项:虽然读写锁允许并发读,但在写操作时,会阻塞所有读操作。因此,如果写操作比较频繁,可能会影响读操作的性能。此外,在使用读写锁时,要注意避免死锁情况。例如,一个goroutine持有读锁,同时尝试获取写锁,而另一个goroutine持有写锁,又尝试获取读锁,就可能导致死锁。
  3. sync.Map
    • 适用场景:适用于高并发读写的场景,尤其是频繁进行插入、删除和查找操作的情况。例如分布式缓存系统,大量的客户端可能同时对缓存进行读写操作,sync.Map能够很好地应对这种高并发场景,提供高性能的并发操作。
    • 注意事项sync.Map内部实现复杂,在一些简单场景下可能会带来额外的开销。此外,sync.Map不支持像普通map那样直接遍历所有键值对,需要通过Range方法来实现遍历,并且在遍历过程中不能修改map。如果应用场景对遍历和修改操作有特殊要求,需要谨慎使用sync.Map

在实际项目中,需要根据具体的业务需求和性能要求,仔细权衡选择合适的并发安全map实现方式,以确保程序的高效运行和数据的一致性。同时,在编写并发代码时,要养成良好的习惯,通过单元测试和性能测试来验证并发安全的正确性和性能表现。