前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >深入理解Golang sync.Map设计与实现

深入理解Golang sync.Map设计与实现

原创
作者头像
路之遥
发布2023-09-14 15:26:46
3870
发布2023-09-14 15:26:46
举报
文章被收录于专栏:luzhiyaoluzhiyao

Golang 为并发编程提供了多种并发原语(Mutex、RWMutex、sync.Map),用于临界区的数据访问和保护;开发应用时,面对不同的场景如何选择合适的并发原语,使功能正常实现的同时提供更高的性能;在互联网应用中,由并发原语保护的临界区从本质上来说无非三种情况:读多写少、写多读少、读写一致。 上篇文章介绍了 sync.RWMutex并发原语,其适用于写多读少的场景;Mutex、RWMutex 其保护的都是某个临界状态区,无论是访问、存储都使用锁进行保护;当操作频繁且要求性能的情况下,锁的优化已无法满足业务需求,此时就需要考虑额外的方式来提升性能。对于读多写少的场景,最常用的方式是进行读写分离,将访问压力分散至其它组件。

Golang为了支持读多写少的场景,提供了sync.Map并发原语,由普通map、Mutex与原子变量组合而成,作为一个并发安全的map,部分情况下读、写数据通过原子操作避免加锁,从而提高临界区访问的性能,同时在高并发的情况下仍能保证数据的准确性,支持Load、Store、 Delete、 Range等操作,可以实现对sync.Map的遍历以及根据key获取value;sync.Map可以有效地替代锁的使用,提高程序的执行效率。

设计原理

讨论设计原理前,先观察下sync.Map的结构定义,主要三种元素组成:map、mutex、atomic

代码语言:javascript
复制
type Map struct {
    mu Mutex
    read atomic.Value // readOnly
    dirty map[interface{}]*entry
    misses int
}

// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
    m       map[interface{}]*entry
    amended bool
}

sync.Map 采用了装饰器 模式,在普通的map上组合其它结构进行修饰,在利用map快速定位某个元素的同时增加了读写分离的功能,当元素在只读组件read中时,使用原子操作避免加锁快速访问其数据,在sync.Map中新增元素或访问不存在的元素时操作dirty组件时使用锁操作进行保护,当只读组件read的命中率降低至一定阈值时触发状态转换,将dirty状态提升为最新的read状态,以降低访问sync.Map时加锁的次数。

下面将介绍sync.Map中每个状态字段的功能及其含义

  1. read 提供读写分离的读功能,使用atomic.Value原子操作提供并发的能力,当数据在read中时原子操作可以避免加锁提供并发访问的能力;当read的命中率降低到一定阈值时,触发将脏数据dirty更新为read,此时需要加锁保护。read实际使用map存储数据,它存储的*entry字段值可以使用CAS操作并发更新,且该*entry与dirty中存储的值指向同一地址,因此CAS修改后操作结果,read与dirty都可以观察到。
    1. Map.read的原子状态中实际存储的结构为readOnly,它是一个“只读数据”,此处的只读是指readOnly.m的值不会修改,而不是说它存储的key的value值不会修改;因此数据库概念中理解的读写分离不同。
    2. 其中m存储k-v数据,是dirty的一个子集,注意的是:它存储的某个key的数据值与dirty中该key存储的值指向同一个地址,因此当修改read中某个key的值时,dirty中该key的值也会同步修改;
    3. amended 表示dirty中是否含有readOnly.m不存在的数据.
  2. dirty 提供读写分离的写功能,是一个非线程安全的map,用Mutex锁进行保护。包含所有key的数据,其中key可以分为两类,仅存在于dirty的新数据、read与dirty中都存在的数据。当查询某个key时,如果read中不存在,且其amended状态为true,表示dirty中存在read未包含的数据,需要再查询dirty中是否包含该key.
  3. mu 互斥锁保护 read、dirty,在两种情况下需要加锁对sync.Map的状态进行保护;1. 从dirty中读写数据时,需要使用互斥锁保护,避免并发操作导致非线程安全的map触发panic;2. 从read状态更新开始累计统计加锁操作的数量misses(即命中read状态失败的次数),如果命中失败的次数大于dirty中元素的数量时,sync.Map的命中率太低需要将dirty提升为read,提升过程中使用锁进行保护。
  4. misses 累计从read更新后进行加锁操作的数量(即read状态命中失败的次数)(例如:当查询key时,如果read中未找到,需要加锁查询dirty;或向 dirty中写入数据需要加锁保护),当misses值大于等于dirty中包含的元素数量时,将dirty提升为read,并将dirty置为nil.

功能实现

sync.Map提供了Load、Store、Delete、Range基础操作,以及LoadAndDelete、LoadOrStore复合操作,下面将介绍sync.Map如何实现这些功能、以及在读多写少情况下提供高性能的原因。

Load 读取操作

  1. 从read只读的原子状态中查找,避免加锁,如果存在,则返回数据;如果不存在,且amended为false,表示dirty未包含read中不存在的数据,直接返回.
    1. 快速路径,使用原子操作,避免加锁
  2. read中不存在且amended为true(表示dirty中含有read不存在的数据),进入慢路径,查询dirty;此时先上锁,再次查询read,防止在获取互斥锁期间 dirty提升为read;仍然不存在且amended仍为true,则查寻dirty,无论是否找到,都调用missLocked将统计值misses递增,表示使用了一次互斥锁。
代码语言:javascript
复制
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
    // 1. 先查询只读的原子状态
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    // 2. 不存在,且amended为true,进入慢路径
    if !ok && read.amended {
        // 2.1 获取互斥锁
        m.mu.Lock()
        // 2.2 再次查询只读的原子状态,存在获取互斥锁期间read状态被更新的情况
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        // 2.3 仍然不存在,查询dirty
        if !ok && read.amended {
            e, ok = m.dirty[key]
            // 2.4 将统计数据累加,达到条件时,将dirty提升为read
            m.missLocked()
        }
        m.mu.Unlock()
    }
    if !ok {
        return nil, false
    }
    // 返回存储的数据
    return e.load()
}

missLocked 有两个功能:

  1. 累计read更新后命中丢失的次数
  2. 当命中丢失次数达到阈值时,将dirty提升为read,并将dirty置为nil,misses状态清0;将dirty提升read后,提升下次只读状态read查询的命中率,避免加锁。
代码语言:javascript
复制
func (m *Map) missLocked() {
     m.misses++
     if m.misses < len(m.dirty) {
         return
     }
     m.read.Store(readOnly{m: m.dirty})
     m.dirty = nil
     m.misses = 0
}

entry.load: sync.Map的 read、dirty中存储数据时,实际用户的数据是存储在entry.p中,存储了用户对象的地址,当从read或dirty查询到对象时,需要使用load 方法加载出用户对象。

如果entry.p为nil或expunged,则表示用户对象已被删除。

代码语言:javascript
复制
type entry struct {
     p unsafe.Pointer // *interface{}
}
func (e *entry) load() (value interface{}, ok bool) {
     p := atomic.LoadPointer(&e.p)

     if p == nil || p == expunged {
        return nil, false
     }
     return *(*interface{})(p), true
}

entry.p 存储值逻辑上有3种状态,value(存储了用户对象)、nil、expunged.

  1. value: 存储了用户对象,允许从一个old_value更新到new_value
  2. nil: 当删除对应的key时,不会立即将key从sync.Map中删除,而是将它的值设为nil,表示逻辑上删除;当map运行过程中,从只读的read状态构建写状态dirty时,对于值为nil的key,将其设置为expunged态,对于expunged态的key不写入dirty,当下次dirty提升为read状态时,expunged态的key由于未被任何状态记录从而被统一删除。
  3. expunged: 将一个key标识为擦除,处于该状态的key只存在于read状态中,dirty中不存在;因此当存储一个key对应的值时,如果它key对应的状态为擦除态,需要先将其修改为nil添加到dirty中,才可以更新entry对应的p值;如果直接由expunged更新为new_value,会导致read与dirty状态不一致。
    1. 一个key的删除,从逻辑上立即需要两轮 dirty->read的提升才会被真正删除;在删除key后的第一次dirty->read提升,将key的值从nil -> expunged;在第二轮将标记为expunged值丢弃由GC回收.

Store 存储操作

将一个新对象记录到sync.Map或更新已有的对象,对于不同的对象,使用不同的方式提供执行效率。

  1. 更新一个存在于read状态中的非擦除对象时,使用CAS原子操作避免加锁,提高执行效率。
  2. 更新一个存在的擦除对象时,需要加锁将对象设置为nil,添加到dirty中,再从nil更新为新值。
  3. 更新一个存在于dirty中的对象时,加锁将它的值设置为new_value
  4. 更新一个新对象时,加锁更新read的状态amended为true,表示dirty含有read不存在的对象,并将新key对应的对象存入dirty

Note: 在存储对象时,虽然某些情况下存在加锁行为,但并未累计加锁数量 misses.

代码语言:javascript
复制
func (m *Map) Store(key, value interface{}) {
     // 1. 更新已存在的非擦除态对象
     // 使用CAS原子操作,避免加锁
     read, _ := m.read.Load().(readOnly)
     if e, ok := read.m[key]; ok && e.tryStore(&value) {
        return
     }

     // 2. 加锁
     m.mu.Lock()
     read, _ = m.read.Load().(readOnly)
     if e, ok := read.m[key]; ok {
         // 2.1.1 更新已存在的擦除态对象,先更新它的状态为nil,
         // 并将它添加到dirty中
         if e.unexpungeLocked() {
            // The entry was previously expunged, which implies that there is a
            // non-nil dirty map and this entry is not in it.
            m.dirty[key] = e
         }
         // 2.1.2 将nil更新为为new_value
         e.storeLocked(&value)
    } else if e, ok := m.dirty[key]; ok {
         // 2.2 更新存在与dirty中对象,将它的值更新为new_value
         e.storeLocked(&value)
    } else {
         // 2.3.1 添加一个新对象,先更新read.amended,表示dirty中存在
         // read不含有得key
         if !read.amended {
              m.dirtyLocked()
              m.read.Store(readOnly{m: read.m, amended: true})
         }
         // 2.3.2 将key对应的值存入dirty
         m.dirty[key] = newEntry(value)
     }
     m.mu.Unlock()
}

// 将entry中元素的状态由expunged修改为nil
func (e *entry) unexpungeLocked() (wasExpunged bool) {
     return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}

func (m *Map) dirtyLocked() {
     if m.dirty != nil {
        return
     }

      // 当dirty提升为read后,dirty会被置为nil
      // 因此当未来向dirty中添加元素时,需要先构建新dirty的状态
      // 因为dirty要包含所有有效的key元素,所以新构建dirty状态时,
      // 将read中存储有效值的数据也存储至dirty中
      read, _ := m.read.Load().(readOnly)
      m.dirty = make(map[interface{}]*entry, len(read.m))
      for k, e := range read.m {
           // 将包含有效值的元素存入dirty
           if !e.tryExpungeLocked() {
               m.dirty[k] = e
           }
      }
}

// 将entry中状态为nil的元素置为expunged,表示它已被逻辑删除
// 当返回值为true时,表示元素已被删除,否则,元素为一个有效值
func (e *entry) tryExpungeLocked() (isExpunged bool) {
      p := atomic.LoadPointer(&e.p)
      for p == nil {
          if atomic.CompareAndSwapPointer(&e.p, nil, expunged) {
              return true
          }
          p = atomic.LoadPointer(&e.p)
      }
      return p == expunged
}

Delete 删除操作

删除Key对应的元素,对于不同的对象,有不同的删除方式

  1. 存在于read中的对象,使用原子操作避免加锁,将查询到的对象使用CAS操作将元素值置为nil,从逻辑上删除
  2. 仅存在于dirty的对象,累计命中率丢失的次数,并使用CAS操作将元素值置为nil,从逻辑上删除
代码语言:javascript
复制
// Delete deletes the value for a key.
func (m *Map) Delete(key interface{}) {
     m.LoadAndDelete(key)
}

func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) {
      //1. 使用原子操作查询read状态
      read, _ := m.read.Load().(readOnly)
      e, ok := read.m[key]
      if !ok && read.amended {
           //2. 未查到,加锁查询dirty
           m.mu.Lock()
           read, _ = m.read.Load().(readOnly)
           e, ok = read.m[key]
           if !ok && read.amended {
                e, ok = m.dirty[key]
                delete(m.dirty, key)
                //3. 累计加锁的数量,触发状态提升
                m.missLocked()
           }
           m.mu.Unlock()
      }
      // 4. 查询到key,将key的值置为nil,进行逻辑上删除
     if ok {
          return e.delete()
     }
     return nil, false
}

// 将元素从逻辑上进行删除,将状态置为nil
func (e *entry) delete() (value interface{}, ok bool) {
     for {
         p := atomic.LoadPointer(&e.p)
         if p == nil || p == expunged {
              return nil, false
         }
         if atomic.CompareAndSwapPointer(&e.p, p, nil) {
              return *(*interface{})(p), true
         }
      }
}

Range 遍历操作

提供了O(N)的方式遍历sync.Map,用户传入遍历key-value的动作函数,如果函数返回false,则遍历终止;即使在遍历过程中发生key的并发读写操作,每个key也仅会被最多遍历一次。 因为 dirty可能包含read中不存在的key的状态,当遍历操作发生时,如果dirty与read存储的有效key的状态不一致,将dirty提升为read。

  1. read包含所有有效元素时,直接便利read中存储的值
  2. dirty含有read中不存在的元素时,将dirty提升为read,再遍历新read中存储的值
代码语言:javascript
复制
func (m *Map) Range(f func(key, value interface{}) bool) {
      read, _ := m.read.Load().(readOnly)
      // 1. dirty中包含read不存在的状态,
      // 将dirty提升为read.
      if read.amended {
          m.mu.Lock()
          read, _ = m.read.Load().(readOnly)
          if read.amended {
              read = readOnly{m: m.dirty}
              m.read.Store(read)
              m.dirty = nil
              m.misses = 0
          }
          m.mu.Unlock()
       }

       // 2. 遍历read,当动作函数返回false时,退出遍历。
       // 由于遍历过程中可能存在map的并发修改操作,因此当遍历该entry时,
       // 实际存储的值被删除时,则不再遍历.
       for k, e := range read.m {
           v, ok := e.load()
           if !ok {
               continue
           }
           if !f(k, v) {
                 break
           }
       }   
}

func (e *entry) load() (value interface{}, ok bool) {
      p := atomic.LoadPointer(&e.p)
      if p == nil || p == expunged {
          return nil, false
      }
      return *(*interface{})(p), true
}

LoadAndDelete 复合操作

与Delete操作一样,仅增加了返回值传递被删除的对象.

LoadOrStore 复合操作

如果key存在,则返回已存在的值,否则将传入的参数存入Map并返回。loaded 为true ,表示返回的值为以前存在的旧值,值为false,返回的值为传入的参数。

执行逻辑与Store类似,也是4种场景

  1. read中存在key的有效值,不更新返回已存在的值
  2. read中存在key但是它的值被逻辑删除nil,则将其更新为传入的新值;
  3. read中存在key但是它的值被擦除expunged,则先将它的状态更新为nil,将它存入dirty,并更新它为传入的新值
  4. dirty中存在key的有效值,不更新且返回已存在的值
  5. dirty中存在key但是它的值被逻辑删除nil,则将其更新为传入的新值
  6. 一个新加入的key,将它存入dirty,并更新readOnly的amended状态未true
代码语言:javascript
复制
func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) {
      // Avoid locking if it's a clean hit.
      read, _ := m.read.Load().(readOnly)
      if e, ok := read.m[key]; ok {
            actual, loaded, ok := e.tryLoadOrStore(value)
            if ok {
                return actual, loaded
            }
      }

      m.mu.Lock()
      read, _ = m.read.Load().(readOnly)
      if e, ok := read.m[key]; ok {
          if e.unexpungeLocked() {
              m.dirty[key] = e
          }
          actual, loaded, _ = e.tryLoadOrStore(value)
      } else if e, ok := m.dirty[key]; ok {
            actual, loaded, _ = e.tryLoadOrStore(value)
            m.missLocked()
      } else {
            if !read.amended {
                // We're adding the first new key to the dirty map.
                // Make sure it is allocated and mark the read-only map as incomplete.
                m.dirtyLocked()
                m.read.Store(readOnly{m: read.m, amended: true})
             }
             m.dirty[key] = newEntry(value)
             actual, loaded = value, false
      }
      m.mu.Unlock()

      return actual, loaded
}

tryLoadOrStore执行逻辑,entry中存储的值存在三种状态:有效值、nil、expunged

  1. expunged:该值已被擦除,当前仅存在于read状态中,不允许直接更新为新值,因此直接返回。
  2. 有效值:存在一个有效值,直接返回该旧值,不更新为新值。
  3. nil: key对应的对象被逻辑删除,可以被设置为新值,如果设置成功则返回。因为entry会被其它go程并发读写调用,因此更新失败时需要判断它的状态是否为expunged或有效值状态,是则表示值被其它go程更新,返回对应的值。
代码语言:javascript
复制
func (e *entry) tryLoadOrStore(i interface{}) (actual interface{}, loaded, ok bool) {
      p := atomic.LoadPointer(&e.p)
      if p == expunged {
           return nil, false, false
      }
      if p != nil {
           return *(*interface{})(p), true, true
      }

      ic := i
      for {
            if atomic.CompareAndSwapPointer(&e.p, nil, unsafe.Pointer(&ic)) {
                  return i, false, true
            }
            p = atomic.LoadPointer(&e.p)
            if p == expunged {
                 return nil, false, false
            }
            if p != nil {
                   return *(*interface{})(p), true, true
            }
      }
}

总结

  1. Map适用的场景:
    1. 读多写少的场景,具体到key的细节场景为下列两种情况
      1. key被一写多读的场景,因为当key存在与read中时,利用原子操作来避免上锁来提升效率;同时通过及时将dirty提升为read,减少查询读状态时的miss次数
      2. 并发更新已存在的不同key的场景,利用原子的CAS操作更新已存在的值
  2. Map不适用的场景:
    1. 读写相等或写多读少的场景,原因
      1. 因为新增的key初始仅存在于dirty中,此时的存储操作需要加锁
      2. 读写相等的情况下(如key写一次、读一次),读操作时无法从read状态命中,从而需要加锁读取dirty状态,相比于简单的Mutex增加了额外的执行逻辑;写多读少也是类似,dirty的元素增加速率大于read的命中率缺失,从而迟迟无法触发状态提升(dirty->read),会导致读取操作极容易触发加锁读取dirty的过程
    2. 大量读取不存在的key的场景,原因
      1. 频繁触发状态提升 dirty->read,因为key不存在导致read的丢失命中率极容易达到阈值
      2. Load相比于普通的Mutex加锁处理,多执行很多逻辑
  3. Map的设计原理
    1. 读写分离,使用原子操作提升并发场景下的读操作性能
    2. 采用逻辑删除,批量删除已被擦除的key,将实际的删除成本摊销
    3. 累计只读read状态的命中率缺失次数,及时将dirty提升为read,提高命中率
    4. 读写两个状态中存储对象的指针,read中数据被CAS修改后的值对两个状态都可见

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 设计原理
  • 功能实现
    • Load 读取操作
      • Store 存储操作
        • Delete 删除操作
          • Range 遍历操作
            • LoadAndDelete 复合操作
              • LoadOrStore 复合操作
              • 总结
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档