Go 面试 —— Go Map 的并发安全问题

查看 60|回复 5
作者:YunFun   
面试官:你好,我们来聊下 Go 语言的 map 。首先,请聊下 Go 语言的 map 是不是并发安全的?
应试者:不是的。Go 语言的 map 不是并发安全的。如果在多个 goroutine 同时读写同一个 map 时,会出现竞态条件( race condition ),可能导致程序运行出错,甚至崩溃。
为了证明这一点,我可以展示一个并发不安全的示例:
package main
import (
    "fmt"
    "sync"
)
func main() {
    m := make(map[int]int)
    var wg sync.WaitGroup
    for i := 0; i
这段代码可能会出现以下问题:
[ol]
  • 并发写入可能导致 map 数据损坏
  • 可能会触发运行时 panic
  • 最终的 map 大小可能不是预期的 100
    [/ol]
    面试官:OK 。那当我们需要在并发场景下使用 map 时,你有什么好的解决方案吗?
    应试者:在 Go 语言中,主要有三种解决方案:
    [ol]
  • 使用 sync.Mutex 互斥锁来保护 map
    [/ol]
    type SafeMap struct {
        sync.Mutex
        m map[int]int
    }
    func (sm *SafeMap) Set(key, value int) {
        sm.Lock()
        defer sm.Unlock()
        sm.m[key] = value
    }
    func (sm *SafeMap) Get(key int) (int, bool) {
        sm.Lock()
        defer sm.Unlock()
        val, exists := sm.m[key]
        return val, exists
    }
    [ol]
  • 使用 sync.RWMutex,允许并发读,但写入互斥
    [/ol]
    type SafeMap struct {
        sync.RWMutex
        m map[int]int
    }
    func (sm *SafeMap) Get(key int) (int, bool) {
        sm.RLock()
        defer sm.RUnlock()
        val, exists := sm.m[key]
        return val, exists
    }
    [ol]
  • 使用 sync.Map,这是 Go 标准库提供的并发安全的 map 实现
    [/ol]
    var m sync.Map
    // 存储
    m.Store("key", "value")
    // 读取
    value, ok := m.Load("key")
    // 删除
    m.Delete("key")
    面试官:能详细解释一下为什么普通的 map 不是并发安全的吗?这背后的机制是什么?
    应试者:这涉及到 Go 语言 map 的底层实现。在 Go 的源码中( runtime/map.go ),map 的结构大致是这样的:
    type hmap struct {
        count     int    // 元素个数
        flags     uint8  // 状态标志
        B         uint8  // 桶的对数
        noverflow uint16 // 溢出桶的近似数
        hash0     uint32 // 哈希种子
        buckets    unsafe.Pointer // 桶数组
        oldbuckets unsafe.Pointer // 旧桶数组,在扩容时使用
        // ... 其他字段
    }
    并发不安全的根本原因在于:
    [ol]
  • map 的内部操作(如插入、删除)不是原子的
  • 扩容过程中会修改桶的结构
  • 多个 goroutine 同时操作会导致数据竞争
    [/ol]
    具体来说,一个简单的写入操作可能包含多个步骤:
  • 计算 key 的哈希值
  • 定位到具体的桶
  • 在桶中找到空位
  • 写入数据

    这些步骤如果被并发执行,就会导致不可预期的结果。
    面试官:sync.Map 是如何解决这些并发问题的?能详细介绍一下它的实现原理吗?
    应试者:sync.Map 的核心设计是读写分离和优化的并发控制。我们可以看一下它的大致结构:
    type Map struct {
        mu Mutex
        read atomic.Value // readOnly
        dirty map[interface{}]*entry
        misses int
    }
    type readOnly struct {
        m       map[interface{}]*entry
        amended bool // 是否有新的数据在 dirty 中
    }
    它的主要优化策略包括:
    [ol]

  • 双层存储:
  • read map:无锁读取
  • dirty map:需要加锁的可写 map

  • 读优化:
  • 优先从 read map 读取
  • 使用原子操作 atomic.Value 保证读取的线程安全

  • 写入机制:
  • 先尝试在 read map 中更新
  • 如果不成功,则加锁操作 dirty map

  • 动态提升:
  • 当 dirty map 被频繁访问时,会将其提升为 read map

    [/ol]
    实际的读写流程大致如下:
    func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
        // 首先无锁读取 read map
        read, _ := m.read.Load().(readOnly)
        e, ok := read.m[key]
        if !ok && read.amended {
            // 如果 read map 没有,且有新数据,则加锁查询 dirty map
            m.mu.Lock()
            // 双检查,避免重复加锁
            read, _ = m.read.Load().(readOnly)
            e, ok = read.m[key]
            if !ok && read.amended {
                e, ok = m.dirty[key]
                // 记录未命中次数,可能会晋升 dirty map
                m.missLocked()
            }
            m.mu.Unlock()
        }
        // ... 返回结果
    }
    面试官:那么,sync.Map 是不是在所有并发场景下都是最佳选择?
    应试者:不是的。sync.Map 有其特定的适用场景和局限性:
    适用场景:
    [ol]
  • 读操作明显多于写操作
  • key 是动态增长的
  • 元素命中率较高
    [/ol]
    不适用场景:
    [ol]
  • 写操作频繁
  • key 是有限且提前确定的
  • 需要有序遍历 map
  • 需要对 key 进行排序或自定义比较的场景
    [/ol]
    性能建议:
  • 对于写多读少的场景,传统的 sync.Mutex + map 可能更高效
  • 对于读写均衡的场景,可以考虑分片锁等自定义方案

    面试官:最后,你能分享一个实际工作中处理并发 map 的最佳实践吗?
    应试者:在高并发缓存场景,我曾使用分片锁方案来优化 map 的并发性能:
    type ShardedMap struct {
        shards     []map[string]interface{}
        locks      []sync.RWMutex
        shardCount int
    }
    func NewShardedMap(shardCount int) *ShardedMap {
        sm := &ShardedMap{
            shards:     make([]map[string]interface{}, shardCount),
            locks:      make([]sync.RWMutex, shardCount),
            shardCount: shardCount,
        }
        for i := 0; i
    这种方案的优点:
    [ol]
  • 降低锁的粒度
  • 提高并发性能
  • 可以根据业务动态调整分片数
    [/ol]
    面试官:很好,你对 map 的并发问题理解的相当充分。
    应试者:非常感谢!
    更多 Go 高质量内容👇: https://portal.yunosphere.com
    欢迎关注我,经常分享有用的 Go 知识 / 面试 / 创业经历 / 其他编程知识 👇
  • 公众号:GopherYes
  • B 站:YunFuns
  • 知乎、掘金、头条号:YunFun
  • 小红书号:986261983

  • wolfsun   
    很佩服楼主能有这样的行动力去做一件事,在这个时代就是要这样才能搞到钱,也是挺难的。
    tcpdump   
    笑死,除了面试,很少会专研这个,你写业务的时候,写测试,不就测出来了吗?面试钻某个点的都是装的
    yingxiangyu   
    @tcpdump #2 但是实际上问的这些问题本质都是对哈希表的优化,就算不是面试,有多线程大量读写共享最终也是这个方案,跟语言、面试也没啥关系
    tcpdump   
    @yingxiangyu 对啊,就是内存共享、争用、锁、一致性啊,业务都要考虑的,不要关注代码层,应该是先有思路再去专研,实现业务需求
    DefoliationM   
    好的,知道了,回去等通知吧。
    您需要登录后才可以回帖 登录 | 立即注册

    返回顶部