Skip to content
Theory · Goroutines and channels · lesson 9 of 13

Mutex and data races

The kitchen has a storeroom, and on its door hangs a sheet with the number of flour sacks. A cook who brings a sack reads the number, adds one and writes the new number down. What happens if two cooks read the sheet at the same moment? Both see 41, both write 42, and one sack drops out of the count.

Goroutines fall into the same trap. A thousand goroutines increment one variable, each by one, and main waits for all of them:

GO
package main

import "fmt"
import "sync"

func main() {
    var wg sync.WaitGroup
    n := 0
    for range 1000 {
        wg.Go(func() { n++ })
    }
    wg.Wait()
    fmt.Println(n)
}

You would expect 1000, but on a computer with several cores the program usually prints less, for example 970. Each run gives a different number, and on a single core the bug may never show up.

Data race

The operation n++ has three steps:

  1. Read n.
  2. Add one.
  3. Write the result.

Two goroutines may read 41 at the same time and both write 42, and one increment is lost.

When two goroutines access one variable at the same time and at least one of them writes, this is a data race. The result of such a program is unpredictable.

Finding a race

The -race flag helps find a race. The command go run -race main.go prints WARNING: DATA RACE and the lines where the goroutines met.

A race in a map

With a map, a race ends even worse. If several goroutines write to one map without protection, here is what happens:

  • the runtime often notices and stops the program with fatal error: concurrent map writes;
  • this is not a panic: recover cannot catch it.

Interviewers like to ask about this: a plain map in Go is not safe for concurrent writes.

sync.Mutex

The kitchen's fix is a single key to the storeroom. Whoever holds the key goes in and updates the sheet, and the others wait at the door. The sync.Mutex type from the sync package is that key: it lets one goroutine at a time reach the data. It has two methods:

  • Lock takes the key; a goroutine that calls Lock on a locked Mutex waits until the owner calls Unlock;
  • Unlock gives the key back.
GO
var wg sync.WaitGroup
var mu sync.Mutex
n := 0
for range 1000 {
    wg.Go(func() {
        mu.Lock()
        n++
        mu.Unlock()
    })
}
wg.Wait()
fmt.Println(n) // 1000

Let's take the goroutine's body apart:

  1. mu.Lock() takes the key or waits until it is given back.
  2. n++ does all three steps while the goroutine holds the key.
  3. mu.Unlock() gives the key to the next goroutine.

No other goroutine can slip in between the read and the write, so no increment is lost, and the program prints 1000 on every run.

Zero value and copying

A Mutex is declared and passed by the same rules as a WaitGroup:

  • the zero value of a Mutex is unlocked and ready to use;
  • the literal sync.Mutex{} gives the same value, so var wg, mu = sync.WaitGroup{}, sync.Mutex{} declares both variables ready to use;
  • a Mutex must not be copied;
  • a struct with a sync.Mutex field is passed by pointer, and its methods are declared with a pointer receiver.

Rules for a Mutex

This is where the key picture stops working. A real door will not open without the key, but a Mutex holds nobody back by force: a goroutine that never called Lock can still reach n. Hence three rules:

  • every goroutine that touches the shared data must call Lock;
  • reading a shared variable also needs the Mutex if other goroutines write to it;
  • keep as little code as possible between Lock and Unlock, because the other goroutines wait all that time.

Common mistake

A function called Lock but returned on one of its paths without calling Unlock:

GO
var mu sync.Mutex
var stock = 5

func take(n int) bool {
    mu.Lock()
    if stock < n {
        return false // ✗
    }
    stock -= n
    mu.Unlock()
    return true
}

Here is what happens:

  1. After the call take(10) the Mutex stays locked: the cook walked off with the key.
  2. The next call to take waits on Lock forever.
  3. If every goroutine waits like this, the program stops with fatal error: all goroutines are asleep - deadlock!.

The fix is defer mu.Unlock() right after mu.Lock(): then the Mutex is unlocked however the function exits.

Key points

  • A data race is concurrent access to a variable where at least one goroutine writes; the -race flag finds it.
  • Concurrent writes to an unprotected map can stop the program with fatal error: concurrent map writes.
  • mu.Lock() and mu.Unlock() let one goroutine at a time reach the data; everyone who touches the data calls Lock.
  • defer mu.Unlock() right after Lock unlocks the Mutex on any exit; a Mutex is not copied.

Subscribe to take the course

The course is free. Sign in with Telegram and subscribe to the channel to open every lesson.

Sign in another way