diff --git a/syncmap.go b/syncmap.go index 5615df7..02d8e07 100644 --- a/syncmap.go +++ b/syncmap.go @@ -67,6 +67,24 @@ func (m *SyncMap[K, V]) Delete(key K) { m.syncMap.Delete(key) } +// Swap replaces the value stored for key with value and returns the +// previous value, if any. The loaded result reports whether the key +// was present. If the key was not present, previous is the zero +// value of V. +func (m *SyncMap[K, V]) Swap(key K, value V) (previous V, loaded bool) { + v, l := m.syncMap.Swap(key, value) + if !l { + var v2 V + return v2, false + } + return v.(V), l +} + +// Clear removes all entries from the map, leaving it empty. +func (m *SyncMap[K, V]) Clear() { + m.syncMap.Clear() +} + // CompareAndSwap swaps the old and new values for key if the value // currently stored in m is equal to old. The swapped result reports // whether the swap was performed. diff --git a/syncmap_bench_test.go b/syncmap_bench_test.go index f468375..2a690bb 100644 --- a/syncmap_bench_test.go +++ b/syncmap_bench_test.go @@ -21,10 +21,10 @@ import ( ) // This file seeds the benchmark suite with coverage for the functions -// landing in issue #14 (CompareAndSwap / CompareAndDelete). A full -// benchmark set covering every public method, plus a raw-sync.Map -// overhead comparison and a committed bench.txt baseline, is owned by -// issue #15. +// landing in issues #13 (Swap, Clear) and #14 (CompareAndSwap, +// CompareAndDelete). A full benchmark set covering every public +// method, plus a raw-sync.Map overhead comparison and a committed +// bench.txt baseline, is owned by issue #15. func BenchmarkCompareAndSwap(b *testing.B) { b.ReportAllocs() @@ -74,3 +74,67 @@ func BenchmarkCompareAndSwapParallel(b *testing.B) { } }) } + +func BenchmarkSwap(b *testing.B) { + b.ReportAllocs() + var m syncmap.SyncMap[string, int] + m.Store("k", 0) + b.ResetTimer() + for i := 0; i < b.N; i++ { + m.Swap("k", i) + } +} + +func BenchmarkSwapAbsent(b *testing.B) { + b.ReportAllocs() + // int keys avoid the string-hash cost so the measured overhead + // is dominated by the !loaded guard and sync.Map's fresh-entry path. + var m syncmap.SyncMap[int, int] + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Fresh key each iteration — exercises the !loaded guard. + m.Swap(i, i) + } +} + +func BenchmarkSwapParallel(b *testing.B) { + b.ReportAllocs() + var m syncmap.SyncMap[string, int] + m.Store("k", 0) + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + m.Swap("k", i) + i++ + } + }) +} + +func BenchmarkClear(b *testing.B) { + b.ReportAllocs() + var m syncmap.SyncMap[string, int] + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Cost includes one Store per iteration — Clear on an empty map + // is meaningless, so this measures the combined cost. Isolate + // via pprof if the Clear fraction needs to be teased apart. + m.Store("k", 0) + m.Clear() + } +} + +func BenchmarkClearParallel(b *testing.B) { + b.ReportAllocs() + // Each goroutine owns its own map so Clear can race with Stores + // without invalidating per-iteration semantics. Concurrent Clear + // on a shared map is a legitimate pattern but the result is less + // informative (you can't reason about what any iteration "did"). + b.RunParallel(func(pb *testing.PB) { + var m syncmap.SyncMap[string, int] + for pb.Next() { + m.Store("k", 0) + m.Clear() + } + }) +} diff --git a/syncmap_test.go b/syncmap_test.go index 467f5c6..d837bd1 100644 --- a/syncmap_test.go +++ b/syncmap_test.go @@ -678,3 +678,108 @@ func TestCompareAndSwapContention(t *testing.T) { require.True(t, ok) assert.Equal(t, int(winnerID.Load()), finalVal, "stored value must match the winning goroutine's id") } + +func TestSwap(t *testing.T) { + t.Parallel() + + t.Run("absent_returns_zero_V_loaded_false_and_stores", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + previous, loaded := m.Swap("k", 42) + assert.False(t, loaded) + assert.Zero(t, previous) + v, ok := m.Load("k") + require.True(t, ok) + assert.Equal(t, 42, v) + }) + + t.Run("present_returns_old_loaded_true_and_overwrites", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 10) + previous, loaded := m.Swap("k", 20) + assert.True(t, loaded) + assert.Equal(t, 10, previous) + v, ok := m.Load("k") + require.True(t, ok) + assert.Equal(t, 20, v) + }) + + t.Run("zero_V_distinguished_from_absent", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 0) + previous, loaded := m.Swap("k", 1) + assert.True(t, loaded) + assert.Equal(t, 0, previous) + }) +} + +func TestClear(t *testing.T) { + t.Parallel() + + t.Run("empty_map_noop", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + assert.NotPanics(t, func() { m.Clear() }) + assert.Equal(t, 0, m.Len()) + }) + + t.Run("populated_map_becomes_empty", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("a", 1) + m.Store("b", 2) + m.Store("c", 3) + require.Equal(t, 3, m.Len()) + m.Clear() + assert.Equal(t, 0, m.Len()) + _, ok := m.Load("a") + assert.False(t, ok) + }) + + t.Run("subsequent_Store_works", func(t *testing.T) { + t.Parallel() + m := &syncmap.SyncMap[string, int]{} + m.Store("k", 1) + m.Clear() + m.Store("k", 2) + v, ok := m.Load("k") + require.True(t, ok) + assert.Equal(t, 2, v) + assert.Equal(t, 1, m.Len()) + }) +} + +func TestSwapContention(t *testing.T) { + t.Parallel() + + const goroutines = 100 + + m := &syncmap.SyncMap[string, int]{} + var wg sync.WaitGroup + var firstStores atomic.Int32 + + // Exactly one goroutine should see loaded=false (the first writer); + // the remaining 99 should all see loaded=true on a fresh key. + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + _, loaded := m.Swap("contended", id) + if !loaded { + firstStores.Add(1) + } + }(i) + } + + wg.Wait() + + assert.Equal(t, int32(1), firstStores.Load(), "exactly one Swap should observe loaded=false on a fresh key") + + // All goroutines wrote to the same key, so Len() is 1 and the + // stored value is one of the goroutine ids. + _, ok := m.Load("contended") + assert.True(t, ok) + assert.Equal(t, 1, m.Len()) +}