Aiming for Jarvis, Creating D.A.N.I.

Tutorial 15: Mutexes and waitgroups (or, How to keep your threads from stepping on each other)

Concurrency is great until it isn't. You can have all the channels in the world, but eventually, you’re going to have state that needs protecting. If you've been working with any system where multiple "things" are trying to grab the same resource at once, you know the pain of the race condition. It’s the kind of bug that appears on your machine once every six months, but runs rampant the moment you push to production.

Let's talk about how to keep your shared memory from descending into total anarchy.

Mutexes: The "Only One at a Time" rule

In std/sync, we have the Mutex (Mutual Exclusion). It’s the bouncer at the door of your data. You lock it, you do your business, and you unlock it. If anyone else shows up, they wait.

module main

import "std/sync"
import "std/time"

var counter: int = 0

fn worker(mu: *sync.Mutex, id: int) {
    for i := 0; i < 1000; i = i + 1 {
        mu.Lock()
        // The critical section: keep it short and sweet
        counter = counter + 1
        mu.Unlock()
    }
}

fn main() -> int {
    mu := sync.NewMutex()
    spawn worker(mu, 1)
    spawn worker(mu, 2)
    
    // In the real world, you'd use a WaitGroup (see below) 
    // instead of guessing with sleep times.
    time.SleepMs(100)
    print("counter: {counter}")
    return 0
}

A word of warning: if you forget to Unlock(), or if you hit an error and return without releasing the lock, you’ve just created a deadlock. Your program will freeze, and you'll be left wondering why the universe hates you.

WaitGroups: Because guessing with sleep is amateur hour

We’ve all done it: time.SleepMs(1000) and hoped the work finishes before the main thread dies. It’s a bad habit. WaitGroup lets you track tasks properly. You Add() the number of tasks, tell them to Done() when they're finished, and Wait() until the crowd is gone.

import "std/waitgroup"

fn produce(wg: *waitgroup.WaitGroup, ch: chan<int>, val: int) {
    ch <- val
    wg.Done()
}

fn main() -> int {
    ch := make(chan<int>)
    wg := waitgroup.New()
    
    wg.Add(2)
    spawn produce(wg, ch, 1)
    spawn produce(wg, ch, 2)
    
    for i := 0; i < 2; i = i + 1 {
        print("{<-ch}")
    }
    
    wg.Wait() // No more guessing!
    return 0
}

Read-Write Locks: When everyone wants to read, but only one can write

If you have a configuration object that everyone reads constantly but only updates once a day, a standard Mutex is overkill. Use std/sync.RWMutex to let everyone read at once, and lock them all out only when you need to update.

rw := sync.NewRWMutex()

// Many readers can hold a RLock() at once
rw.RLock()
// ... read logic ...
rw.RUnlock()

// But only one writer can get the Lock()
rw.Lock()
// ... write logic ...
rw.Unlock()

Exercises

  1. The Crowd Control: Spawn three workers that each add to a shared counter and use a WaitGroup to make sure main doesn't print the results until the work is actually done.
  2. Read-Only Refactor: Convert a Mutex example to use an RWMutex where workers mostly read the counter, only writing when the value hits a certain threshold.
  3. Protecting the Slice: Use a Mutex to protect a shared []int slice that workers append to. It’s a great way to see what happens when you accidentally forget to lock it.

Next

When you're ready to put all of these pieces together into something that actually resembles a useful piece of software, we'll talk about Building a small project.