tsmap

tsmap reads and writes maps shared across goroutines, taking a caller-supplied lock at every call site, with Do and RDo for compound operations.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/threadsafe/tsmap"

Concurrent access to a shared map: read, write, delete and transform, each taking a caller-supplied lock so the access is serialised.

Every function takes the lock as its first argument:

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")

Writes (Set, Delete, Do) want a threadsafe.Locker; reads and pure transforms (Get, GetOK, Len, Snapshot, Filter, Map, Reduce, Invert, RDo) want an RLocker and take the read lock.

What it keeps that a concurrent map type gives up

The map stays a plain map[K]V, so everything that works on one still works:

  • Static typing. Keys and values keep their declared types, with no any and no type assertion on the way out.
  • Len. A guarded len(m) is exact at the moment the read lock is held.
  • The transforms. Filter, Map, Reduce and Invert run under the lock by delegating to maputil.
  • Snapshot. A copy taken under the read lock, which is a point-in-time view you own.

The cost is that every read takes the read lock, including reads of keys nobody is writing.

sync.Map in the standard library makes the opposite trade: it drops static typing and len, and its Range is not a point-in-time view, in exchange for avoiding lock acquisition on read-mostly workloads with a stable key set.

The part that actually bites

Each helper takes and releases the lock on its own. That makes any sequence of two calls non-atomic, which is the classic check-then-act race:

if _, ok := tsmap.GetOK(&mu, m, "k"); !ok {
    tsmap.Set(&mu, m, "k", 1)      // another goroutine may have set it in between
}

Do and RDo exist for this, running a callback under a single acquisition:

tsmap.Do(&mu, m, func(mm map[string]int) {
    if _, ok := mm["k"]; !ok {
        mm["k"] = 1
    }
})

The callbacks come with three rules that the package documents and the compiler cannot enforce:

  1. They run while the lock is held, so they must be cheap and must not block.
  2. They must not call back into these helpers on the same lock. A write helper called from a read-locked callback deadlocks; recursive read locking is unsafe once a writer is queued; anything called under Do deadlocks outright.
  3. They must not retain the map beyond their own return. After the lock is released, that reference races with everyone else.

Rule 3 compiles silently. A closure may capture the map and use it after the lock has been released, and nothing in the type system objects.

Snapshot is the escape hatch: it copies under the read lock and hands back something you own.

Inherited semantics

Filter, Map, Reduce and Invert delegate to maputil under the lock, and inherit its rules unchanged. Reduce is deterministic only for an order-independent reducer, and Map and Invert are last-write-wins on key collision. Holding a lock does not make Go’s randomised map iteration order deterministic.

Concurrency note on passing the map

Maps are reference types and no helper reassigns the caller’s variable, so passing the map by value is correct here. tsslice takes a pointer instead, because appending to a slice can reallocate and the caller’s header has to be updated.

Dependencies

This package reaches no external module: it uses only the Go standard library, plus threadsafe and maputil.