Documentation
¶
Overview ¶
Package tsmap solves the common concurrency problem of safely operating on Go maps shared across multiple goroutines without repeatedly writing lock/unlock boilerplate at every call site.
Problem ¶
Go maps are not safe for concurrent read/write access. Teams typically wrap each operation with a mutex, but this creates repetitive, error-prone code and makes it easy to forget a lock around one path. Generic map transformations such as filter, map, reduce, and invert are especially noisy when each operation also needs synchronization.
tsmap provides lock-aware generic helpers that execute map operations under the appropriate lock contract from github.com/tecnickcom/nurago/pkg/threadsafe.
How It Works ¶
Every function receives both the map and a lock interface:
- write operations (Set, Delete, Do) require threadsafe.Locker and use `Lock`/`Unlock`.
- read and pure-transform operations (Get, GetOK, Len, Snapshot, Filter, Map, Reduce, Invert, RDo) require threadsafe.RLocker and use `RLock`/`RUnlock`.
This design keeps synchronization policy explicit at the call site while providing a concise, reusable API.
Key Features ¶
- Generic API for any map type: functions are parameterized over `M ~map[K]V`, so they work with plain maps and named map types.
- Clear read/write lock separation: mutation helpers use exclusive locks, query/transform helpers use read locks.
- Functional map utilities with safety built in: Filter, Map, Reduce, and Invert delegate to github.com/tecnickcom/nurago/pkg/maputil while preserving thread safety via the provided lock.
- Presence-checked reads via GetOK, and atomic compound operations via Do (write) and RDo (read) for sequences a single helper cannot express.
- Snapshot returns a consistent copy under the read lock for safe read-out.
- Minimal adoption cost: works directly with standard sync.RWMutex (or any compatible custom lock type) through interfaces.
Usage ¶
var (
mu sync.RWMutex
m = map[string]int{"a": 1, "b": 2}
)
tsmap.Set(&mu, m, "c", 3)
v, ok := tsmap.GetOK(&mu, m, "a")
_ = v
_ = ok
even := tsmap.Filter(&mu, m, func(_ string, n int) bool { return n%2 == 0 })
total := tsmap.Reduce(&mu, m, 0, func(_ string, n int, acc int) int { return acc + n })
_ = even
_ = total
Determinism ¶
Go map iteration order is randomized, so the transform helpers inherit the semantics of github.com/tecnickcom/nurago/pkg/maputil: Reduce is deterministic only when its reducing function is order-independent (for example commutative and associative), and Map and Invert follow last-write-wins when several input entries map to the same output key.
Concurrency ¶
Maps are reference types and no helper reassigns the caller's map variable, so passing the map by value is safe: every helper serializes access to the shared map through the provided lock. Route all access through the helpers using the same lock instance; touching the map directly outside the lock is a data race.
Each helper acquires and releases the lock on its own, so a sequence of separate helper calls is not atomic as a whole (a GetOK followed by a separate Set is a classic check-then-act race). Use Do (write) or RDo (read) to run a compound operation under a single lock acquisition:
tsmap.Do(&mu, m, func(mm map[string]int) {
if _, ok := mm["k"]; !ok {
mm["k"] = 1
}
})
The predicate/transform callbacks passed to Filter, Map, Reduce, Invert, Do, and RDo run while the lock is held. They must be cheap and non-blocking, and must not call back into these helpers on the same lock: a write helper invoked from a read-locked callback deadlocks, recursive read locking is not safe when a writer is waiting, and any helper invoked under Do's exclusive lock deadlocks outright. A callback must also not retain the map it receives beyond its own return: once the lock is released, reading or writing it races with other goroutines.
Filter, Map, and Invert return new maps, but any reference-typed values they contain remain shared with the original: the helpers protect the map, not the objects it points to.
Guarded Wrapper ¶
The free functions above require the caller to pair the right lock with the right map at every call site. Guarded is an optional higher-level type that owns both a map and its sync.RWMutex, so access can only go through its methods and a lock can never be mismatched or forgotten. Prefer it when a single map is the unit of sharing; keep the free functions when one lock must guard several containers at once. Transforms to a different type (Map/Reduce/Invert) are expressed through Guarded.RDo.
See also: github.com/tecnickcom/nurago/pkg/threadsafe
Index ¶
- func Delete[M ~map[K]V, K comparable, V any](mux threadsafe.Locker, m M, k K)
- func Do[M ~map[K]V, K comparable, V any](mux threadsafe.Locker, m M, f func(M))
- func Filter[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M, f func(K, V) bool) M
- func Get[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M, k K) V
- func GetOK[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M, k K) (V, bool)
- func Invert[M ~map[K]V, K, V comparable](mux threadsafe.RLocker, m M) map[V]K
- func Len[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M) int
- func Map[M ~map[K]V, K, J comparable, V, U any](mux threadsafe.RLocker, m M, f func(K, V) (J, U)) map[J]U
- func RDo[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M, f func(M))
- func Reduce[M ~map[K]V, K comparable, V, U any](mux threadsafe.RLocker, m M, init U, f func(K, V, U) U) U
- func Set[M ~map[K]V, K comparable, V any](mux threadsafe.Locker, m M, k K, v V)
- func Snapshot[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M) M
- type Guarded
- func (g *Guarded[K, V]) Delete(k K)
- func (g *Guarded[K, V]) Do(f func(m map[K]V))
- func (g *Guarded[K, V]) Filter(f func(K, V) bool) map[K]V
- func (g *Guarded[K, V]) Get(k K) V
- func (g *Guarded[K, V]) GetOK(k K) (V, bool)
- func (g *Guarded[K, V]) Len() int
- func (g *Guarded[K, V]) RDo(f func(m map[K]V))
- func (g *Guarded[K, V]) Set(k K, v V)
- func (g *Guarded[K, V]) Snapshot() map[K]V
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Delete ¶
func Delete[M ~map[K]V, K comparable, V any](mux threadsafe.Locker, m M, k K)
Delete removes key k from map using an exclusive lock.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.Mutex{}
m := map[int]string{0: "Hello", 1: "World"}
tsmap.Delete(mux, m, 0)
fmt.Println(m)
}
Output: map[1:World]
func Do ¶
func Do[M ~map[K]V, K comparable, V any](mux threadsafe.Locker, m M, f func(M))
Do runs f while holding the exclusive lock, giving it raw access to the map for atomic compound operations (check-then-set, bulk mutation). The map is passed by value, so it must be non-nil for f to insert entries (a nil map panics on assignment, exactly as Set does). f must not call back into these helpers on the same lock, nor retain the map beyond its own return: using it after the lock is released races with other goroutines.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.Mutex{}
m := map[string]int{"a": 1}
// Atomically set "b" only if it is absent, then bump "a".
tsmap.Do(mux, m, func(mm map[string]int) {
if _, ok := mm["b"]; !ok {
mm["b"] = 2
}
mm["a"]++
})
fmt.Println(m["a"], m["b"])
}
Output: 2 2
func Filter ¶
func Filter[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M, f func(K, V) bool) M
Filter returns a new map containing entries for which predicate f is true, under a read lock.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.RWMutex{}
m := map[int]string{0: "Hello", 1: "World"}
filterFn := func(_ int, v string) bool { return v == "World" }
s2 := tsmap.Filter(mux, m, filterFn)
fmt.Println(s2)
}
Output: map[1:World]
func Get ¶
func Get[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M, k K) V
Get returns value for key k under a read lock.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.RWMutex{}
m := map[int]string{0: "Hello", 1: "World"}
fmt.Println(tsmap.Get(mux, m, 0))
fmt.Println(tsmap.Get(mux, m, 1))
}
Output: Hello World
func GetOK ¶
func GetOK[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M, k K) (V, bool)
GetOK returns value and presence flag for key k under a read lock.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.RWMutex{}
m := map[int]string{0: "Hello", 1: "World"}
v, ok := tsmap.GetOK(mux, m, 0)
fmt.Println(v, ok)
v, ok = tsmap.GetOK(mux, m, 3)
fmt.Println(v, ok)
}
Output: Hello true false
func Invert ¶
func Invert[M ~map[K]V, K, V comparable](mux threadsafe.RLocker, m M) map[V]K
Invert returns a new map with keys and values swapped, under a read lock.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.RWMutex{}
m := map[int]int{1: 10, 2: 20}
s2 := tsmap.Invert(mux, m)
fmt.Println(s2)
}
Output: map[10:1 20:2]
func Len ¶
func Len[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M) int
Len returns map length under a read lock.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.RWMutex{}
m := map[int]string{0: "Hello", 1: "World"}
fmt.Println(tsmap.Len(mux, m))
}
Output: 2
func Map ¶
func Map[M ~map[K]V, K, J comparable, V, U any](mux threadsafe.RLocker, m M, f func(K, V) (J, U)) map[J]U
Map transforms each map entry under a read lock and returns a new mapped result.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.RWMutex{}
m := map[int]string{0: "Hello", 1: "World"}
mapFn := func(k int, v string) (string, int) { return "_" + v, k + 1 }
s2 := tsmap.Map(mux, m, mapFn)
fmt.Println(s2)
}
Output: map[_Hello:1 _World:2]
func RDo ¶
func RDo[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M, f func(M))
RDo runs f while holding the read lock for atomic multi-step reads. f must not mutate the map, call back into these helpers on the same lock, or retain the map beyond its own return: reading it after the lock is released races with other goroutines.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.RWMutex{}
m := map[string]int{"a": 1, "b": 2, "c": 3}
sum := 0
tsmap.RDo(mux, m, func(mm map[string]int) {
for _, v := range mm {
sum += v
}
})
fmt.Println(sum)
}
Output: 6
func Reduce ¶
func Reduce[M ~map[K]V, K comparable, V, U any](mux threadsafe.RLocker, m M, init U, f func(K, V, U) U) U
Reduce folds map entries under a read lock starting from init.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.RWMutex{}
m := map[int]int{0: 2, 1: 3, 2: 5, 3: 7, 4: 11}
init := 97
reduceFn := func(k, v, r int) int { return k + v + r }
r := tsmap.Reduce(mux, m, init, reduceFn)
fmt.Println(r)
}
Output: 135
func Set ¶
func Set[M ~map[K]V, K comparable, V any](mux threadsafe.Locker, m M, k K, v V)
Set stores value v at key k using an exclusive lock. Like a built-in map assignment, Set panics if m is nil; use Guarded, which allocates lazily, to avoid pre-initializing the map.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.Mutex{}
m := make(map[int]string, 2)
tsmap.Set(mux, m, 0, "Hello")
tsmap.Set(mux, m, 1, "World")
fmt.Println(m)
}
Output: map[0:Hello 1:World]
func Snapshot ¶
func Snapshot[M ~map[K]V, K comparable, V any](mux threadsafe.RLocker, m M) M
Snapshot returns a shallow copy of the map taken under a read lock. The copy is safe to use after the lock is released, but any reference-typed values remain shared with the original map. It is the free-function counterpart of Guarded.Snapshot.
Example ¶
package main
import (
"fmt"
"sync"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
mux := &sync.RWMutex{}
m := map[string]int{"a": 1, "b": 2}
// Snapshot returns a copy that is safe to use after the lock is released.
snap := tsmap.Snapshot(mux, m)
fmt.Println(len(snap), snap["a"], snap["b"])
}
Output: 2 1 2
Types ¶
type Guarded ¶
type Guarded[K comparable, V any] struct { // contains filtered or unexported fields }
Guarded is a map bundled with its own sync.RWMutex. All access goes through its methods, which take the appropriate lock, so the lock can never be mismatched with the data or forgotten.
The zero value is ready to use and holds an empty map; write methods allocate the backing map lazily on first use. A Guarded must not be copied after first use; pass it by pointer (see NewGuarded).
Method callbacks passed to Guarded.Filter, Guarded.Do, and Guarded.RDo run while the lock is held: they must be cheap, non-blocking, must not call back into the same Guarded (doing so deadlocks), and must not retain the map they receive beyond their return (using it after the lock is released races with other goroutines).
Example ¶
package main
import (
"fmt"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
// The guard owns both the map and its lock; every method is safe to call
// from multiple goroutines without pairing a separate mutex.
g := tsmap.NewGuarded(map[string]int{"a": 1})
g.Set("b", 2)
g.Delete("a")
fmt.Println(g.Len())
v, ok := g.GetOK("b")
fmt.Println(v, ok)
}
Output: 1 2 true
func NewGuarded ¶
func NewGuarded[K comparable, V any](m map[K]V) *Guarded[K, V]
NewGuarded returns a Guarded that takes ownership of m. Pass nil for an initially empty map.
Ownership must be exclusive: after the call the caller must not read or write m except through the returned Guarded, otherwise those accesses race with the Guarded's methods.
func (*Guarded[K, V]) Delete ¶
func (g *Guarded[K, V]) Delete(k K)
Delete removes key k under an exclusive lock. Deleting an absent key is a no-op.
func (*Guarded[K, V]) Do ¶
func (g *Guarded[K, V]) Do(f func(m map[K]V))
Do runs f under an exclusive lock, giving it the map for atomic compound operations (check-then-set, bulk mutation). The map passed to f is never nil. f must not call back into this Guarded, nor retain the map beyond its own return.
Example ¶
package main
import (
"fmt"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
// Do runs a compound operation atomically under the write lock.
g := tsmap.NewGuarded(map[string]int{"a": 1})
g.Do(func(m map[string]int) {
if _, ok := m["b"]; !ok {
m["b"] = 2
}
m["a"]++
})
fmt.Println(g.Get("a"), g.Get("b"))
}
Output: 2 2
func (*Guarded[K, V]) Filter ¶
Filter returns a new map containing entries for which predicate f is true, under a read lock.
func (*Guarded[K, V]) Get ¶
func (g *Guarded[K, V]) Get(k K) V
Get returns the value for key k under a read lock, or the zero value if the key is absent.
func (*Guarded[K, V]) GetOK ¶
GetOK returns the value and presence flag for key k under a read lock.
func (*Guarded[K, V]) RDo ¶
func (g *Guarded[K, V]) RDo(f func(m map[K]V))
RDo runs f under a read lock for atomic multi-step reads (including Map, Reduce, or Invert via github.com/tecnickcom/nurago/pkg/maputil). f must not mutate the map, call back into this Guarded, or retain the map beyond its own return.
Example ¶
package main
import (
"fmt"
"github.com/tecnickcom/nurago/pkg/maputil"
"github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"
)
func main() {
// Transforms such as Invert go through RDo, which holds the read lock while
// delegating to maputil.
g := tsmap.NewGuarded(map[int]int{1: 10, 2: 20})
var inv map[int]int
g.RDo(func(m map[int]int) {
inv = maputil.Invert(m)
})
fmt.Println(inv[10], inv[20])
}
Output: 1 2