Goroutines #

Goroutines are one of the strongest reasons Go excels at concurrent systems. Not because Go is the only language with concurrency, but because goroutines make it extremely easy and cheap: a goroutine only needs ~2KB of stack when first created (it can grow dynamically), so you can run hundreds of thousands of goroutines in a single process without running out of memory. OS threads, by contrast, need ~1-8MB of fixed stack. This model — called M:N scheduling (M goroutines scheduled onto N OS threads by the Go runtime) — is what makes Go so efficient for I/O-bound workloads like HTTP servers handling thousands of concurrent connections.

This scheduling model is managed by the Go runtime scheduler, which intelligently distributes many goroutines across operating system threads. The M:N scheduling model can be illustrated in the following diagram:

flowchart TD
    subgraph GoRuntime["Go Runtime (M:N Scheduler)"]
        direction TB
        G1["Goroutine 1 (G)"]
        G2["Goroutine 2 (G)"]
        G3["Goroutine 3 (G)"]
        P1["Logical Processor 1 (P)"]
        P2["Logical Processor 2 (P)"]
        G1 & G2 --> P1
        G3 --> P2
    end

    subgraph OS["Operating System (OS)"]
        M1["OS Thread 1 (M)"]
        M2["OS Thread 2 (M)"]
    end

    P1 --> M1
    P2 --> M2

How to Create a Goroutine #

Just add the go keyword before a function call:

import (
    "fmt"
    "time"
)

func sayHello(name string) {
    fmt.Printf("Hello, %s!\n", name)
}

func main() {
    go sayHello("Budi")    // run as a goroutine
    go sayHello("Sari")    // second goroutine
    go func() {            // goroutine with an anonymous function
        fmt.Println("Anonymous goroutine is running")
    }()

    // PROBLEM: main() doesn't wait for goroutines to finish!
    // If main() exits, all goroutines are terminated immediately
    time.Sleep(100 * time.Millisecond)  // a temporary solution, not idiomatic
}

Why time.Sleep Isn’t the Right Solution #

Using time.Sleep to wait for goroutines is an anti-pattern — you don’t know how long a goroutine needs. The correct solution is sync.WaitGroup or channels.


sync.WaitGroup — Waiting for Many Goroutines #

A WaitGroup is a counter that lets one goroutine wait for a group of other goroutines to finish:

import "sync"

func processData(id int, wg *sync.WaitGroup) {
    defer wg.Done()  // make sure Done() is always called, even on panic
    fmt.Printf("Worker %d started\n", id)
    // ... do the work
    fmt.Printf("Worker %d finished\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1)          // increment the counter before go
        go processData(i, &wg)
    }

    wg.Wait()  // block until the counter reaches 0
    fmt.Println("All workers finished")
}

wg.Add(1) must be called before go, not inside the goroutine. If called inside the goroutine, wg.Wait() may be called before Add(), so the program exits immediately without waiting.

// ANTI-PATTERN: Add called inside the goroutine
go func() {
    wg.Add(1)  // ✗ too late — Wait() may have already passed
    defer wg.Done()
    // ...
}()

// CORRECT: Add called before go
wg.Add(1)
go func() {
    defer wg.Done()  // ✓
    // ...
}()

Channels — Communication Between Goroutines #

Channels are the mechanism for goroutines to communicate safely — “don’t communicate by sharing memory; share memory by communicating.”

Unbuffered Channels #

An unbuffered channel makes the sender block until the receiver is ready, and vice versa. This guarantees synchronization:

ch := make(chan int)  // unbuffered

// Sender — a goroutine
go func() {
    fmt.Println("Sending value...")
    ch <- 42           // blocks until someone receives
    fmt.Println("Value sent")
}()

// Receiver — the main goroutine
value := <-ch          // blocks until someone sends
fmt.Println("Received:", value)

Buffered Channels #

A buffered channel has an internal capacity. The sender only blocks if the buffer is full:

ch := make(chan int, 3)  // buffered, capacity 3

ch <- 1    // doesn't block, goes into the buffer
ch <- 2    // doesn't block
ch <- 3    // doesn't block
// ch <- 4  // blocks! buffer full

fmt.Println(<-ch)  // 1
fmt.Println(<-ch)  // 2
fmt.Println(<-ch)  // 3

Closing and Ranging over Channels #

ch := make(chan int, 5)

// Send several values
go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)  // signal that no more values will be sent
}()

// Range automatically stops when the channel is closed
for v := range ch {
    fmt.Println(v)  // 0 1 2 3 4
}

// Check whether the channel is still open
v, ok := <-ch
if !ok {
    fmt.Println("Channel is closed, value:", v)  // v is the zero value
}
Never close a channel from the receiver side, and don’t close an already-closed channel — both cause panics. Convention: only the sender may close a channel.

Directional Channels — Channels with a Direction #

You can restrict a channel to send-only or receive-only for clarity of intent:

func producer(ch chan<- int) {  // send-only channel
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
    // <-ch  // ← compile error: receive from send-only channel
}

func consumer(ch <-chan int) {  // receive-only channel
    for v := range ch {
        fmt.Println("Consumed:", v)
    }
    // ch <- 1  // ← compile error: send to receive-only channel
}

func main() {
    ch := make(chan int, 5)
    go producer(ch)
    consumer(ch)
}

select — Channel Multiplexing #

select lets one goroutine wait on several channel operations at once, executing the first case that’s ready:

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "one"
    }()
    go func() {
        time.Sleep(500 * time.Millisecond)
        ch2 <- "two"
    }()

    // select picks the first case that's ready
    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println("From ch1:", msg1)
        case msg2 := <-ch2:
            fmt.Println("From ch2:", msg2)
        }
    }
    // Output: From ch2: two (first), then From ch1: one
}

Select with a Timeout #

func fetchData(url string) (string, error) {
    resultCh := make(chan string, 1)

    go func() {
        // simulate an HTTP request
        time.Sleep(2 * time.Second)
        resultCh <- "data from " + url
    }()

    select {
    case result := <-resultCh:
        return result, nil
    case <-time.After(1 * time.Second):
        return "", fmt.Errorf("timeout: request to %s took too long", url)
    }
}

Non-Blocking Select with Default #

ch := make(chan int, 1)

// Try sending without blocking
select {
case ch <- 42:
    fmt.Println("Successfully sent")
default:
    fmt.Println("Channel full, skip")
}

// Try receiving without blocking
select {
case v := <-ch:
    fmt.Println("Received:", v)
default:
    fmt.Println("No value, skip")
}

sync.Mutex — Mutual Exclusion #

When goroutines need to access shared state (not via channels), use a Mutex to prevent race conditions:

import "sync"

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()  // unlock is guaranteed to be called
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

func main() {
    counter := &SafeCounter{}
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment()
        }()
    }

    wg.Wait()
    fmt.Println("Final:", counter.Value())  // always 1000
}

sync.RWMutex — Read-Write Lock #

For workloads with more reads than writes, RWMutex is more efficient because it allows many readers at once:

type Cache struct {
    mu    sync.RWMutex
    store map[string]string
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()          // write lock — exclusive
    defer c.mu.Unlock()
    c.store[key] = value
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()         // read lock — can be concurrent with other readers
    defer c.mu.RUnlock()
    v, ok := c.store[key]
    return v, ok
}

sync/atomic — Atomic Operations #

For simple operations on numeric types, atomic is lighter than a mutex because it doesn’t need a lock:

import "sync/atomic"

var counter int64

// Atomic increment — thread-safe without a mutex
atomic.AddInt64(&counter, 1)

// Atomic load — safely read the current value
val := atomic.LoadInt64(&counter)

// Atomic store
atomic.StoreInt64(&counter, 0)

// CompareAndSwap — change only if the current value matches
swapped := atomic.CompareAndSwapInt64(&counter, 0, 100)
fmt.Println("Swapped:", swapped)

// Since Go 1.19 — atomic.Value for any type
var v atomic.Value
v.Store("hello")
fmt.Println(v.Load())  // "hello"

Race Conditions and the Race Detector #

A race condition happens when two goroutines access the same variable simultaneously and at least one writes — without proper synchronization:

// ANTI-PATTERN: race condition
var count int

go func() { count++ }()  // goroutine 1 reads+writes
go func() { count++ }()  // goroutine 2 reads+writes concurrently

// The final value of count is unpredictable!

Go provides a very useful race detector:

go run -race main.go
go test -race ./...
go build -race -o myapp .

The race detector prints a detailed report when a race is detected:

==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 7:
  main.main.func2()
      /home/user/main.go:12 +0x38

Previous write at 0x00c0000b4010 by goroutine 6:
  main.main.func1()
      /home/user/main.go:11 +0x38
==================

Context — Cancellation and Timeouts #

context.Context is Go’s standard way of propagating cancellation signals, deadlines, and values across goroutines:

import "context"

func fetchUser(ctx context.Context, id int) (*User, error) {
    // Create a cancellable request
    req, _ := http.NewRequestWithContext(ctx, "GET",
        fmt.Sprintf("/users/%d", id), nil)

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("fetchUser: %w", err)
    }
    defer resp.Body.Close()
    // ...
}

func main() {
    // A context with a 5-second timeout
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()  // always call cancel to free resources

    user, err := fetchUser(ctx, 42)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            fmt.Println("Request timeout")
        }
        return
    }
    fmt.Println(user)
}

// Propagating cancellation to child goroutines
func processAll(ctx context.Context, items []Item) error {
    for _, item := range items {
        // Check whether the context has been cancelled
        select {
        case <-ctx.Done():
            return ctx.Err()  // context.Canceled or context.DeadlineExceeded
        default:
        }

        if err := process(ctx, item); err != nil {
            return err
        }
    }
    return nil
}

Goroutine Leaks — Goroutines That Never Stop #

A goroutine leak happens when a goroutine is created but never stops — usually because it’s waiting on a channel that never receives a value or gets closed:

// ANTI-PATTERN: goroutine leak
func doWork() <-chan int {
    ch := make(chan int)
    go func() {
        for {
            ch <- rand.Int()  // this goroutine runs forever!
        }
    }()
    return ch
}

// CORRECT: use a done channel or context to stop
func doWorkWithStop(ctx context.Context) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for {
            select {
            case ch <- rand.Int():
            case <-ctx.Done():
                return  // stop when the context is cancelled
            }
        }
    }()
    return ch
}

Idiomatic Patterns #

Worker Pools #

func workerPool(numWorkers int, jobs <-chan int, results chan<- int) {
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for job := range jobs {
                results <- job * job  // process the job
            }
        }(i)
    }
    go func() {
        wg.Wait()
        close(results)
    }()
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    go workerPool(5, jobs, results)

    for i := 1; i <= 20; i++ {
        jobs <- i
    }
    close(jobs)

    for r := range results {
        fmt.Println(r)
    }
}

Pipelines #

// Stage 1: generate numbers
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            out <- n
        }
    }()
    return out
}

// Stage 2: square each number
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * n
        }
    }()
    return out
}

func main() {
    // Chain the pipeline
    c := generate(2, 3, 4, 5)
    out := square(square(c))  // square twice

    for v := range out {
        fmt.Println(v)  // 16, 81, 256, 625
    }
}

Complete Example Program #

package main

import (
    "context"
    "fmt"
    "math/rand"
    "sync"
    "sync/atomic"
    "time"
)

// Job represents work that needs processing
type Job struct {
    ID    int
    Value int
}

// Result represents a processing outcome
type Result struct {
    JobID  int
    Output int
    Worker int
}

// Stats tracks statistics with atomic operations
type Stats struct {
    processed int64
    errors    int64
    totalTime int64
}

func (s *Stats) RecordSuccess(elapsed time.Duration) {
    atomic.AddInt64(&s.processed, 1)
    atomic.AddInt64(&s.totalTime, int64(elapsed))
}

func (s *Stats) RecordError() {
    atomic.AddInt64(&s.errors, 1)
}

func (s *Stats) Report() {
    processed := atomic.LoadInt64(&s.processed)
    errors := atomic.LoadInt64(&s.errors)
    totalTime := atomic.LoadInt64(&s.totalTime)

    fmt.Printf("\n=== Statistics ===\n")
    fmt.Printf("Successfully processed : %d\n", processed)
    fmt.Printf("Errors                : %d\n", errors)
    if processed > 0 {
        avg := time.Duration(totalTime / processed)
        fmt.Printf("Average time          : %v\n", avg)
    }
}

// worker processes jobs from jobCh and sends results to resultCh
func worker(
    ctx context.Context,
    id int,
    jobCh <-chan Job,
    resultCh chan<- Result,
    stats *Stats,
    wg *sync.WaitGroup,
) {
    defer wg.Done()

    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d: context cancelled, stopping\n", id)
            return

        case job, ok := <-jobCh:
            if !ok {
                fmt.Printf("Worker %d: jobCh closed, done\n", id)
                return
            }

            start := time.Now()

            // Simulate work with a random duration
            delay := time.Duration(rand.Intn(100)) * time.Millisecond
            select {
            case <-time.After(delay):
                // work finished
            case <-ctx.Done():
                stats.RecordError()
                return
            }

            // Simulate an error 10% of the time
            if rand.Float32() < 0.1 {
                stats.RecordError()
                fmt.Printf("Worker %d: error processing job #%d\n", id, job.ID)
                continue
            }

            elapsed := time.Since(start)
            stats.RecordSuccess(elapsed)

            result := Result{
                JobID:  job.ID,
                Output: job.Value * job.Value,
                Worker: id,
            }

            select {
            case resultCh <- result:
            case <-ctx.Done():
                return
            }
        }
    }
}

func main() {
    const (
        numWorkers = 5
        numJobs    = 30
        timeout    = 3 * time.Second
    )

    // A context with a timeout
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()

    jobCh := make(chan Job, numJobs)
    resultCh := make(chan Result, numJobs)
    stats := &Stats{}

    // Start the worker pool
    var wg sync.WaitGroup
    for i := 1; i <= numWorkers; i++ {
        wg.Add(1)
        go worker(ctx, i, jobCh, resultCh, stats, &wg)
    }

    // Close resultCh after all workers finish
    go func() {
        wg.Wait()
        close(resultCh)
    }()

    // Send all jobs
    for i := 1; i <= numJobs; i++ {
        select {
        case jobCh <- Job{ID: i, Value: i}:
        case <-ctx.Done():
            fmt.Println("Timeout while sending jobs!")
            break
        }
    }
    close(jobCh)

    // Collect the results
    fmt.Printf("Starting %d jobs with %d workers...\n\n", numJobs, numWorkers)
    received := 0
    for result := range resultCh {
        received++
        fmt.Printf("Job #%-3d → %d² = %d (Worker %d)\n",
            result.JobID, result.JobID, result.Output, result.Worker)
    }

    stats.Report()
    fmt.Printf("Total results received: %d out of %d jobs\n", received, numJobs)
}

Summary #

  • Goroutines are very lightweight (~2KB initial stack) — you can run hundreds of thousands in a single process.
  • wg.Add(1) before go, not inside the goroutine — the signal to the WaitGroup must be given before the goroutine runs.
  • Unbuffered channels make the sender and receiver wait for each other — strict synchronization.
  • Buffered channels let the sender not block as long as the buffer isn’t full — decoupling producers and consumers.
  • Only the sender may close a channel — closing from the receiver or closing an already-closed channel causes a panic.
  • select multiplexes channels; use default for non-blocking, time.After for timeouts.
  • sync.Mutex for shared state that’s frequently written; sync.RWMutex for read-heavy workloads.
  • sync/atomic is lighter than a mutex for simple operations on numeric types.
  • Always run with -race during development and testing to detect race conditions.
  • Context propagates cancellation and deadlines across the entire call chain and goroutines.
  • Goroutine leaks happen when goroutines never stop — always provide a stop mechanism via context or a done channel.

← Previous: Keywords   Next: I/O →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact