Concurrency in Go: Goroutines, Channels and WaitGroups

One of Go's defining features is its built-in support for concurrency. Unlike traditional multithreading models in languages like Java or C++ that rely on heavy OS threads and complex locking mechanisms, Go utilizes **goroutines** and **channels** based on Tony Hoare's Communicating Sequential Processes (CSP) model.

This comprehensive guide covers spawning lightweight goroutines, synchronizing execution with `sync.WaitGroup`, communicating safely across channels, and preventing common concurrency pitfalls like deadlocks.

1. Goroutines: Lightweight Threads of Execution

A **goroutine** is a lightweight thread managed by the Go runtime. Initiated simply by prepending the `go` keyword to a function call, goroutines consume as little as 2KB of stack memory (compared to ~1MB for standard OS threads), allowing hundreds of thousands of concurrent tasks to run simultaneously.

C
package main

import (
    "fmt"
    "time"
)

func printMessage(msg string) {
    for i := 0; i < 3; i++ {
        fmt.Println(msg, i)
        time.Sleep(500 * time.Millisecond)
    }
}

func main() {
    // Launching a background goroutine
    go printMessage("Async Goroutine")

    // Main function execution
    printMessage("Main Function")
}

2. Synchronizing Execution with `sync.WaitGroup`

In Go, if the `main` function exits, all active background goroutines are terminated immediately, regardless of whether they finished executing. To ensure the main thread waits for background tasks to complete, we use `sync.WaitGroup`.

C
package main

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

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // Decrement counter when worker finishes
    fmt.Printf("Worker %d starting...\n", id)
    time.Sleep(1 * time.Second)
    fmt.Printf("Worker %d done.\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1) // Increment counter for each goroutine
        go worker(i, &wg)
    }

    wg.Wait() // Block until counter returns to 0
    fmt.Println("All workers completed successfully.");
}

3. Communicating via Channels

"Do not communicate by sharing memory; instead, share memory by communicating." **Channels** provide a typed conduit through which goroutines can send and receive values.

  • Unbuffered Channels: Synchronization point where send and receive blocks until both parties are ready.
  • Buffered Channels: Allows sending a predefined number of values without blocking until the buffer is full.
C
package main

import "fmt"

func computeSum(numbers []int, ch chan int) {
    sum := 0
    for _, n := range numbers {
        sum += n
    }
    ch <- sum // Send sum into channel
}

func main() {
    nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    ch := make(chan int)

    go computeSum(nums[:5], ch)
    go computeSum(nums[5:], ch)

    sum1 := <-ch
    sum2 := <-ch

    fmt.Printf("Total Sum: %d\n", sum1 + sum2);
}

Conclusion

Mastering Go's concurrency model—leveraging lightweight **goroutines**, safe synchronization via **WaitGroups**, and data transfer through **channels**—enables developers to build exceptionally fast, resilient concurrent systems.