liveBook · Manning

Locking

As explained in Learn Go with Pocket-Sized Projects

Mutual exclusion is the property that prevents more than one execution context from accessing a protected resource at the same time.

A Go map shared by goroutines needs protection when they may access it concurrently. A mutex gives one goroutine exclusive access to the protected resource at a time. In a cache, that resource is the map holding entries; in an HTTP service, it can be the repository map shared by request handlers. section 7.3.4 section 8.4.3

Keep the lock’s responsibility narrow:

  • Put an unexported sync.Mutex beside the field it protects, rather than locking the whole server. If the storage is already thread-safe, an additional mutex may not be needed. section 8.4.4
  • Call Lock before accessing the field, then immediately arrange defer Unlock. Protect reads as well as writes, including a check followed by an insertion. section 7.6 section 8.4.4
  • Pass a used mutex, or a struct containing one, by pointer: copying a mutex after first use can cause unexpected locking behavior. Its zero value is unlocked; TryLock is rarely the best synchronization choice. section 7.3.4
Listing 8.33 memory.go: Callilng the Lock and Unlock mutex on Add()
// Add inserts a game in memory the first time it is received.
func (gr *GameRepository) Add(game session.Game) error {
    log.Print("Adding a game...")

    // Lock the reading and the writing of the game.
    gr.mutex.Lock() #1
    defer gr.mutex.Unlock() #2

    _, ok := gr.storage[game.ID]
    if ok {
        return fmt.Errorf("%w (%s)", ErrConflictingID, game.ID)
    }

    gr.storage[game.ID] = game

    return nil
}

The repository’s Add method holds one lock across both the duplicate-ID check and insertion. Deferred unlocking also covers the early error return. section 8.4.4

Choose the lock to match what a method actually does. A cache Read that deletes expired entries writes to the map, so it uses sync.Mutex rather than the RWMutex previously used for reads. The tradeoff is that two such reads can no longer run simultaneously. section 7.4.1

Listing 7.14 cache.go: Read with a TTL
func (c *Cache[K, V]) Read(key K) (V, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()

    var zeroV V #1

    e, ok := c.data[key]

    switch {
    case !ok:
        return zeroV, false
    case e.expires.Before(time.Now()):
        // The value has expired.
        delete(c.data, key)
        return zeroV, false
    default:
        return e.value, true
    }
}

This TTL cache read locks before looking up an entry and holds the lock through possible deletion of an expired entry. section 7.4.1

A solver can likewise lock a shared solution field when saving a result and when checking whether one has been found. That quick stop signal does not, by itself, reliably fix the solver’s deadlock. Test concurrent code with go test -race, but a run without a race report does not prove that no race exists. section 9.3.5 section 7.6

How Learn Go with Pocket-Sized Projects covers it

How deep it goes
Works through lock placement, deferred unlocking, pointer safety, and a read-side mutation tradeoff; does not explain mutex internals.
Examples
Uses a generic cache, a locked repository insertion, and a solver’s shared solution flag.
What it assumes
Assumes familiarity with Go maps, methods, and goroutines while introducing the mutex and its Lock and Unlock methods.
What you can do after
Apply it via sync.Mutex to protect shared fields and use go test -race to look for data races.
How it connects
Connects mutexes to goroutines, race conditions, maps, cache expiration, RWMutex, and concurrent HTTP requests.

Also explained in

Compare how 18 books explain locking →

Related concepts

This explainer was generated by Manning's knowledge graph from the text of Learn Go with Pocket-Sized Projects. The section links point to the parts of the book it draws on.