// lesson: parking-and-wakeups

Parking and Wakeups

What actually happens when a goroutine blocks โ€” on a channel receive, a mutex, a timer? The one thing that must not happen is an OS thread going to sleep or, worse, spinning. Threads are the scarce resource; blocking one on every blocked goroutine would collapse M:N scheduling back to 1:1.

The runtime's answer is a pair of functions you'll see all over proc.go:

  • gopark โ€” called by the blocking goroutine, on its own stack. It flips the G's status from _Grunning to _Gwaiting, records a waitreason (that's the "chan receive" you see in goroutine dumps), then switches to the scheduler via mcall. Crucially, the G is now on no run queue at all. It is not scheduled, not polled, costs zero CPU. It lives only in whatever wait-list the blocking primitive keeps โ€” a channel's waiter queue, a mutex's semaphore list โ€” as a sudog (a small "G waiting on this thing" node; one G can sit in several wait-lists at once thanks to select).
  • goready โ€” called by whoever unblocks it (the sender, the unlocker). It flips the G back to _Grunnable and pushes it onto a run queue. The M never stopped: after gopark it immediately picked up another runnable G.

sync.Mutex is a consumer of this machinery: its fast path is a single atomic CAS (compare-and-swap: "if the value is still what I last saw, swap in the new one โ€” atomically, in one CPU instruction, no lock needed") in userspace, and only the contended slow path calls into the runtime semaphore (semacquire/semrelease), which is a futex-style "sleep until someone releases" built on gopark/goready.

You can't call gopark from user code โ€” but you don't need to, because a channel operation is a gopark/goready pair in a trench coat. A blocked ch <- x parks you on the channel's sender wait-list; a receive readies you, FIFO. So a buffered channel of capacity 1 is a complete lock implementation:

  • Lock = put the token in (ch <- struct{}{}) โ€” blocks (parks!) while the buffer is full, i.e. while someone else holds the lock.
  • Unlock = take the token out (<-ch) โ€” wakes exactly one parked waiter.
  • TryLock = a select with a default arm: the non-blocking attempt.

Fairness note: waiters on a channel are queued FIFO by the runtime, which is a nicer property than sync.Mutex guarantees in its normal (barging) mode. You get that for free.

โ€บ A Mutex from a Channel

25 pts

Implement a mutex whose entire blocking behavior is delegated to the runtime's channel park/unpark machinery:

type Mutex struct { /* your state โ€” a chan struct{} of capacity 1 */ }

func NewMutex() *Mutex
func (m *Mutex) Lock()          // parks until the lock is available
func (m *Mutex) TryLock() bool  // never blocks; true iff it acquired the lock
func (m *Mutex) Unlock()        // releases; wakes one parked Lock, FIFO

Required semantics:

  • Mutual exclusion: between Lock (or a successful TryLock) and the matching Unlock, no other Lock/TryLock may succeed.
  • TryLock returns immediately: true and holds the lock, or false.
  • Unlock of an unlocked Mutex must panic (with any message). Detect it with the non-blocking receive pattern โ€” if there's no token to remove, the mutex wasn't locked. Like the real sync.Mutex, there is no ownership: any goroutine may unlock a locked mutex.
  • Do not use sync.Mutex/sync.RWMutex or spin loops; the point is that one buffered channel already is the lock. (Honor system again.)

The tests hammer it from 8 goroutines with a critical-section collision detector: every entry does an atomic increment of an "occupancy" gauge and fails if it ever reads anything but 1.

Log in to submit a solution and earn points.