Skip to main content

Command Palette

Search for a command to run...

The "Guardians" of the Concurrency

Updated
5 min read

When diving into concurrent programming in Go, two fundamental synchronization primitives often come up: mutexes and semaphores. While they might seem similar at first glance, they serve different purposes and understanding when to use each is crucial for writing safe, efficient concurrent code.

In this post, we’ll explore both concepts through relatable analogies and practical Go examples. By the end, you’ll have a clear understanding of when to reach for a mutex versus a semaphore.

Mutex: The Library Book

A mutex (short for “mutual exclusion“) is like a popular library book with only one copy. Only one person can check it out at a time, and they must return it before the next person can borrow it. When they’re done reading, they return the book to the library, and the next person in line can check it out.

In Go, a sync.Mutex ensures that only one goroutine can access a critical section of code at a time. It’s binary: locked or unlocked. There’s no middle ground.

package main

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

var (
    counter int
    mutex   sync.Mutex
)

func increment() {
    mutex.Lock()         // Check out the book
    defer mutex.Unlock() // Always return the book, even if panic occurs

    // Critical section: only one goroutine can be here at a time
    temp := counter
    time.Sleep(1 * time.Millisecond) // Simulate reading the book
    counter = temp + 1
}

func main() {
    var wg sync.WaitGroup

    // Launch 10 goroutines trying to increment
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            increment()
        }()
    }

    wg.Wait()
    fmt.Printf("Final counter: %d\n", counter) // Will always prints 10
}

The mutex is the librarian managing the book checkout. It doesn't matter how many people want to read the book, only one person can have it at a time. Simple, predictable, and perfect when you need exclusive access.

Semaphore: The Computer Lab

A semaphore, on the other hand, is like a computer lab with 5 computers available. The lab monitor keeps track of how many computers are free. When a computer becomes available, the next student can use it. If all 5 computers are in use, new students must wait.

In Go, semaphores can be implemented using buffered channels (for educational purposes) or using the official golang.org/x/sync/semaphore package (for production use). They allow a specified number of goroutines to access a resource simultaneously, not just one.

Understanding Semaphores: The Simple Way

Here's a simple semaphore implementation using a buffered channel to illustrate the concept. This example limits concurrent computer usage to manage lab resources:

package main

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

// A semaphore implemented with a buffered channel
type Semaphore struct {
    tokens chan struct{}
}

func NewSemaphore(capacity int) *Semaphore {
    return &Semaphore{
        tokens: make(chan struct{}, capacity),
    }
}

func (s *Semaphore) Acquire() {
    s.tokens <- struct{}{} // Take a token
}

func (s *Semaphore) Release() {
    <-s.tokens // Return the token
}

var sem = NewSemaphore(3) // // Limit to 3 concurrent computer users

// Simulate using a computer in the lab
func useComputer(id int) {
    sem.Acquire()         // Get permission to use a computer
    defer sem.Release()   // Return the computer when done

    fmt.Printf("Student %d: Using computer...\n", id)
    time.Sleep(100 * time.Millisecond) // // Simulate work time
    fmt.Printf("Student %d: Finished using computer\n", id)
}

func main() {
    var wg sync.WaitGroup

    // Tell 10 students to use computers
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            useComputer(id)
        }(i)
    }

    wg.Wait()
    fmt.Println("All students finished using computers")
}

Notice how the semaphore controls the concurrency without needing a mutex? This is because we’re limiting access to an external resource (computers), not protecting shared mutable state. Only 3 students can use computers simultaneously, while the others wait.

Production-Ready: Using the Official Package

For production code, you should use the official golang.org/x/sync/semaphore package, which provides a weighted semaphore with context support and better performance:

package main

import (
    "context"
    "fmt"
    "sync"
    "time"

    "golang.org/x/sync/semaphore"
)

var sem = semaphore.NewWeighted(3) // Limit to 3 concurrent computer users

// Simulate using a computer in the lab
func useComputer(ctx context.Context, id int) error {
    if err := sem.Acquire(ctx, 1); err != nil {
        return err // Context cancelled or timeout
    }
    defer sem.Release(1)

    fmt.Printf("Student %d: Using computer...\n", id)
    time.Sleep(100 * time.Millisecond) // Simulate work time
    fmt.Printf("Student %d: Finished using computer\n", id)
    return nil
}

func main() {
    ctx := context.Background()
    var wg sync.WaitGroup

    // Tell 10 students to use computers
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            if err := useComputer(ctx, id); err != nil {
                fmt.Printf("Student %d: failed: %v\n", id, err)
            }
        }(i)
    }

    wg.Wait()
    fmt.Println("All students finished using computers")
}

The official package offers additional benefits like context cancellation support (so you can timeout or cancel semaphore acquisition) and weighted acquisition (allowing different goroutines to request different amounts of capacity). This is perfect for rate limiting, connection pooling, or controlling access to resource pools.

The Moral of the Story

Both mutexes and semaphores are tools in your concurrency toolkit, each serving different purposes:

  • Mutex: “Only one at a time, please.”

  • Semaphore: “We have N computers available, but no more.”

Understanding when to use each is key to writing safe, efficient concurrent Go programs. The mutex is your strict bouncer, ensuring exclusive access. The semaphore is your capacity manager, controlling the flow while allowing controlled concurrency.

In the world of Go concurrency, both have their place. Choose wisely, and your programs will thank you with predictable behavior and optimal performance.