sync.Map の他にも concurrent な hash map の実装はあって少ししらべてみると
というのがあった。 lrita/cmap はベンチ取ると遅いしふつうにmutex しているので特に参考にならない。 cornelk/hashmap はけっこうコンセプトが似ていて興味深い。というかこれは俺と同じようにやっていたら Split-Ordered Lists: Lock-Free Extensible Hash Tables と同じに行き着いたのか、すでに知っていたのか わからないけど基本的なアルゴリズムは同じだ
- key/value item は全部 doubly linked list でつないで全部単一のdoubly linked list でいてkey のhash 値でsort されている
- bucket から item への参照は dummy item をいれる
という形だ。でも100000レコードを先に入れておいて取得するベンチを取ってみると
Benchmark_Map/skiplistmap4_w/_0_bucket=_16-16 35947046 38.25 ns/op 15 B/op 1 allocs/op
Benchmark_Map/skiplistmap4_w/_0_bucket=_32-16 36800390 36.61 ns/op 15 B/op 1 allocs/op
Benchmark_Map/hashmap______w/_0_bucket=__0-16 20071834 63.11 ns/op 31 B/op 2 allocs/op
けっこう差がでる。あとベンチではまだみてないがkey の追加が非常に遅い。(ベンチ前の初期化時間が圧倒的に長いので) 純粋にdoubly linked list の性能差がでてるのじゃないかなぁとおもった。 ざっと違いは
- linked-list で deleted field をもっている
- list の最小単位が異なる
- shard によるlocality がない
- value は直接もたずに unsafe.Pointer にしてるかどうか
という感じだ
それで cornelk/hashmap のlinked list を見ると
type List struct {
count uintptr
head *ListElement
}
type ListElement struct {
keyHash uintptr
previousElement unsafe.Pointer // is nil for the first item in list
nextElement unsafe.Pointer // is nil for the last item in list
key interface{}
value unsafe.Pointer
deleted uintptr // marks the item as deleting or deleted
}
こうなってる。一方 skiplistmap のitem は
type SampleItem struct {
K interface{}
V interface{}
MapHead
}
type mapState byte
type MapHead struct {
state mapState
conflict uint64
reverse uint64
elist_head.ListHead
}
// from elist_head
type ListHead struct {
prev uintptr
next uintptr
}
大差はないのだけどいくつか異なっていて hashmap は value をunsafe.Pointer でもっているのはたぶん別の場所でvalue はまとめている (これはskiplistmap も後で改造したほうがいいかも)
linked-list で deleted field をもっている
insert の処理は
- 追加するエレメントの接続先の prev/next
- 前と後ろのelement の next/prev をcas でつながるまで繰り返す
という作りで変えようがない。
delete が異なっていて
deleted というfield があるということだ。これは削除marking 用で 削除にはいったら
func (l *List) Delete(element *ListElement) {
if !atomic.CompareAndSwapUintptr(&element.deleted, uintptr(0), uintptr(1)) {
return // concurrent delete of the item in progress
}
まぁ1を立てている。立てられない場合は削除失敗という感じ、調べてみないとわからないけど、これはもしかしたら削除と追加が同時に 走ると壊れそうだがこれは map 用のlinked list なのでたぶん同時にエントリ削除は前提にしてないのだろう
skiplistmap では left <-> fordeleted <-> right
という前提だとすると
- foredelete.prev/.next に marking で
|1
で立てる。 - left/right のprev/next を互いにcas で向ける
- 同時に連続したitem が削除することを考えて自分に向くポインタがなくなるまで待つ
ということをしている。そのため、skiplistmap では deleted field はいらない。struct としては MapHead.state という bucket の item かどうかの情報をもってるstate があるのでstruct としてのサイズは代わりないが、traverse していくときに ListElement のstruct 全体を 見続ける必要があるのと、 MapHead は reverse/elist_head.ListHead だけでいいのでその差がでているような気がする。
linked list のlock free な add/delete を考えると list は delete では自分をいれて3個、add では2個ないと head かtail か特殊な処理がはいってしまう。実際に cornelk/hashmap は以下のようになってる。
func (l *List) insertAt(element *ListElement, left *ListElement, right *ListElement) bool {
if left == nil {
//element->previous = head
element.previousElement = unsafe.Pointer(l.head)
func (l *List) Delete(element *ListElement) {
if !atomic.CompareAndSwapUintptr(&element.deleted, uintptr(0), uintptr(1)) {
return // concurrent delete of the item in progress
}
for {
left := element.Previous()
right := element.Next()
if left == nil { // element is first item in list?
if !atomic.CompareAndSwapPointer(&l.head.nextElement, unsafe.Pointer(element), unsafe.Pointer(right)) {
continue // now head item was inserted concurrently
}
これはlist の初期状態が head とtail のみの2つのterminater だけで構成されるのとは違う。というか、最初はlista_encabezado もそうだったが 全部初期状態は2element のlist に変えた。insert/add するものは単位tで問題ないので
type List struct {
count uintptr
head *ListElement
}
type initedListHead [2]ListHead
// NewEmptyList ... make Empty List . this has only head and tail terminater.
// elist_head require head/tail terminater for list operation.
func NewEmptyList() initedListHead {
list := initedListHead{}
InitAsEmpty(&list[0], &list[1])
return list
}
この2つも大きな差になっている。 cornelk/hashmap をみたおかげで skiplistmap もまだ改良の余地があって
- value は interface{} より unsafe.Pointer とかにして別で管理したほうがいい。(interface{}からの取り出しコストもある)どうせread では最後にしか使わないしね
- MapHead.state を delete marking と完全に融合することで state field をなくす
ということをしたらもう少しパフォーマンスはあがりそうだ。後者のほうはtraverse 時に演算がはいってしまうので、むしろ遅くなるかもしれないが やってみる価値はあるかも。
他の実装をみることでやはりいろいろな知見は手にはいる。