文章目录
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