Semaphores, Rendezvous, Barriers

Updated

September 8, 2026

Semaphores, Rendezvous, Barriers

Overview

Limit concurrency to ≤ N (semaphore), meet at a sync point (rendezvous), or wait for everyone before next phase (barrier).

Mutex is N=1

type External struct{ lock sync.Mutex }

func (e *External) Call() {
    e.lock.Lock()
    defer e.lock.Unlock()
    // only one call at a time
}

When the dependency allows 4 parallel calls, a mutex is too strict.

Semaphore model

slots: [free free free free]  capacity 4

Acquire: take a free slot or block
Release: free a slot; wake one waiter
  semaphore capacity N:  [free free …]
  Acquire → take slot or block
  Release → free slot, wake waiter

  rendezvous: each waits until the other arrives

Channel implementation

Stdlib has no Semaphore type; a buffered channel is the idiomatic simple form (golang.org/x/sync/semaphore for weighted cases).

type Semaphore chan struct{}

func NewSemaphore(n int) Semaphore {
    return make(chan struct{}, n)
}

func (s Semaphore) Acquire() { s <- struct{}{} }
func (s Semaphore) Release() { <-s }

func (s Semaphore) TryAcquire() bool {
    select {
    case s <- struct{}{}:
        return true
    default:
        return false
    }
}

Where to acquire

Pattern Effect
Acquire inside worker May spawn huge number of blocked Gs
Acquire before go Bounds goroutine count as well as concurrency
for range nCalls {
    sem.Acquire()
    wg.Go(func() {
        defer sem.Release()
        ex.Call()
    })
}

Rendezvous

Two parties wait for each other before continuing:

G1 reaches point ──wait──┐
                         ├── both proceed
G2 reaches point ──wait──┘
ready1 := make(chan struct{})
ready2 := make(chan struct{})

// G1:
close(ready1)
<-ready2

// G2:
close(ready2)
<-ready1
sequence (top → bottom):
  actors: G1, G2
  G1 --> G1  : close ready1
  G1 --> G2  : wait ready2
  G2 --> G2  : close ready2
  G2 --> G1  : wait ready1
  note: both unblocked

Caution: concurrent fmt itself synchronizes and can hide races when debugging.

Barrier (N parties)

Generalize rendezvous to N workers: each Add(1) to a cycle, last arriver releases all. Patterns:

  • sync.WaitGroup for one-shot phase joins
  • Two WaitGroups or condition variables for reusable barriers
  • Channel of capacity 0 with careful N-handshake (easy to get wrong)

Teaching pattern for one phase:

var start, done sync.WaitGroup
start.Add(n)
done.Add(n)
for i := 0; i < n; i++ {
    go func() {
        start.Done()
        start.Wait() // all started
        // phase work
        done.Done()
    }()
}
done.Wait()

Runnable example

go mod init example && go run .
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    sem := make(chan struct{}, 4)
    var wg sync.WaitGroup
    start := time.Now()
    for i := 0; i < 12; i++ {
        sem <- struct{}{}
        wg.Go(func() {
            defer func() { <-sem }()
            time.Sleep(10 * time.Millisecond)
            fmt.Print(".")
        })
    }
    wg.Wait()
    fmt.Printf("\n%d ms\n", time.Since(start).Milliseconds())
}

Expected: ~30ms (12/4 × 10ms), twelve dots.

Try next: Implement rendezvous so two printers always print “sync” before either prints “after”.