Skip to content
All essays
CraftMarch 17, 202413 min

Go Routines and Concurrency

Master goroutines for concurrent programming in Go

Ü
Ümit Uz
Mobile & Full Stack Developer

Goroutines are Go's lightweight threads that make concurrent programming easy and efficient. They're much cheaper than OS threads and enable you to run thousands of concurrent operations with minimal overhead.

What Are Goroutines?

Goroutines are functions running concurrently with other goroutines in the same address space.

Basic Syntax

go
func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go say("world")  // Start goroutine
    say("hello")     // Run in main goroutine
}

The go keyword starts a new goroutine.

Lightweight Nature

Goroutines are extremely lightweight:

go
func main() {
    // Start 100,000 goroutines
    for i := 0; i < 100000; i++ {
        go func(n int) {
            fmt.Println(n)
        }(i)
    }
    time.Sleep(time.Second)
}

This would be impossible with OS threads.

Goroutine Lifecycle

Understanding goroutine lifecycle is crucial for effective concurrency.

Starting Goroutines

go
func worker(id int) {
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Worker %d done\n", id)
}

func main() {
    for i := 1; i <= 3; i++ {
        go worker(i)
    }
    time.Sleep(2 * time.Second)
}

Anonymous Goroutines

go
go func() {
    fmt.Println("Anonymous goroutine")
}()

Be careful with variable capture:

go
// WRONG: All goroutines capture same i
for i := 0; i < 3; i++ {
    go func() {
        fmt.Println(i) // Prints 3, 3, 3
    }()
}

// RIGHT: Pass i as parameter
for i := 0; i < 3; i++ {
    go func(n int) {
        fmt.Println(n) // Prints 0, 1, 2
    }(i)
}

WaitGroups

sync.WaitGroup waits for a collection of goroutines to finish.

Basic Usage

go
import "sync"

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Printf("Worker %d\n", id)
}

func main() {
    var wg sync.WaitGroup

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

    wg.Wait() // Wait for all workers
}

Common Pattern

go
func process(items []int) {
    var wg sync.WaitGroup
    sem := make(chan struct{}, 10) // Limit to 10 concurrent

    for _, item := range items {
        wg.Add(1)
        sem <- struct{}{} // Acquire

        go func(i int) {
            defer wg.Done()
            defer func() { <-sem }() // Release

            // Process item
            fmt.Println(i)
        }(item)
    }

    wg.Wait()
}

Mutex for Synchronization

sync.Mutex protects shared data.

Basic Mutex

go
import (
    "sync"
)

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

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

RWMutex

RWMutex allows multiple readers or one writer:

go
type SafeMap struct {
    mu   sync.RWMutex
    data map[string]int
}

func (m *SafeMap) Get(key string) int {
    m.mu.RLock()
    defer m.mu.RUnlock()
    return m.data[key]
}

func (m *SafeMap) Set(key string, value int) {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.data[key] = value
}

Once for Single Execution

sync.Once ensures a function runs only once.

go
var once sync.Once
var instance *Database

func getDatabase() *Database {
    once.Do(func() {
        instance = connectToDatabase()
    })
    return instance
}

Context for Cancellation

context package manages goroutine lifecycles.

Basic Cancellation

go
import "context"

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Worker cancelled")
            return
        default:
            fmt.Println("Working...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    go worker(ctx)
    time.Sleep(3 * time.Second)
}

With Values

go
func handleRequest(ctx context.Context) {
    userID := ctx.Value("userID").(string)
    fmt.Println("Handling request for", userID)
}

func main() {
    ctx := context.WithValue(context.Background(), "userID", "12345")
    handleRequest(ctx)
}

Atomic Operations

sync/atomic provides low-level atomic operations.

Basic Atomic Operations

go
import "sync/atomic"

var counter int64

func increment() {
    atomic.AddInt64(&counter, 1)
}

func get() int64 {
    return atomic.LoadInt64(&counter)
}

Compare and Swap

go
var config int64

func updateConfig(newConfig int64) bool {
    return atomic.CompareAndSwapInt64(&config, 0, newConfig)
}

Pool for Object Reuse

sync.Pool reuses objects to reduce allocation.

go
var bufferPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func getBuffer() *bytes.Buffer {
    return bufferPool.Get().(*bytes.Buffer)
}

func putBuffer(buf *bytes.Buffer) {
    buf.Reset()
    bufferPool.Put(buf)
}

Common Patterns

Worker Pool

go
func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, j)
        time.Sleep(time.Second)
        results <- j * 2
    }
}

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

    // Start workers
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Send jobs
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)

    // Collect results
    for a := 1; a <= 5; a++ {
        <-results
    }
}

Fan-Out, Fan-In

go
func generator(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func fanIn(ch1, ch2 <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for {
            select {
            case n := <-ch1:
                out <- n
            case n := <-ch2:
                out <- n
            }
        }
    }()
    return out
}

Pipeline

go
func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func sq(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

func main() {
    // Set up pipeline
    c := gen(2, 3)
    out := sq(c)

    // Consume output
    fmt.Println(<-out) // 4
    fmt.Println(<-out) // 9
}

Rate Limiting

go
func main() {
    ticker := time.NewTicker(100 * time.Millisecond)
    defer ticker.Stop()

    bursts := make(chan time.Time, 5)

    for i := 0; i < 5; i++ {
        bursts <- time.Now()
    }

    go func() {
        for t := range ticker.C {
            bursts <- t
        }
    }()

    requests := make(chan int, 5)
    for i := 1; i <= 5; i++ {
        requests <- i
    }
    close(requests)

    for req := range requests {
        <-bursts
        fmt.Println("Request", req, time.Now())
    }
}

Best Practices

  1. 1Always use WaitGroup to wait for goroutines
  2. 2Pass context for cancellation
  3. 3Use mutex for shared data
  4. 4Be careful with goroutine leaks
  5. 5Limit concurrency with semaphores
  6. 6Handle panics in goroutines
  7. 7Use channels for communication

Common Mistakes

Goroutine Leaks

go
// WRONG: Goroutine never exits
func leak() {
    ch := make(chan int)
    go func() {
        val := <-ch // Blocks forever
        fmt.Println(val)
    }()
}

// RIGHT: Provide way to exit
func noLeak() {
    ch := make(chan int)
    done := make(chan struct{})
    go func() {
        select {
        case val := <-ch:
            fmt.Println(val)
        case <-done:
            return
        }
    }()
    close(done)
}

Data Races

go
// WRONG: Data race
var counter int
go func() { counter++ }()
go func() { counter++ }()

// RIGHT: Use mutex
var mu sync.Mutex
var counter int
go func() {
    mu.Lock()
    counter++
    mu.Unlock()
}()

Closing Channels Multiple Times

go
// WRONG: Panic on second close
close(ch)
close(ch)

// RIGHT: Close once
defer once.Do(func() {
    close(ch)
})

Performance Considerations

Goroutines are cheap but not free:

go
// Be reasonable with concurrency
const maxConcurrency = 100
sem := make(chan struct{}, maxConcurrency)

for _, item := range items {
    sem <- struct{}{}
    go func(i Item) {
        defer func() { <-sem }()
        process(i)
    }(item)
}

Conclusion

Goroutines make concurrent programming in Go straightforward and efficient. Combined with channels and proper synchronization, they enable you to write highly concurrent applications that scale well.

Master goroutines, and you'll be able to write Go programs that handle thousands of concurrent operations with ease. The key is understanding when to use goroutines, how to synchronize them properly, and how to avoid common pitfalls like leaks and data races.

Practice with goroutines, experiment with different concurrency patterns, and leverage Go's powerful synchronization primitives. Goroutines are one of Go's standout features that make building concurrent systems enjoyable rather than painful.

Enjoyed this essay? Share it.

Related essays

Next essay
React Performance Patterns