golang map 数据结构在开发中的坑,你踩了几个?

写作背景

Go 中的 map 是日常开发中常用的数据结构,用于存储键值对。但是,如果使用不当,容易遇到一些问题。我先列一些坑大家看看是否踩过。

1、 并发读写冲突:多个协程同时对同一个 map 进行读写操作时,可能会导致数据竞争和不一致的结果。

2、 迭代中修改 map:在使用 range 迭代 map 时,如果在迭代过程中对 map 进行了修改(添加、删除元素),可能会导致迭代器失效、也会产生不确定的结果。

3、 遍历顺序不确定性:map 的迭代顺序是不确定的,每次遍历的结果可能不同。

4、 零值问题:对值为 nil 的 map 操作,如果操作不当会造成程序 Panic。

5、 内存泄漏:在map中添加新的键值对并不会增加 map 本身的大小,而是会增加底层的哈希表的大小。如果不再需要某些键值对,一定要通过 delete() 函数将其从 map 中删除,以释放内存。

常见3个坑案例

map 并发问题

map 不是并发安全的,使用过程中要非常谨慎,尤其在并发读写的场景。

我曾经就在并发高的业务场景下读写 map 造成了业务异常。业务上存在组织架构的公共模块,多个业务方都在使用,最终决定把组织架构模块抽象下单独封装起来提供了一个库提供给业务方使用,其中一个函数就返回了 map。为了提高函数效率底层用了协程池操作这个 map 并且没有对 map 进行加锁保护。我把案例简化下代码如下:

go 复制代码
func TestMapError(t *testing.T) {
	var (
		m  = make(map[int]int)
		wg = sync.WaitGroup{}
	)
	wg.Add(2)
        
	go func() {
		defer wg.Done()

		for i := 0; i < 200000; i++ {
			time.Sleep(100)
			m[i] = i
		}
	}()

	go func() {
		defer wg.Done()

		for i := 0; i < 20000; i++ {
			time.Sleep(100)
			_ = m[1]
		}
	}()

	wg.Wait()
}

代码逻辑比较简单,协程1负责向 map 写数据,协程2负责从 map 中读数据。当你运行上面代码,偶尔会报下面这段错误。

arduino 复制代码
=== RUN   TestMapError
--- PASS: TestMapError (0.00s)
fatal error: concurrent map read and map write
PASS

下面我们再看看并发写入问题

go 复制代码
func TestMapError(t *testing.T) {
	var (
		m  = make(map[int]int)
		wg = sync.WaitGroup{}
	)

	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func(in int) {
			defer wg.Done()

			m[in] = in
		}(i)
	}

	wg.Wait()
}

上段代码执行的结果如下

go 复制代码
=== RUN   TestMapError
fatal error: concurrent map writes
fatal error: concurrent map writes
goroutine 29 [running]:
......

对原因感兴趣的同学应该很好奇为啥会抛这两个错呢?带你看看源码:/runtime/map_fast64.go,你自行 debug 会更清晰。

由于篇幅原因我只框了下面这段源码

蓝色框内做了几件事儿

  1. 计算key的hash值。

  2. 将map状态标记为正在写(h.flags ^= hashWriting)。

  3. hash值与桶数组长度取模得到所处桶。

h.flags ^= hashWriting 后面的代码会有并发问题(比如扩容),因为 map 本身没有加锁,又要保证数据正确性,通过 h.flags&hashWriting != 0 来判断是否有数据写入,所以当你在并发写入时,map 底层会给你抛 "concurrent map writes",让你快速排查问题来着。

再来看看从 map 中读数据,读数据源码也用了 h.flags&hashWriting != 0 判断是否有数据正在写入,后续也有一些 map 的数据初始化,也存在并发问题。所以当并发操作 map 时,map 底层也会抛"concurrent map read and map write"。

同理,删除 map 中的元素,在并发操作的情况下也会抛"concurrent map writes"。

解决方案

map 并发问题解决方案有以下 2 种。

方案一 并发安全的数据结构 sync.map

go 提供并发安全的 map 开箱即用,替换非并发安全 map 就可以了。使用上有一些区别,代码如下:

scss 复制代码
func TestMapError(t *testing.T) {
	var (
		m  = sync.Map{}
		wg = sync.WaitGroup{}
	)
	wg.Add(2)
        
	go func() {
		defer wg.Done()

		for i := 0; i < 20000; i++ {
			time.Sleep(100)
			m.Store(i, i)
		}
	}()

	go func() {
		defer wg.Done()

		for i := 0; i < 20000; i++ {
			time.Sleep(100)
			m.Load(1)
		}
	}()

	wg.Wait()
	fmt.Println("测试完成")
}

不管你运行多少次,都会得到正确的结果哦

它还提供了下面截图部分的语法糖,自己去验证咯。

方案二 对 map 加锁

如果你是一个有追求的同学肯定会对 map 进行二次封装,将复杂的逻辑封装到内部,而不是将加锁逻辑散落在各处。代码如下:

scss 复制代码
import (
	"sync"
	"sync/atomic"
)

type Map[K, T comparable] struct {
	mu   sync.RWMutex
	m    map[K]T
	done uint32
}

func (s *Map[K, T]) init() {
	if atomic.LoadUint32(&s.done) != 0 { // 利用原子操作高性能特性快速快速判断
		return
	}

	s.mu.Lock() // 利用慢路径加锁保证临界区安全
	defer s.mu.Unlock()
	if s.done != 0 {
		return
	}
	s.m = make(map[K]T)
	atomic.StoreUint32(&s.done, 1)
}

func (s *Map[K, T]) Store(k K, val T) {
	s.init()

	s.mu.Lock()
	defer s.mu.Unlock()

	s.m[k] = val
}

func (s *Map[K, T]) LoadOne(k K) (T, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	val, exists := s.m[k]
	return val, exists
}

// LoadAll 底层做了拷贝,防止外部使用map造成并发问题
func (s *Map[K, T]) LoadAll() map[K]T {
	out := make(map[K]T)
	s.mu.RLock()
	defer s.mu.RUnlock()

	for k, v := range s.m {
		out[k] = v
	}
	return out
}

// Delete 删除
func (s *Map[K, T]) Delete(k K) {
	s.mu.Lock()
	defer s.mu.Unlock()

	delete(s.m, k)
}

// LoadAll2Slice 底层做了拷贝
func (s *Map[K, T]) LoadAll2Slice() []T {
	out := make([]T, 0, len(s.m))
	s.mu.RLock()
	defer s.mu.RUnlock()

	for _, v := range s.m {
		out = append(out, v)
	}
	return out
}

func (s *Map[K, T]) Len() int {
	s.mu.RLock()
	defer s.mu.RUnlock()

	return len(s.m)
}

map 二次封装我用了范型,由于 map key 必须是可比较的,所以用了 comparable。另外我用了读写锁,因为读写锁性能更高一些,尤其在读多写少的场景 。

map 二次封装比较简单,我只提供了 Store()、LoadOne()、LoadAll()、LoadAll2Slice()、Delete() 、Len() 方法,可以根据业务场景定制。

重点讲讲 LoadOne()、LoadAll()、LoadAll2Slice() 这三个方法吧

  1. LoadOne(),通过 key 查 val 的场景,应该是非常常见的。

  2. LoadAll(),底层拷贝了一个 map,外部操作 map,并不影响原始 map 操作造成数据异常。

  3. LoadAll2Slice(),同理,跟 LoadAll() 方法类似只是返回了切片。

另外我这里用了快慢路径,不懂的可以看看这篇文章深入GO之sync.Once,80%研发同学都不了解其中一行重要代码 - 掘金

下面看看使用方代码。

scss 复制代码
func TestCustomMap(t *testing.T) {
	var (
		wg = sync.WaitGroup{}
		m  = new(Map[int, int])
	)

	wg.Add(2)
	// 写入
	go func() {
		defer wg.Done()
		for i := 0; i < 200; i++ {
			m.Store(i, i)
		}
	}()

	// 读取
	go func() {
		defer wg.Done()
		for i := 0; i < 200; i++ {
			m.LoadOne(i)
		}
	}()

	wg.Wait()
	fmt.Printf("测试完成,len=%d\n", m.Len())

	var key = 1
	val, exists := m.LoadOne(key)
	fmt.Printf("val=%v,exists=%v\n", val, exists)

	m.Delete(key)
	fmt.Printf("删除后元素个数,len=%d\n", m.Len())
	fmt.Printf("删除后元素个数,vals=%d\n", m.LoadAll())
	fmt.Printf("删除后元素个数,vals=%d\n", m.LoadAll2Slice())
}

我们的业务场景中,这三个方法是非常常用的。尤其是 LoadAll()、LoadAll2Slice()。拿到数据后遍历做各自的业务。

map 顺序问题

map 是一种哈希表的实现,它是无序的。在哈希表中,元素的存储位置是由它们的键经过哈希函数计算得到的。虽然在哈希表中,元素的存储位置一般是按照哈希函数计算的,但并不保证遍历时按照这个顺序输出。举个案例如下:

go 复制代码
func TestRange(t *testing.T) {
	var (
		m = make(map[int]int)
	)
	for i := 0; i < 10; i++ {
		m[i] = i
	}

	for k, v := range m {
		fmt.Printf("key=%d,val=%d\n", k, v)
	}
}

上段代码的结果输出如下:

ini 复制代码
=== RUN   TestRange
key=8,val=8
key=9,val=9
key=0,val=0
key=2,val=2
key=3,val=3
key=6,val=6
key=7,val=7
key=1,val=1
key=4,val=4
key=5,val=5
--- PASS: TestRange (0.00s)
PASS

所以,你的场景对顺序有要求最好不要通过 map 来存,否则只能通过复杂的操作来保证顺序得不偿失。

值为 nil 的 map 进行读写

在值为 nil 的 map 中添加元素会 Panic,其他操作都不会报错。

go 复制代码
func TestNilMap(t *testing.T) {
	var m map[string]string

	v, exists := m["2"]
	if exists {
		log.Printf("exists key%+v", v)
	} else {
		log.Printf("not fond...")
	}

	delete(m, "1")

	m["22"] = "22" // 添加元素会报 Panic,其他操作都不会报错
}

上段代码结果输出如下:

go 复制代码
=== RUN   TestNilMap
2024/03/17 14:44:48 not fond...
--- FAIL: TestNilMap (0.00s)
panic: assignment to entry in nil map [recovered]
	panic: assignment to entry in nil map

goroutine 18 [running]:

为了避免零值问题,在使用 map 前一定要初始化。

最后总结

本文讨论了 map 中常见的坑,并且分析了坑背后的原因和解决方案。我简单总结下。

  1. 为了保证 map 中的键和值的类型正确性,有 2 种方案,1、加锁。2、使用并发安全的数据结构 snyc.Map,大部分场景对并发的要求并不高,所以我更喜欢对 map 进行二次封装,自己可操作性更多一些。

  2. 如果对顺序有要求,不要使用 map ,用 slice 、数组就可以了,一般来讲,用 map 的场景对顺序都是没有要求的。

  3. 值为 nil 的 map 中添加元素会报 Panic,其他操作都不会报错。

相关推荐
心软小念44 分钟前
外包干了27天,技术退步明显。。。。。
软件测试·面试
2401_882727572 小时前
低代码配置式组态软件-BY组态
前端·后端·物联网·低代码·前端框架
追逐时光者3 小时前
.NET 在 Visual Studio 中的高效编程技巧集
后端·.net·visual studio
大梦百万秋3 小时前
Spring Boot实战:构建一个简单的RESTful API
spring boot·后端·restful
斌斌_____4 小时前
Spring Boot 配置文件的加载顺序
java·spring boot·后端
路在脚下@4 小时前
Spring如何处理循环依赖
java·后端·spring
海绵波波1075 小时前
flask后端开发(1):第一个Flask项目
后端·python·flask
小奏技术6 小时前
RocketMQ结合源码告诉你消息量大为啥不需要手动压缩消息
后端·消息队列
小k_不小6 小时前
C++面试八股文:指针与引用的区别
c++·面试
AI人H哥会Java8 小时前
【Spring】控制反转(IoC)与依赖注入(DI)—IoC容器在系统中的位置
java·开发语言·spring boot·后端·spring