Skip to content

syncmap

Go 標準ライブラリが提供する sync.Map は並行安全な map で、使用する際にロックなどの方式で制御する必要はありません。その実装はそれほど複雑ではなく、コメントを削除すると合計 200〜300 行程度のコードです。

構造

go
type Map struct {
    mu Mutex
    read atomic.Pointer[readOnly]
    dirty map[any]*entry
    misses int
}

フィールドは合計 4 つだけです。それぞれ以下の通りです。

  • read:読み取り専用の map。dirty のキャッシュと理解できます
  • dirty:通常の map
  • missesread へのアクセスでヒットしなかった回数
  • mudirty の並行安全を保護する

readsync.readOnly 型で、内部はやはり map です。その中の amended フィールドは dirtyread にない key を含んでいるかどうかを表します。

go
type readOnly struct {
  m       map[any]*entry
  amended bool // true if the dirty map contains some key not in m.
}

また entry 型の構造は以下の通りで、p は value へのポインタです。

go
type entry struct {
  p atomic.Pointer[any]
}

entry 而言、3 つの可能性があります。

  1. 通常の状況、対応する値が格納されている
  2. pnil、そのキー値対が削除されたことを表す。此时 dirty は空の場合もあれば、依然として dirty 内に存在する場合もある
  3. p == expungedexpunged は空のインターフェースオブジェクトで、キー値対が既に削除され、dirty 内に存在しないことも表す

標準ライブラリ map の並行安全は読み書き分離によって実現されています。readdirty が格納する entry ポインタはすべて同じ value を指しています。read は読み取り専用なので、複数のゴルーチンがアクセスしても安全问题はありません。dirty は変更可能で、相互排他ロックによって保護されています。misses は key アクセスがヒットしなかった回数を記録し、回数が一定値に達すると、現在の dirtyread に転換し、misses がクリアされます。これが sync.Map の大まかな作業ロジックです。後続でその操作をより詳細に分析します。

読み取り

読み取り操作は Map.Load メソッドに対応し、コードは以下の通りです。

go
func (m *Map) Load(key any) (value any, ok bool) {
  read := m.loadReadOnly()
  e, ok := read.m[key]
  if !ok && read.amended {
    m.mu.Lock()
    read = m.loadReadOnly()
    e, ok = read.m[key]
    if !ok && read.amended {
      e, ok = m.dirty[key]
      m.missLocked()
    }
    m.mu.Unlock()
  }
  if !ok {
    return nil, false
  }
  return e.load()
}

まず read にアクセスし、存在すれば直接返します。否则 mu 相互排他ロックの取得を試みます。ロック取得期間中に dirty が read に昇格する可能性があるためです。それでも見つからない場合、最終的に dirty にアクセスし、miss を 1 回記録してロック解除します。

go
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
}

missLocked メソッドから、dirty が read に昇格する閾値条件は m.misses >= len(m.dirty) であることがわかります。

書き込み

書き込み操作は Store メソッドに対応しますが、実際には Swap メソッドによって完了します。previous は以前の値を表し、loaded は key が存在するかどうかを表します。

go
func (m *Map) Swap(key, value any) (previous any, loaded bool)

書き込み操作のプロセスは 2 つの部分に分かれます。アクセスした key が read 内に存在する場合、対応する entry を直接取得し、CAS を通じて entry の値を更新します。期間中にロックは不要です。

go
read := m.loadReadOnly()
if e, ok := read.m[key]; ok {
    if v, ok := e.trySwap(&value); ok {
        if v == nil {
            return nil, false
        }
        return *v, true
    }
}

スピン期間中、p == expunged の場合、その key が既に削除されたことを表し、直接返します。

go
func (e *entry) trySwap(i *any) (*any, bool) {
  for {
    p := e.p.Load()
    if p == expunged {
      return nil, false
    }
    if e.p.CompareAndSwap(p, i) {
      return p, true
    }
  }
}

key が read 内に存在しない場合、ロックを取得して次の操作を試みます。次に 3 つの状況に分かれます。1 つ目の状況、ロック取得期間中に dirty が read に昇格した場合、アクセスした entryexpunged であれば、それが既に削除され、dirty 内に存在しないことを表します。この時、それを dirty に追加してから、対応する値を格納する必要があります。

go
read = m.loadReadOnly()
if e, ok := read.m[key]; ok {
    if e.unexpungeLocked() {
        m.dirty[key] = e
    }
    if v := e.swapLocked(&value); v != nil {
        loaded = true
        previous = *v
    }
}

2 つ目の状況、read にはなく、dirty 内にある場合、対応する値を直接格納します。

go
if e, ok := m.dirty[key]; ok {
    if v := e.swapLocked(&value); v != nil {
        loaded = true
        previous = *v
    }
}

3 つ目の状況、read にもなく、dirty 内にもない場合、ここで read.amendedfalse であれば、dirty が空であることを表します。その後 m.dirtyLocked を使用して、read 内のすべての未削除キー値対を dirty にコピーし、read.amendedtrue にマークし、最後に新しい entry を作成して対応する値を格納します。

go
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)
}

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

  read := m.loadReadOnly()
  m.dirty = make(map[any]*entry, len(read.m))
  for k, e := range read.m {
    if !e.tryExpungeLocked() {
      m.dirty[k] = e
    }
  }
}

削除

削除操作は LoadAndDelete メソッドに対応し、その考え方は読み取り操作とほぼ完全に一致しますが、delete 関数の呼び出しが 1 つ増えただけです。

go
func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
  read := m.loadReadOnly()
  e, ok := read.m[key]
  if !ok && read.amended {
    m.mu.Lock()
    read = m.loadReadOnly()
    e, ok = read.m[key]
    if !ok && read.amended {
      e, ok = m.dirty[key]
      delete(m.dirty, key)
      m.missLocked()
    }
    m.mu.Unlock()
  }
  if ok {
    return e.delete()
  }
  return nil, false
}

キー値対を削除する際、永遠に dirty に対してのみ delete 操作を実行します。read 而言、格納する entry の値を nil に変更するだけです。

go
func (e *entry) delete() (value any, ok bool) {
  for {
    p := e.p.Load()
    if p == nil || p == expunged {
      return nil, false
    }
    if e.p.CompareAndSwap(p, nil) {
      return *p, true
    }
  }
}

走査

走査操作は Range メソッドに対応します。

go
func (m *Map) Range(f func(key, value any) bool) {
  read := m.loadReadOnly()
  if read.amended {
    m.mu.Lock()
    read = m.loadReadOnly()
    if read.amended {
      read = readOnly{m: m.dirty}
      m.read.Store(&read)
      m.dirty = nil
      m.misses = 0
    }
    m.mu.Unlock()
  }

  for k, e := range read.m {
    v, ok := e.load()
    if !ok {
      continue
    }
    if !f(k, v) {
      break
    }
  }
}

走査時、read のみ走査します。read.amendedtrue の場合、read 内の key に欠落があることを表します。この時、直接 dirty を read に昇格させ、for range ループを通じて走査し、各キー値対に対してコールバック関数を呼び出します。

パフォーマンス

sync.Map は読み書き分離の方式を採用して並行制御を行っています。読み取りが多く書き込みが少ないシーンに適しています。大部分の状況でキー値対にアクセスする際、ロックは不要です。しかし新しい要素を追加する必要がある場合、グローバルロックを保持する必要があります。現在の map のすべての操作をブロッキングするため、書き込みパフォーマンスの低下につながります。したがって sync.Map はすべての状況に適しているわけではありません。読み取りが少なく書き込みが多い状況では、セグメントロックの方式を採用して実装できます。これによりグローバルなブロッキングを回避できます。ここでオープンソース実装 orcaman/concurrent-map: a thread-safe concurrent map for go (github.com) を推奨します。シャード方式で実装され、ジェネリックもサポートしています。パフォーマンスと使用体験の両面で優れています。

Golang学习网由www.golangdev.cn整理维护