V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
YunFun
V2EX  ›  程序员

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

  •  
  •   YunFun · 2 天前 · 1595 次点击

    面试官:你好,我们来聊下 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 < 100; i++ {
            wg.Add(1)
            go func(n int) {
                defer wg.Done()
                m[n] = n
            }(i)
        }
    
        wg.Wait()
        fmt.Println("Map 的大小:", len(m))
    }
    

    这段代码可能会出现以下问题:

    1. 并发写入可能导致 map 数据损坏
    2. 可能会触发运行时 panic
    3. 最终的 map 大小可能不是预期的 100

    面试官:OK 。那当我们需要在并发场景下使用 map 时,你有什么好的解决方案吗?

    应试者:在 Go 语言中,主要有三种解决方案:

    1. 使用 sync.Mutex 互斥锁来保护 map
    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
    }
    
    1. 使用 sync.RWMutex,允许并发读,但写入互斥
    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
    }
    
    1. 使用 sync.Map,这是 Go 标准库提供的并发安全的 map 实现
    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 // 旧桶数组,在扩容时使用
        // ... 其他字段
    }
    

    并发不安全的根本原因在于:

    1. map 的内部操作(如插入、删除)不是原子的
    2. 扩容过程中会修改桶的结构
    3. 多个 goroutine 同时操作会导致数据竞争

    具体来说,一个简单的写入操作可能包含多个步骤:

    • 计算 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 中
    }
    

    它的主要优化策略包括:

    1. 双层存储:

      • read map:无锁读取
      • dirty map:需要加锁的可写 map
    2. 读优化:

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

      • 先尝试在 read map 中更新
      • 如果不成功,则加锁操作 dirty map
    4. 动态提升:

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

    实际的读写流程大致如下:

    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 有其特定的适用场景和局限性:

    适用场景:

    1. 读操作明显多于写操作
    2. key 是动态增长的
    3. 元素命中率较高

    不适用场景:

    1. 写操作频繁
    2. key 是有限且提前确定的
    3. 需要有序遍历 map
    4. 需要对 key 进行排序或自定义比较的场景

    性能建议:

    • 对于写多读少的场景,传统的 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 < shardCount; i++ {
            sm.shards[i] = make(map[string]interface{})
        }
        return sm
    }
    
    func (sm *ShardedMap) getShard(key string) (map[string]interface{}, *sync.RWMutex) {
        hash := fnv32(key)
        shardIndex := hash % uint32(sm.shardCount)
        return sm.shards[shardIndex], &sm.locks[shardIndex]
    }
    
    func (sm *ShardedMap) Set(key string, value interface{}) {
        shard, lock := sm.getShard(key)
        lock.Lock()
        defer lock.Unlock()
        shard[key] = value
    }
    
    func fnv32(key string) uint32 {
        hash := uint32(2166136261)
        for i := 0; i < len(key); i++ {
            hash *= 16777619
            hash ^= uint32(key[i])
        }
        return hash
    }
    

    这种方案的优点:

    1. 降低锁的粒度
    2. 提高并发性能
    3. 可以根据业务动态调整分片数

    面试官:很好,你对 map 的并发问题理解的相当充分。

    应试者:非常感谢!


    更多 Go 高质量内容👇: https://portal.yunosphere.com

    欢迎关注我,经常分享有用的 Go 知识 / 面试 / 创业经历 / 其他编程知识 👇

    • 公众号:GopherYes
    • B 站:YunFuns
    • 知乎、掘金、头条号:YunFun
    • 小红书号:986261983
    38 条回复    2024-11-29 18:13:58 +08:00
    wolfsun
        1
    wolfsun  
       2 天前   ❤️ 1
    很佩服楼主能有这样的行动力去做一件事,在这个时代就是要这样才能搞到钱,也是挺难的。
    tcpdump
        2
    tcpdump  
       2 天前
    笑死,除了面试,很少会专研这个,你写业务的时候,写测试,不就测出来了吗?面试钻某个点的都是装的
    yingxiangyu
        3
    yingxiangyu  
       2 天前
    @tcpdump #2 但是实际上问的这些问题本质都是对哈希表的优化,就算不是面试,有多线程大量读写共享最终也是这个方案,跟语言、面试也没啥关系
    tcpdump
        4
    tcpdump  
       2 天前
    @yingxiangyu 对啊,就是内存共享、争用、锁、一致性啊,业务都要考虑的,不要关注代码层,应该是先有思路再去专研,实现业务需求
    DefoliationM
        5
    DefoliationM  
       2 天前 via Android
    好的,知道了,回去等通知吧。
    YunFun
        6
    YunFun  
    OP
       2 天前
    @tcpdump 是面试啊
    YunFun
        7
    YunFun  
    OP
       2 天前
    @yingxiangyu 对的哈哈
    YunFun
        8
    YunFun  
    OP
       2 天前
    @DefoliationM 经典等通知😼
    YunFun
        9
    YunFun  
    OP
       2 天前
    @wolfsun 感谢感谢,喜欢研究技术,也做点自己喜欢的事,能有收入就更好了 😎
    grzhan
        10
    grzhan  
       2 天前
    fastcache 也是分片锁的思路,切成 512 个 buckets ,进一步最主要就是针对 GC 做了优化,索引用 map[int]int noscan 来减小 GC 扫描开销,实际 key,value 放在一个自己手动 mmap 分配管理的 chunks ([][]bytes )里,跳过了 golang 自己的堆 gc 。这套思路上生产很多场景应该是够用了
    CLMan
        11
    CLMan  
       2 天前
    或许没有一个编程语言能逃过八股文。
    kingcanfish
        12
    kingcanfish  
       2 天前
    什么时候卖课
    cydian
        13
    cydian  
       2 天前
    @YunFun 希望你官网的课程可以开放几节公开课,希望能看到你课程讲解的深度 和 你的表达能力。
    CEBBCAT
        14
    CEBBCAT  
       2 天前
    感觉写得还不错,前两天另外一个人发 goroutine 还是什么的,被我评论区“省流”了。

    虽然写得不错,但我读完感觉更期望有若干篇从软件设计角度研讨的文章——楼上也有人提到,这些代码背后其实就是运用不同策略应对不同场景的需求——那样的话想必能够提纲挈领,促使读者举一反三
    bitfly
        15
    bitfly  
       2 天前 via Android
    这个我用过
    在读取一段 ip 列表时 比如有 100 万个 ip 需要验证是否能 ping 通
    但是不能顺序读取
    需要随机从里面的列表读取
    且不能漏 且需要并发每次读取 100 个不然一个个读取要几个月时间
    且有超时条件
    就需要用这个方法
    但是也偶尔会触发 panic
    如果不 panic 那处理的效率是 c#的数倍
    moudy
        16
    moudy  
       2 天前
    @tcpdump #2 测出来算你烧高香。实际很多犄角旮旯的用错了测不出来,上线才会暴雷
    COW
        17
    COW  
       2 天前 via Android
    八股文太多了,如果 op 能结合实际场景去分享,我觉得更吸引人一点。
    ryalu
        18
    ryalu  
       2 天前
    面试官:不错,基础很扎实。能再谈一谈你对 Go 1.24 即将落地的 swiss table 的看法吗?
    xierqii
        19
    xierqii  
       2 天前
    @ryalu 哈哈哈哈 接着问
    ranye777
        20
    ranye777  
       2 天前
    @moudy #16 是的,线上出个并发的偶现问题是会搞死人的
    Felldeadbird
        21
    Felldeadbird  
       2 天前
    感谢楼主分享。我写 go 才小半年,还是菜鸟一个,问我 map 的我都答不上。😂
    gongxuanzhang
        22
    gongxuanzhang  
       2 天前
    在 Java 八股面前,这玩意和小儿科一样
    zbatman
        23
    zbatman  
       2 天前
    面试官:好,那喜欢爸爸还是妈妈?
    YunFun
        24
    YunFun  
    OP
       2 天前
    @grzhan 对,主要就是 GC 优化,自定义内存管理减少 Go 自己的 GC 。。。内存也更可控了。
    YunFun
        25
    YunFun  
    OP
       2 天前
    @CLMan 哈哈哈哈哈,八股也算一种真实需求了😂
    YunFun
        26
    YunFun  
    OP
       2 天前
    @kingcanfish 在卖了在卖了
    YunFun
        27
    YunFun  
    OP
       2 天前
    @cydian 听劝。改版下官网,最近出个公开课模块哈哈,其实前段时间搞了点免费课程 https://yunosphere.com ,最近也在一边更新一边重新规划公开的内容~ 欢迎关注 🤝 ,感谢宝贵的建议 🙏🙏
    YunFun
        28
    YunFun  
    OP
       2 天前
    @CEBBCAT 好的,后续会考虑搞一些业务实例来配合解读这样的,坚持研究跟更新 💪
    YunFun
        29
    YunFun  
    OP
       2 天前
    @bitfly 🐂 🍺,这是大佬
    YunFun
        30
    YunFun  
    OP
       2 天前
    @COW 后边会慢慢尝试从实例或者合理的场景出发聊一些内容,多谢建议🙏🙏 欢迎关注~ 我会尝试坚持产出嘿嘿
    YunFun
        31
    YunFun  
    OP
       2 天前
    @ryalu 哈哈 有机会写写看~
    YunFun
        32
    YunFun  
    OP
       2 天前
    @ranye777 对,,真的到了生产上其实还是很多奇葩问题的,八股大多数时候只能搞定面试
    YunFun
        33
    YunFun  
    OP
       2 天前
    @gongxuanzhang #22 哈哈哈,jvm 原理都是入门对吧
    YunFun
        34
    YunFun  
    OP
       2 天前
    @Felldeadbird #21 能帮到你最好哈哈,互相学习😂 欢迎关注🙏
    YunFun
        35
    YunFun  
    OP
       2 天前
    @zbatman #23 你是懂面试的😂
    gongxuanzhang
        36
    gongxuanzhang  
       2 天前
    @YunFun 这点锁的知识连培训班都得教了
    YunFun
        37
    YunFun  
    OP
       2 天前
    @gongxuanzhang #36
    哥们,本来我是做好被喷卖课的准备的,结果倒是被喷不如培训班 🙃
    首先我是卖课需要积累自己的内容所以在摸索着做,想解决的是想学学其他技能这部分人的需求顺便看能否赚点钱,不做培训班,也没那能力偶
    其次培训班面向的是包就业,这不是我的目的。真的工作经验积累也不是培训班那点套路东西能搞定的不是
    最后培训班也是要收费的,我花时间写了,至少这里还是免费的 🙃
    gongxuanzhang
        38
    gongxuanzhang  
       1 天前
    @YunFun 首先对伤害到你感到抱歉,并没有想嘲讽你和针对你。
    我想表达的就是关于 Map 的线程安全问题和如何解决衍生的一系列知识。
    我觉得对于 Java 面试来说这个就是非常初级的八股,哪怕和 JVM 相关也是初级八股。
    这个初级不是说知识有多简单,而是因为它被问的频繁,我不太了解 go ,仅仅是在语法层面而已。
    祝你大卖
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2616 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 06:24 · PVG 14:24 · LAX 22:24 · JFK 01:24
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.