哈希表(Hash Table)这个数据结构。它实现的就是 key-value 之间的映射关系,主要提供的方法包括 Add、Lookup、Delete 等。因为这种数据结构是一个基础的数据结构,每个 key 都会有一个唯一的索引值,通过索引可以很快地找到对应的值,所以使用哈希表进行数据的插入和读取都是很快的。Go 语言本身就内建了这样一个数据结构,也就是 map 数据类型。
基本使用方法
Go的内建类型:map[key]value
其中,key 类型的 K 必须是可比较的(comparable),也就是可以通过 == 和 != 操作符 进行比较;value 的值和类型无所谓,可以是任意的类型,或者为 nil。
在 Go 语言中,bool、整数、浮点数、复数、字符串、指针、Channel、接口都是可比较 的,包含可比较元素的 struct 和数组,这俩也是可比较的,而 slice、map、函数值都是不 可比较的。
那么,上面这些可比较的数据类型都可以作为 map 的 key 吗?显然不是。通常情况下, 我们会选择内建的基本类型,比如整数、字符串做 key 的类型,因为这样最方便。
这里有一点需要注意,如果使用 struct 类型做 key 其实是有坑的,因为如果 struct 的某个 字段值修改了,查询 map 时无法获取它 add 进去的值,也就识别不到值了。
map 是无序的,所以当遍历一个 map 对象的时候,迭代的元素的顺序是不确定的,无法 保证两次遍历的顺序是一样的,也不能保证和插入的顺序一致。那怎么办呢?如果我们想 要按照 key 的顺序获取 map 的值,需要先取出所有的 key 进行排序,然后按照这个排序 的 key 依次获取对应的值。而如果我们想要保证元素有序,比如按照元素插入的顺序进行 遍历,可以使用辅助的数据结构,比如orderedmap,来记录插入顺序。
使用map常见错误
一、未初始化
map使用之前要先初始化,不然会panic。初始化话的目的是为map分配内存,因为像map,slice,channel都是引用类型,使用前都要先初始化。
二、并发读写
对于 map 类型,另一个很容易犯的错误就是并发访问问题。 如果没有注意到并发问题,程序在运行的时候就有可能出现并发读写导致的 panic。
Go 内建的 map 对象不是线程(goroutine)安全的,并发读写的时候运行时会有检查, 遇到并发问题就会导致 panic。
例子:
func main() {
var m = make(map[int]int,10) // 初始化一个map
go func() {
for {
m[1] = 1 //设置key
}
}()
go func() {
for {
_ = m[2] //访问这个map
}
}()
select {}
}
虽然这段代码看起来是读写 goroutine 各自操作不同的元素,貌似 map 也没有扩容的问 题,但是运行时检测到同时对 map 对象有并发访问,就会直接 panic。panic 信息会告诉 我们代码中哪一行有读写问题,根据这个错误信息你就能快速定位出来是哪一个 map 对象 在哪里出的问题了。
实现线程安全的map
避免 map并发读写panic的方式之一就是加锁,考虑到读写性能,可以使用读写锁提供性能。
比较遗憾的是,目前 Go 还没有正式发布泛型特性,我们还不能实现一个通用的支持泛型 的加锁 map。但是,将要发布的泛型方案已经可以验证测试了,离发布也不远了,也许发 布之后 sync.Map 就支持泛型了。
当然了,如果没有泛型支持,我们也能解决这个问题。我们可以通过 interface{}来模拟泛 型,但还是要涉及接口和具体类型的转换,比较复杂,还不如将要发布的泛型方案更直 接、性能更好。
这里我以一个具体的 map 类型为例,来演示利用读写锁实现线程安全的 map[int]int 类 型:
type RWMap struct { // 一个读写锁保护的线程安全的map
sync.RWMutex // 读写锁保护下面的map字段
m map[int]int
}
// 新建一个RWMap
func NewRWMap(n int) *RWMap {
return &RWMap{
m: make(map[int]int, n),
}
}
func (m *RWMap) Get(k int) (int, bool) { //从map中读取一个值
m.RLock()
defer m.RUnlock()
v, existed := m.m[k] // 在锁的保护下从map中读取
return v, existed
}
func (m *RWMap) Set(k int, v int) { // 设置一个键值对
m.Lock() // 锁保护
defer m.Unlock()
m.m[k] = v
}
func (m *RWMap) Delete(k int) { //删除一个键
m.Lock() // 锁保护
defer m.Unlock()
delete(m.m, k)
}
func (m *RWMap) Len(k int) {
m.RLock() // 锁保护
defer m.RUnlock()
return len(m.m)
}
func (m *RWMap) Each(f func(k, v int) bool) { // 遍历map
m.RLock() //遍历期间一直持有读锁
defer m.RUnlock()
for k, v := range m.m {
if !f(k, v) {
return
}
}
}
正如这段代码所示,对 map 对象的操作,无非就是增删改查和遍历等几种常见操作。我们 可以把这些操作分为读和写两类,其中,查询和遍历可以看做读操作,增加、修改和删除 可以看做写操作。如例子所示,我们可以通过读写锁对相应的操作进行保护。
分片加锁:更高效的并发 map
虽然使用读写锁可以提供线程安全的 map,但是在大量并发读写的情况下,锁的竞争会非 常激烈。锁是性能下降的万恶之源之一。
在并发编程中,我们的一条原则就是尽量减少锁的使用。一些单线程单进程的应用(比如 Redis 等),基本上不需要使用锁去解决并发线程访问的问题,所以可以取得很高的性 能。但是对于 Go 开发的应用程序来说,并发是常用的一个特性,在这种情况下,我们能 做的就是,尽量减少锁的粒度和锁的持有时间。
你可以优化业务处理的代码,以此来减少锁的持有时间,比如将串行的操作变成并行的子 任务执行。不过,这就是另外的故事了,今天我们还是主要讲对同步原语的优化,所以这 里我重点讲如何减少锁的粒度。
减少锁的粒度常用的方法就是分片(Shard),将一把锁分成几把锁,每个锁控制一个分 片。Go 比较知名的分片并发 map 的实现是orcaman/concurrent-map。
它默认采用32个分片,GetShard是一个关键的方法,能够根据key计算出分片索引。
var SHARD_COUNT = 32
// 分成SHARD_COUNT个分片的map
type ConcurrentMap []*ConcurrentMapShared
// 通过RWMutex保护的线程安全的分片,包含一个map
type ConcurrentMapShared struct {
items map[string]interface{}
sync.RWMutex // Read Write mutex, guards access to internal map.
}
// 创建并发map
func New() ConcurrentMap {
m := make(ConcurrentMap, SHARD_COUNT)
for i := 0; i < SHARD_COUNT; i++ {
m[i] = &ConcurrentMapShared{items: make(map[string]interface{})}
}
return m
}
// 根据key计算分片索引
func (m ConcurrentMap) GetShard(key string) *ConcurrentMapShared {
return m[uint(fnv32(key))%uint(SHARD_COUNT)]
}
增加或者查询的时候,首先根据分片索引得到分片对象,然后对分片对象加锁进行操作:
func (m ConcurrentMap) Set(key string, value interface{}) {
// 根据key计算出对应的分片
shard := m.GetShard(key)
shard.Lock() //对这个分片加锁,执行业务操作
shard.items[key] = value
shard.Unlock()
}
func (m ConcurrentMap) Get(key string) (interface{}, bool) {
// 根据key计算出对应的分片
shard := m.GetShard(key)
shard.RLock()
// 从这个分片读取key的值
val, ok := shard.items[key]
shard.RUnlock()
return val, ok
}
当然,除了 GetShard 方法,ConcurrentMap 还提供了很多其他的方法。这些方法都是 通过计算相应的分片实现的,目的是保证把锁的粒度限制在分片上。
好了,到这里我们就学会了解决 map 并发 panic 的两个方法:加锁和分片。
使用并发 map 的过程中,加锁和分片加锁这两种方案都比较常用,如果是追求更 高的性能,显然是分片加锁更好,因为它可以降低锁的粒度,进而提高访问此 map 对象的 吞吐。如果并发性能要求不是那么高的场景,简单加锁方式更简单。
接下来,我会继续给你介绍 sync.Map,这是 Go 官方线程安全 map 的标准实现。虽然是 官方标准,反而是不常用的,为什么呢?一句话来说就是 map 要解决的场景很难描述,很 多时候在做抉择时根本就不知道该不该用它。但是呢,确实有一些特定的场景,我们需要 用到 sync.Map 来实现,所以还是很有必要学习这个知识点。具体什么场景呢,我慢慢给 你道来。
应对特殊场景的 sync.Map
Go 内建的 map 类型不是线程安全的,所以 Go 1.9 中增加了一个线程安全的 map,也就 是 sync.Map。但是,我们一定要记住,这个 sync.Map 并不是用来替换内建的 map 类型 的,它只能被应用在一些特殊的场景里。
那这些特殊的场景是啥呢?官方的文档中指出,在以下两个场景中使用 sync.Map,会 比使用 map+RWMutex 的方式,性能要好得多: 这两个场景说得都比较笼统,而且,这些场景中还包含了一些特殊的情况。所以,官方建 议你针对自己的场景做性能评测,如果确实能够显著提高性能,再使用 sync.Map。
- 只会增长的缓存系统中,一个 key 只写入一次而被读很多次;
- 多个 goroutine 为不相交的键集读、写和重写键值对。
这么来看,我们能用到sync.Map 的场景确实不多。即使是sync.Map 的作者Bryan C.Mills,也很少使用sync.Map,即便是在使用sync.Map 的时候,也是需要临时查询它的API,才能清楚记住它的功能。所以,我们可以把 sync.Map看成一个生产环境中很少使用的同步原语。