August 24, 2017·6 min read·engineeringgoconcurrencyrefactoring

Go 1.9 adds a concurrent map with a specific workload

sync.Map optimizes stable-key concurrent access without replacing ordinary maps. Type aliases address a different maintenance problem by letting a moved type retain its identity.

Adding a concurrent map to the standard library might appear to settle how Go programs should share map data. The Go 1.9 announcement immediately limits that interpretation. sync.Map is safe for concurrent access, but it is not a general replacement for the built-in map. Understanding its intended workload explains both its implementation and the kind of application code that should continue using a map with a lock.

The August 24 announcement also introduces type aliases and math/bits. The release notes describe changes to the runtime and tools, including concurrent compilation within a package. These additions solve different problems. The common thread is specific implementation support for patterns that otherwise require repeated application-level work.

I value Go because a reader can usually enter a codebase without learning a private dialect. A standard concurrent container helps when its contract matches the problem, but replacing a typed map indiscriminately would lose useful information. The versioned documentation is unusually direct about that tradeoff.

The workload described by the source

The Go 1.9 implementation says Map is optimized for concurrent loops whose keys remain stable over time. It identifies workloads with few steady-state stores, or stores localized to one goroutine per key. For other workloads, the documentation expects comparable or worse performance than an ordinary map with a read-write mutex, with worse type safety.

That is an important qualification. Safety for concurrent method calls says which interleavings the container supports. It does not say every access pattern is equally efficient. A container optimized for reusing established keys has a different cost structure from one designed around continuously introducing unrelated keys.

The zero value is a valid empty map, and the type must not be copied after first use. These rules make initialization simple while preserving the identity of its synchronization state. Application code can put a sync.Map in a containing object without a separate constructor, but it must keep using that same instance.

The method signatures accept interface{} keys and values. A built-in map can state its key and value types in its declaration, while this container moves that checking into application conventions and type assertions. That is the concrete loss behind the documentation warning about type safety.

Reads use a published map

The implementation separates a read map from a dirty map. An atomic.Value publishes a small immutable structure containing the read map and an indicator of whether the dirty map contains additional keys. Loading that structure is safe without holding the maps mutex.

Entries inside the read map can still change their associated values. The immutable structure fixes which entry objects the map refers to, while each entry has an atomically accessed pointer to its current value. This distinction allows a stable collection of keys to support concurrent value access without copying the entire map on every update.

A Load first searches the read map. When it cannot find a key and the published state says the dirty map contains additional keys, it takes the mutex and checks again. The second check matters because another operation may have promoted the dirty map while this load waited for the lock. The implementation avoids recording a miss against an already superseded state.

A load that still needs the dirty map records a miss. Once enough misses have accumulated, the dirty map becomes the new read map. The threshold is tied to the size of the dirty map, so repeated slow-path reads can pay for making those keys available on the faster path.

This mechanism explains why key stability matters. An established read map can answer many loads without the global mutex. New keys require bookkeeping in the dirty map, and later promotion changes which keys are immediately available to lock-free reads. Constant key churn repeatedly exercises the part of the design that the stable-key case avoids.

Stores and deletion preserve entry state

A store to an existing read-map entry first attempts an atomic update. If the entry has not been expunged, that path can avoid taking the mutex. A store involving an expunged entry or a new key takes the lock and updates the dirty-map bookkeeping as needed.

The source distinguishes a deleted entry from an expunged entry. An entry pointer can be nil to indicate deletion, or it can contain a special expunged marker that also records its absence from the dirty map. Reintroducing an expunged entry requires putting it back into the dirty map under the mutex. The states carry information needed to keep both map views consistent.

LoadOrStore provides a useful combined operation. It returns an existing value when the key is already present, or stores the supplied value otherwise. The second result says whether the returned value came from a load. A separate Load followed by a Store would leave an interval in which another goroutine could establish a value.

var values sync.Map
actual, loaded := values.LoadOrStore("worker", 7)
fmt.Println(actual, loaded)

For a previously empty map this stores the value and reports that it was not loaded. The example uses only the API present in Go 1.9. It does not imply that preparing the supplied value happens just once across callers, because callers evaluate that argument before invoking the method.

Iteration is not a snapshot

The Range documentation explicitly declines to promise a consistent snapshot. It will not visit a key more than once, but concurrent changes can make the value observed for a key come from different points during the call. Code that needs a coherent view of several related entries cannot derive that guarantee merely from using a concurrent map.

The source also warns that Range may cost linear time in the number of elements even if the callback stops after a constant number of calls. If the dirty map has unpublished keys, Range can promote it before iteration. Returning false early from the callback does not undo that initial work.

These constraints matter when the map participates in a larger invariant. Concurrent access to individual entries does not automatically protect a relationship between entries, or between the map and another object. An ordinary map protected by a lock can be easier to understand when one critical section must update several pieces of state together.

I prefer the specialized container when its operations describe the actual shared state. Where the application needs a broader transaction, an explicit lock can communicate that requirement more clearly. The new type adds a useful choice without changing what the built-in map guarantees.

Type aliases preserve identity during a move

The other substantial addition is type aliases. A declaration such as type Old = New gives another spelling to an existing type. It differs from declaring a new defined type with the same underlying representation. The names identify the same type, which is the property needed when callers migrate between package locations at different times.

The release announcement connects this to gradual code repair. A package can retain an old exported name while introducing the new location, allowing users to convert independently. A move no longer has to make every caller change in the same commit merely to preserve type identity.

This solves a development-scale problem that is separate from concurrent access at runtime. A large codebase can be difficult to change because its maintainers cannot coordinate an atomic update. Aliases provide a valid intermediate program, while sync.Map provides particular safe operations on shared state. Each addition has a narrower purpose than the broad label of convenience might suggest.

Implementation support without new syntax

The new math/bits package offers bit counting and manipulation for unsigned integers. The compiler recognizes supported operations and uses special machine instructions where possible. For example, the announcement describes trailing-zero counting on x86-64 as an example using the BSF instruction. The programmer calls an ordinary function while the compiler supplies the architecture-specific implementation.

The runtime also makes explicitly triggered collection concurrent. The release notes identify runtime.GC, debug.SetGCPercent, and debug.FreeOSMemory as blocking their calling goroutine while collection proceeds. That is a change in how collection work affects the rest of the program, even though callers retain familiar functions.

For maintenance work, these details determine where a new abstraction is necessary and where rebuilding supplies an improved implementation. Type aliases require a deliberate source change during refactoring. sync.Map requires choosing a data structure and accepting its contract. Compiler-recognized bit operations expose efficient implementations through library functions whose names state the operation being requested.