Go语言中如何实现线程安全的map

文章目录

map的基本用法

在Go语言开发中,map 是一种比较常用的数据结构,凭借 key-value 映射的特性,实现高效的增删改查。但是在并发场景下,内置 map 的线程不安全问题常常导致程序 panic。

推荐阅读我之前写的关于Go语言map的一些用法:

需要注意的是,map中的key 类型不能乱选:必须是可比较类型(==/!= 可判断),比如 int、string、指针,而 slice、map、函数值不能当 key

可以用 struct 当 key,但是需要注意:如果 struct 字段被修改,会导致查询不到原有值(本质是 key 的哈希值变了),所以 struct 作为 key 时要保证逻辑不可变。

go 复制代码
// struct作为key的坑
type UserKey struct {
    ID int
}

func main() {
    m := make(map[UserKey]string)
    key := UserKey{ID: 10}
    m[key] = "张三"
    fmt.Println(m[key]) // 输出:张三
    
    key.ID = 100 // 修改struct字段
    fmt.Println(m[key]) // 输出:""(查询不到)
}

map可以有两个返回值:

  • 单返回值时,不存在的 key 会返回零值(比如 int 返回 0,string 返回 ""),容易误判;
  • 双返回值的第二个参数是 bool 类型,明确标识 key 是否存在:
go 复制代码
func main() {
    m := make(map[string]int)
    m["a"] = 0
    
    // 单返回值:无法区分是key不存在还是值为0
    fmt.Println(m["a"], m["b"]) // 输出:0 0
    
    // 双返回值:精准判断
    aVal, aExist := m["a"]
    bVal, bExist := m["b"]
    fmt.Println("aVal:", aVal)     // 输出:0
    fmt.Println("aExist:", aExist) // 输出:true
    fmt.Println("bVal:", bVal)     // 输出:0
    fmt.Println("bExist:", bExist) // 输出:false
}

内置 map 声明后必须初始化(make)才能赋值,否则会 panic;但从 nil map 取值不会报错,只会返回零值。

go 复制代码
// 错误示例:未初始化赋值
func main() {
    var m map[int]string
    m[1] = "test" // panic: assignment to entry in nil map
}

// 正确示例:初始化后使用
func main() {
    var m map[int]string
    m = make(map[int]string) // 初始化
    m[1] = "test"
    fmt.Println(m[1]) // 输出:test
    
    // 结构体中的map字段容易遗漏初始化
    type Config struct {
        Data map[string]string
    }
    var cfg Config
    cfg.Data = make(map[string]string) // 必须初始化
    cfg.Data["name"] = "Go"
  	fmt.Println(cfg.Data) // 输出:map[name:Go]
}

并发读写的时候,内置 map 不是线程安全的,多个 goroutine 同时读写会触发 panic。

go 复制代码
// 并发读写panic示例
func main() {
    m := make(map[int]int)
    
    // 写goroutine
    go func() {
        for i := 0; ; i++ {
            m[i] = i
        }
    }()
    
    // 读goroutine
    go func() {
        for i := 0; ; i++ {
            _ = m[i]
        }
    }()
    
    select {} // 阻塞程序
}

运行后会报错:fatal error: concurrent map read and map write

map的并发读写问题

针对这种并发读写问题,一般有以下几种解决方案:

方案1:读写锁(RWMutex)实现

这种方案简单通用。核心思路:用sync.RWMutex(读写锁)保护内置 map,读操作加读锁(支持并发读),写操作加写锁(排他锁,同一时间只能一个写),平衡安全性和性能。

适用场景:

  • 并发读写频率适中,不需要极致性能;
  • 代码复杂度要求低,追求简单易维护。
go 复制代码
package main

import (
    "sync"
)

// RWMap 读写锁保护的线程安全map
type RWMap struct {
    mu sync.RWMutex
    m  map[int]string // 实际存储数据的map
}

// NewRWMap 新建线程安全map
func NewRWMap(size int) *RWMap {
    return &RWMap{
        m: make(map[int]string, size),
    }
}

// Get 读取key对应的值
func (rm *RWMap) Get(key int) (string, bool) {
    rm.mu.RLock()         // 读锁:并发读安全
    defer rm.mu.RUnlock() // 函数结束释放锁
    val, exist := rm.m[key]
    return val, exist
}

// Set 设置key-value
func (rm *RWMap) Set(key int, val string) {
    rm.mu.Lock()         // 写锁:排他锁,防止并发写
    defer rm.mu.Unlock()
    rm.m[key] = val
}

// Delete 删除key
func (rm *RWMap) Delete(key int) {
    rm.mu.Lock()
    defer rm.mu.Unlock()
    delete(rm.m, key)
}

// Len 获取map长度
func (rm *RWMap) Len() int {
    rm.mu.RLock()
    defer rm.mu.RUnlock()
    return len(rm.m)
}

// 测试代码
func main() {
    rm := NewRWMap(10)
    
    // 并发写
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            rm.Set(n, fmt.Sprintf("val_%d", n))
        }(i)
    }
    wg.Wait()
    
    // 并发读
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            val, exist := rm.Get(n)
            if exist {
                fmt.Printf("key=%d, val=%s\n", n, val)
            }
        }(i)
    }
    wg.Wait()
    
    fmt.Println("map长度:", rm.Len()) // 输出:5
}

方案2:分片加锁

方案 1 的问题:在高并发场景下,所有操作都竞争同一把锁,高并发下锁竞争激烈,性能下降。分片加锁的核心是 "减小锁粒度"------ 把一个 map 分成多个分片(比如 32 个),每个分片一把锁,读写只操作对应分片的锁,大幅降低锁竞争。

实现原理流程图:
读 写/删 传入key 计算分片索引(哈希取模) 获取对应分片 操作类型 加读锁 -> 读数据 -> 释放锁 加写锁 -> 写/删数据 -> 释放锁

orcaman/concurrent-map 是一个基于 Go 内置 map 实现的并发安全数据结构,它将一个大的 map 分成多个小的 map(称为 "分片"),每个分片都由一把独立的读写锁(sync.RWMutex)保护。它的核心设计思想是 "分片加锁" ,专门用来解决 Go 内置 map 在并发场景下的安全问题。但也存在一些缺点:需要额外存储分片和锁,比内置 map 消耗更多内存;分片的存在使得某些操作(如 Len()Range())的实现变得复杂。

适用场景:

  • 高并发读写,锁竞争激烈;
  • 追求更高的吞吐量(性能比方案 1 提升明显)。

使用方法:go get github.com/orcaman/concurrent-map/v2

然后,在代码中使用它:

go 复制代码
package main

import (
    "fmt"
    "github.com/orcaman/concurrent-map/v2"
)

func main() {
	//_demo6()
	_demo7()
}

// 1. 测试普通 map(预期会崩溃)
func _demo6() {
	fmt.Println("=== 测试普通 map(并发读写)===")
	regularMap := make(map[string]int)
	var wg sync.WaitGroup

	// 启动 100 个 Goroutine 并发读写
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			key := fmt.Sprintf("key_%d", n%10) // 故意让多个 Goroutine 操作同一个 key

			// 写操作
			regularMap[key] = n

			// 读操作
			fmt.Printf("普通 map: key=%s, value=%d\n", key, regularMap[key])
		}(i)
	}

	wg.Wait()

	// fatal error: concurrent map writes
	fmt.Println("普通 map 测试完成(会崩溃)=== 此行不会输出!")
}

// 2. 测试 concurrent-map(预期安全)
func _demo7() {
	fmt.Println("=== 测试 concurrent-map(并发读写)===")
	concurrentMap := cmap.New[int]()
	var wg sync.WaitGroup

	// 启动 100 个 Goroutine 并发读写
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			key := fmt.Sprintf("key_%d", n%10) // 同样让多个 Goroutine 操作同一个 key

			// 写操作
			concurrentMap.Set(key, n)

			// 读操作
			value, exists := concurrentMap.Get(key)
			if exists {
				fmt.Printf("concurrent-map: key=%s, value=%d\n", key, value)
			}
		}(i)
	}

	wg.Wait()
	fmt.Println("concurrent-map 测试完成(安全无崩溃)")
}

你可以点进去pkg/mod/github.com/orcaman/concurrent-map/v2@v2.0.1/concurrent_map.go 查看它的核心方法:

go 复制代码
// 写入操作
func (m ConcurrentMap[K, V]) Set(key K, value V) {
    // 1. 获取 key 对应的分片
  	// 分片定位:GetShard(key) 是关键步骤。它通过一个哈希函数(默认是 fnv32)计算出 key 的哈希值,然后对分片总数 SHARD_COUNT 取模,得到该 key 应该存入的分片索引。
    shard := m.GetShard(key)
    // 2. 对该分片加写锁, 与使用一把全局锁保护整个大 map 不同,这里只对 key 所在的单个分片进行加锁
    shard.Lock()
    // 3. 执行写入操作(操作的是分片内的内置 map)
    shard.items[key] = value
    // 4. 释放锁
    shard.Unlock()
}

// 读取操作
func (m ConcurrentMap[K, V]) Get(key K) (V, bool) {
    // 1. 获取 key 对应的分片, 读取和写入使用完全相同的逻辑来定位分片,确保能找到正确的数据。
    shard := m.GetShard(key)
    // 2. 对该分片加读锁(注意:是读锁,不是写锁)
  	// 读锁的特性:它允许多个 Goroutine 同时对同一个分片进行读取,但会阻止任何 Goroutine 进行写入。
    // 性能优势:这极大地提升了读多写少场景下的并发性能。多个 Goroutine 可以同时读取同一个分片的数据,而不会产生竞争。
    shard.RLock()
    // 3. 执行读取操作
    val, ok := shard.items[key]
    // 4. 释放读锁
    shard.RUnlock()
    return val, ok
}

// 辅助方法:GetShard(key K): 实现分片思想的核心基础
func (m ConcurrentMap[K, V]) GetShard(key K) *ConcurrentMapShared[K, V] {
    // m.sharding(key) 计算 key 的哈希值
  	// 通常是一个快速的非加密哈希函数,如 fnv32。它的作用是将任意 key 均匀地映射到一个整数空间。
    // % uint(SHARD_COUNT) 对分片数取模,得到分片索引
    return m.shards[uint(m.sharding(key))%uint(SHARD_COUNT)]
}

普通的map和concurrent-map的对比:

特性 普通 map concurrent-map
线程安全 不安全 安全
并发读写 会崩溃(fatal error 正常运行
实现原理 无锁 分片加锁(每个分片一把 RWMutex
适用场景 单线程或低并发(无并发读写) 高并发读写场景

方案3:sync.Map

sync.Map 是 Go 标准库提供的并发安全映射(Go 1.9 + ),核心用于解决 高并发读写场景 下的线程安全问题。

核心用法:

方法 功能说明
Store(key, value) 存储键值对(线程安全,支持新增 / 更新)
Load(key) (value, ok) 获取 key 对应的 value,ok 表示是否存在(线程安全,无锁优先读)
Delete(key) 删除指定 key(线程安全,延迟清理)
Range(f func(key, value interface{}) bool) 遍历所有键值对(线程安全,遍历期间会阻塞写操作,f 返回 false 可提前退出)

sync.Map流程图:
sync.Map 核心 适用场景 实现优化 优缺点 只读多写少(如缓存) 键集不相交(多goroutine操作不同key) 空间换时间(read+dirty双结构) 读不加锁(优先读read) 动态调整(dirty提升为read) 延迟删除(标记删除,批量清理) 优点:特殊场景性能极高 缺点:API不友好,通用场景性能一般

代码示例:

go 复制代码
package main

import (
    "fmt"
    "sync"
)

func main() {
    var m sync.Map

    // 1. 存储数据
    m.Store("name", "张三")
    m.Store("age", 25)

    // 2. 获取数据
    if name, ok := m.Load("name"); ok {
        fmt.Println("name:", name) // 输出:name: 张三
    }

    // 3. 遍历数据
    m.Range(func(key, value interface{}) bool {
        fmt.Printf("key: %s, value: %v\n", key, value)
        return true // 返回 true 继续遍历,false 退出
    })

    // 4. 删除数据
    m.Delete("age")
}

Load方法的核心流程:
是 否 否 是 是 否 调用 Load(key) 读 read 成功? 返回 value 有新数据? 返回 nil 加锁查 dirty 计数+1, 达标则提升 dirty 解锁 查 dirty 成功?

注意事项:

  • 不要用sync.Map替代常规 map,只有满足 "只读多写少" 或 "键集不相交" 时,性能才比方案 1、2 好;
  • 没有Len()方法,如需获取长度,需通过Range遍历计数,因此效率较低(适合元素数量少的场景)。
  • 避免频繁写:写操作会阻塞其他写操作,导致性能下降。
  • 遍历期间阻塞写:Range 遍历期间会加锁,阻塞所有写操作,遍历时间不宜过长。
  • 键值类型限制:key 必须是可比较类型(如 string、int、struct {} 等),value 可以是任意类型。

三种方案对比

Go 的 map 线程安全问题,核心是通过 "锁保护" 或 "优化锁粒度" 解决。日常开发中,优先根据并发强度选型:大多数场景下,读写锁方案足够用;如果是高并发场景,分片加锁是更优选择;只有特定的缓存场景,才考虑sync.Map。没有最好的方案,只有最适合的场景。

  • 简单场景用 "读写锁",代码清爽不踩坑;
  • 高并发用 "分片加锁",性能翻倍无压力;
  • 缓存场景用 "sync.Map",只读多写省资源。
方案 优点 缺点 适用场景
读写锁(RWMutex) 实现简单、易维护 高并发下锁竞争激烈 并发适中、追求简单的场景
分片加锁 高并发性能好、锁竞争低 实现稍复杂、分片耗内存 高并发读写、需要高吞吐量的场景
sync.Map 特殊场景性能极致 API 不友好、通用型差 只读多写少、键集不相交的场景(如缓存)

源代码参考:https://gitee.com/rxbook/go-demo-2025/tree/master/demo/basic/map_demo

相关推荐
时尚IT男1 小时前
Python 魔术方法详解:掌握面向对象编程的精髓
开发语言·python
找了一圈尾巴1 小时前
Python 学习-深入理解 Python 进程、线程与协程(下)
开发语言·python·学习
l***37091 小时前
基于SpringBoot和Leaflet的行政区划地图掩膜效果实战
java·spring boot·后端
小猪写代码1 小时前
C语言系统函数-(新增)
c语言·开发语言
遇到困难睡大觉哈哈1 小时前
Harmony os ——ArkTS 语言笔记(五):泛型、空安全与可选链
前端·笔记·安全·harmonyos·鸿蒙
小坏讲微服务1 小时前
Spring Boot 4.0 与 Spring Cloud Alibaba 2025 整合完整指南
java·spring boot·分布式·后端·spring cloud·微服务·架构
毕设源码-邱学长1 小时前
【开题答辩全过程】以 基于Spring Boot的酒店管理系统为例,包含答辩的问题和答案
java·spring boot·后端
catchadmin1 小时前
PHP Fiber 优雅协作式多任务
后端·php
WXG10111 小时前
【matlab】matlab点云处理
开发语言·matlab