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

1 天前
 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 < 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 同时操作会导致数据竞争

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

这些步骤如果被并发执行,就会导致不可预期的结果。

面试官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 进行排序或自定义比较的场景

性能建议:

面试官:最后,你能分享一个实际工作中处理并发 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 知识 / 面试 / 创业经历 / 其他编程知识 👇

1544 次点击
所在节点    程序员
38 条回复
Felldeadbird
1 天前
感谢楼主分享。我写 go 才小半年,还是菜鸟一个,问我 map 的我都答不上。😂
gongxuanzhang
1 天前
在 Java 八股面前,这玩意和小儿科一样
zbatman
1 天前
面试官:好,那喜欢爸爸还是妈妈?
YunFun
1 天前
@grzhan 对,主要就是 GC 优化,自定义内存管理减少 Go 自己的 GC 。。。内存也更可控了。
YunFun
1 天前
@CLMan 哈哈哈哈哈,八股也算一种真实需求了😂
YunFun
1 天前
@kingcanfish 在卖了在卖了
YunFun
1 天前
@cydian 听劝。改版下官网,最近出个公开课模块哈哈,其实前段时间搞了点免费课程 https://yunosphere.com ,最近也在一边更新一边重新规划公开的内容~ 欢迎关注 🤝 ,感谢宝贵的建议 🙏🙏
YunFun
1 天前
@CEBBCAT 好的,后续会考虑搞一些业务实例来配合解读这样的,坚持研究跟更新 💪
YunFun
1 天前
@bitfly 🐂 🍺,这是大佬
YunFun
1 天前
@COW 后边会慢慢尝试从实例或者合理的场景出发聊一些内容,多谢建议🙏🙏 欢迎关注~ 我会尝试坚持产出嘿嘿
YunFun
1 天前
@ryalu 哈哈 有机会写写看~
YunFun
1 天前
@ranye777 对,,真的到了生产上其实还是很多奇葩问题的,八股大多数时候只能搞定面试
YunFun
1 天前
@gongxuanzhang #22 哈哈哈,jvm 原理都是入门对吧
YunFun
1 天前
@Felldeadbird #21 能帮到你最好哈哈,互相学习😂 欢迎关注🙏
YunFun
1 天前
@zbatman #23 你是懂面试的😂
gongxuanzhang
1 天前
@YunFun 这点锁的知识连培训班都得教了
YunFun
23 小时 56 分钟前
@gongxuanzhang #36
哥们,本来我是做好被喷卖课的准备的,结果倒是被喷不如培训班 🙃
首先我是卖课需要积累自己的内容所以在摸索着做,想解决的是想学学其他技能这部分人的需求顺便看能否赚点钱,不做培训班,也没那能力偶
其次培训班面向的是包就业,这不是我的目的。真的工作经验积累也不是培训班那点套路东西能搞定的不是
最后培训班也是要收费的,我花时间写了,至少这里还是免费的 🙃
gongxuanzhang
17 小时 34 分钟前
@YunFun 首先对伤害到你感到抱歉,并没有想嘲讽你和针对你。
我想表达的就是关于 Map 的线程安全问题和如何解决衍生的一系列知识。
我觉得对于 Java 面试来说这个就是非常初级的八股,哪怕和 JVM 相关也是初级八股。
这个初级不是说知识有多简单,而是因为它被问的频繁,我不太了解 go ,仅仅是在语法层面而已。
祝你大卖

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1093490

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX