Golang 语言中 map 的键值类型选择,它是并发安全的吗?

简介: Golang 语言中 map 的键值类型选择,它是并发安全的吗?

01

介绍

关于 golang 语言的 map,已经在「Go 基础」系列文章中介绍过,文末会附上文章链接,建议还没有阅读的读者阅读。我们知道 map 的键必须支持判等操作,本文我们主要讨论的话题是 golang 语言的 map 键类型怎么选择,和 map 是并发安全的吗?

02

golang 原生 map 键类型选择

在 golang 语言中,map 可以看作是一个 hash 表,其中 hash 的 key 的类型是受限的,而 val 的类型可以是任意类型。hash 表持有一定数量的 hash 桶, hash 桶均匀存储 hash 表的 key-val 键值对。

在 hash 表中查找某个 key 的 val,我们需要传递 key 给这个 hash 表,在 golang 语言的 map 中,hash 表会先使用 hash 函数把 key 转换为 hash 值,hash 表通过 hash 值的低几位去查找 hash 桶,然后在去查找到的 hash 桶中查找 key,因为 key-val 键值对是成对存储的,所以找到 key 就找到了 val。

现在我们知道,key 是由转换的 hash 值代表的,所以在 golang 语言的 map 中,存储的是 hash 值。

有了上面知识的铺垫,我们回到 map 的键为什么必须支持判等操作的问题上,这是因为我们前面提到的,golang 的 key 是被转换为 hash 值,和 val 成对存储在 hash 桶中,也就是说 golang 需要先定位到一个 hash 桶,然后使用 key 转换的 hash 值与该 hash 桶中存储的 hash 值逐一比对,如果没有相等的,直接返回结果,如果有相等的,就再用 key 本身去比对一次,原因是为了避免 hash 碰撞,只有 hash 值和 key 比对都相等,证明查找到了 key-val 键值对。所以,大家应该明白为什么 golang 语言中 map 的 key 必须支持判等了吧。

接下来,我们讨论一下在 golang 语言中,哪些类型不支持判等操作呢?golang 语言的funcmapslice不支持判等操作,所以它们不能用作 map 的 key。

此外,在 golang 中还有一个空接口类型interface{},它可以保存任意类型的值,所以如果空接口类型保存上述三种不支持判等操作的类型,会发生什么问题呢?

func main() {
 m1 := map[interface{}]string{
  1:       "A",
  "2":     "B",
  []int{}: "C",
 }
 fmt.Println(m1)
}

Output:

go run main.go 
panic: runtime error: hash of unhashable type []int
goroutine 1 [running]:
main.main()
        /Users/frank/Desktop/go-concurrent/lesson09/map-key/main.go:8 +0x145
exit status 2

阅读上面的代码,我们将 slice 作为 interface{} 的值,用作 map 的 key,golang 编译器并没有提示错误,但是在运行时引发 panic。我们知道,golang 作为静态语言,其中一个好处就是可以在编译期间及时发现错误,而空接口类型作为 map 的 key 时,即使使用不支持判等操作的类型作为空接口的值,也不会引发编译器错误,而是在运行时引发 panic,这就失去了 golang 编译错误检查的优势,所以我们尽量不要使用空接口类型作为 map 的 key 类型,或者我们可以确保空接口保存的值的类型是支持判等操作的。

此外,数组类型也和空接口类型存在相同的问题,即如果 map 的 key 的类型是数组类型,我们需要确保数组元素的类型不是funcmapslice

03

构建并发安全的 map

golang 语言的 map 不是并发安全的,即在同一段时间,使用多个 goroutine 操作同一个 map是不安全的。

var m = make(map[int]int)
func main () {
 for i := 0; i < 3; i++ {
  go store(i, i)
  go load(i)
 }
 time.Sleep(time.Millisecond * 100)
 fmt.Println(m)
}
func store(key,val int) {
 m[key] = val
}
func load(key int) int {
 return m[key]
}

Output:

go run main.go 
fatal error: concurrent map writes
goroutine 8 [running]:
runtime.throw(0x10ca6a2, 0x15)
        /usr/local/go/src/runtime/panic.go:1117 +0x72 fp=0xc000055760 sp=0xc000055730 pc=0x10327f2
runtime.mapassign_fast64(0x10b1f80, 0xc000100180, 0x1, 0x0)
        /usr/local/go/src/runtime/map_fast64.go:101 +0x33e fp=0xc0000557a0 sp=0xc000055760 pc=0x1010c3e
main.store(0x1, 0x1)
        /Users/frank/Desktop/go-concurrent/lesson09/map/main.go:24 +0x45 fp=0xc0000557d0 sp=0xc0000557a0 pc=0x10a3525
runtime.goexit()
        /usr/local/go/src/runtime/asm_amd64.s:1371 +0x1 fp=0xc0000557d8 sp=0xc0000557d0 pc=0x10645c1
created by main.main
        /Users/frank/Desktop/go-concurrent/lesson09/map/main.go:16 +0x4c
goroutine 1 [sleep]:
time.Sleep(0x5f5e100)
        /usr/local/go/src/runtime/time.go:193 +0xd2
main.main()
        /Users/frank/Desktop/桌面 - 魏如博的MacBook Pro/go-concurrent/lesson09/map/main.go:19 +0x89
exit status 2

阅读上面这段代码,我们发现通过多个 goroutine 操作同一 map,运行时引发致命错误 fatal error: concurrent map writes,并且 goroutine 数量越多,出错几率越大。

我们可以使用原生 map,配合 sync 包的 Mutex 和 RWMutex 构建并发安全的 map。

// 构建并发安全的 map
type safeMap struct {
 m map[int]int
 sync.RWMutex
}
// 构造函数
func newSafeMap() *safeMap {
 sm := new(safeMap)
 sm.m = make(map[int]int)
 return sm
}
// 写
func (s *safeMap) store (key, val int) {
 s.Lock()
 s.m[key] = val
 s.Unlock()
}
// 读
func (s *safeMap) load (key int) int {
 s.RLock()
 val := s.m[key]
 s.RUnlock()
 return val
}
func main () {
 sm := newSafeMap()
 for i := 0; i < 10; i++ {
  go sm.store(i, i)
  go sm.load(i)
 }
 time.Sleep(time.Millisecond * 100)
 fmt.Println(sm.m)
}

Output:

go run main.go 
map[0:0 1:1 2:2 3:3 4:4 5:5 6:6 7:7 8:8 9:9]

阅读上面这段代码,我们通过 RWMutext 和原生 map,实现并发安全的 map。

04

golang 并发安全 map

即便可以通过使用锁和原生 map,构建并发安全的 map。golang 用户还是希望官方可以发布一个标准的并发安全 map,经过 golang 用户多年在社区的吐槽,官方在 golang 1.9 版本加入了并发安全 map - sync.Map

type Map
    func (m *Map) Delete(key interface{})
    func (m *Map) Load(key interface{}) (value interface{}, ok bool)
    func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool)
    func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)
    func (m *Map) Range(f func(key, value interface{}) bool)
    func (m *Map) Store(key, value interface{})

sync.Map 提供了一些读写操作方法,并且时间复杂度都是 O(1),它与使用锁构建并发安全 map 的区别是,它通过尽可能避免使用锁,从而减少了锁的争用。

通过阅读 sync.Map 的源码,可以发现它的方法的参数都是 interface{} 空接口类型的,我们在前面也提到了,map 的 key 类型需要支持判等操作,不能使用 funcmapslice 用作 map 的 key 类型。因为这些类型不能被编译器提示错误,只能在运行时引发 panic,所以我们仍然要特别注意 sync.Map 类型的 key 类型。

var m sync.Map
func main () {
 for i := 0; i < 10; i++ {
  go store(i, i)
  go load(i)
 }
 time.Sleep(time.Millisecond * 100)
 m.Range(list)
}
func store (key, val int) {
 m.Store(key, val)
}
func load (key int) interface{} {
 if val, ok := m.Load(key); ok {
  return val
 }
 return nil
}
func list (key, val interface{}) bool {
 fmt.Printf("key:%d=>val:%d\n", key, val)
 return true
}

Output:

key:2=>val:2
key:3=>val:3
key:0=>val:0
key:6=>val:6
key:1=>val:1
key:4=>val:4
key:5=>val:5
key:7=>val:7
key:8=>val:8
key:9=>val:9

阅读上面的代码,我们可以发现使用官方提供的 sync.Map,可以更方便地实现并发安全的 map。

05

总结

本文我们讨论了 map 的键类型怎么选择,和 map 是并发安全的吗?介绍了 map 的键类型为什么需要支持判等操作,通过示例代码,证明原生 map 不是并发安全的,并且介绍怎么通过使用 sync 包的锁和原生 map 构建并发安全的 map,还介绍了官方提供的并发安全的 map - sync.Map。而且强调了 sync.Map 的键类型也需要支持判等操作。

推荐阅读:

Go 语言学习之map

参考资料:

https://golang.org/pkg/sync/#Map

目录
相关文章
|
4天前
|
安全 Go
Golang深入浅出之-Go语言模板(text/template):动态生成HTML
【4月更文挑战第24天】Go语言标准库中的`text/template`包用于动态生成HTML和文本,但不熟悉其用法可能导致错误。本文探讨了三个常见问题:1) 忽视模板执行错误,应确保正确处理错误;2) 忽视模板安全,应使用`html/template`包防止XSS攻击;3) 模板结构不合理,应合理组织模板以提高可维护性。理解并运用这些最佳实践,能提升Go语言模板编程的效率和安全性,助力构建稳健的Web应用。
16 0
|
21小时前
|
安全 测试技术 Go
Golang深入浅出之-Go语言单元测试与基准测试:testing包详解
【4月更文挑战第27天】Go语言的`testing`包是单元测试和基准测试的核心,简化了测试流程并鼓励编写高质量测试代码。本文介绍了测试文件命名规范、常用断言方法,以及如何进行基准测试。同时,讨论了测试中常见的问题,如状态干扰、并发同步、依赖外部服务和测试覆盖率低,并提出了相应的避免策略,包括使用`t.Cleanup`、`t.Parallel()`、模拟对象和检查覆盖率。良好的测试实践能提升代码质量和项目稳定性。
6 1
|
21小时前
|
运维 监控 Go
Golang深入浅出之-Go语言中的日志记录:log与logrus库
【4月更文挑战第27天】本文比较了Go语言中标准库`log`与第三方库`logrus`的日志功能。`log`简单但不支持日志级别配置和多样化格式,而`logrus`提供更丰富的功能,如日志级别控制、自定义格式和钩子。文章指出了使用`logrus`时可能遇到的问题,如全局logger滥用、日志级别设置不当和过度依赖字段,并给出了避免错误的建议,强调理解日志级别、合理利用结构化日志、模块化日志管理和定期审查日志配置的重要性。通过这些实践,开发者能提高应用监控和故障排查能力。
8 1
|
21小时前
|
安全 Go
Golang深入浅出之-Go语言标准库中的文件读写:io/ioutil包
【4月更文挑战第27天】Go语言的`io/ioutil`包提供简单文件读写,适合小文件操作。本文聚焦`ReadFile`和`WriteFile`函数,讨论错误处理、文件权限、大文件处理和编码问题。避免错误的关键在于检查错误、设置合适权限、采用流式读写及处理编码。遵循这些最佳实践能提升代码稳定性。
5 0
|
1天前
|
安全 Unix Go
Golang深入浅出之-Go语言中的时间与日期处理:time包详解
【4月更文挑战第26天】Go语言的`time`包提供处理日期和时间的功能,包括`time.Time`类型、时间戳、格式化与解析。本文讨论了核心概念、常见问题(如时区处理、格式字符串混淆、超时控制和并发安全)及解决方法。推荐使用`time.LoadLocation`管理时区,熟悉时间格式规则,用`context`精确控制超时,并注意并发安全。文中通过代码示例展示了如何获取格式化时间、计算时间差以及创建定时任务。学习和应用这些知识可提高程序的健壮性和准确性。
15 2
|
1天前
|
XML JSON Go
Golang深入浅出之-XML处理在Go语言中的实现:encoding/xml包
【4月更文挑战第26天】Go语言的`encoding/xml`库提供XML处理,包括序列化和反序列化。本文讨论了XML处理的基础,如`xml.Marshal`和`xml.Unmarshal`函数,以及常见问题和易错点,如标签命名、结构体嵌套、omitempty标签和命名空间。建议遵循标签命名规则,正确处理嵌套和属性,谨慎使用omitempty,以及理解并有效利用命名空间。文中还给出了基础示例和处理XML属性的代码示例,帮助读者掌握XML处理技巧。
12 1
Golang深入浅出之-XML处理在Go语言中的实现:encoding/xml包
|
1天前
|
JSON JavaScript 前端开发
Golang深入浅出之-Go语言JSON处理:编码与解码实战
【4月更文挑战第26天】本文探讨了Go语言中处理JSON的常见问题及解决策略。通过`json.Marshal`和`json.Unmarshal`进行编码和解码,同时指出结构体标签、时间处理、omitempty使用及数组/切片区别等易错点。建议正确使用结构体标签,自定义处理`time.Time`,明智选择omitempty,并理解数组与切片差异。文中提供基础示例及时间类型处理的实战代码,帮助读者掌握JSON操作。
12 1
Golang深入浅出之-Go语言JSON处理:编码与解码实战
|
1天前
|
安全 Go 开发者
Golang深入浅出之-Go语言模板(text/template):动态生成HTML
【4月更文挑战第25天】Go语言的`text/template`和`html/template`库提供动态HTML生成。本文介绍了模板基础,如基本语法和数据绑定,以及常见问题和易错点,如忘记转义、未初始化变量、复杂逻辑处理和错误处理。建议使用`html/template`防止XSS攻击,初始化数据结构,分离业务逻辑,并严谨处理错误。示例展示了条件判断和循环结构。通过遵循最佳实践,开发者能更安全、高效地生成HTML。
7 0
|
3天前
|
中间件 Go API
Golang深入浅出之-Go语言标准库net/http:构建Web服务器
【4月更文挑战第25天】Go语言的`net/http`包是构建高性能Web服务器的核心,提供创建服务器和发起请求的功能。本文讨论了使用中的常见问题和解决方案,包括:使用第三方路由库改进路由设计、引入中间件处理通用逻辑、设置合适的超时和连接管理以防止资源泄露。通过基础服务器和中间件的代码示例,展示了如何有效运用`net/http`包。掌握这些最佳实践,有助于开发出高效、易维护的Web服务。
15 1
|
3天前
|
数据管理 Go 开发者
Golang深入浅出之-Go语言上下文(context)包:处理取消与超时
【4月更文挑战第25天】Go语言中的`context`包在并发、网络请求和长任务中至关重要,提供取消、截止时间和元数据管理。本文探讨`context`基础,如`Background()`、`TODO()`、`WithCancel()`、`WithDeadline()`和`WithTimeout()`。常见问题包括不当传递、过度使用`Background()`和`TODO()`以及忽略错误处理。通过取消和超时示例,强调正确传递上下文、处理取消错误和设置超时以提高应用健壮性和响应性。正确使用`context`是构建稳定高效Go应用的关键。
12 1