Free Ebook cover Go (Golang) Fundamentals: Simple, Fast Programs for Beginners

Go (Golang) Fundamentals: Simple, Fast Programs for Beginners

New course

12 pages

Concurrency Basics in Go: Goroutines, Channels, and Synchronization Patterns

Capítulo 11

Estimated reading time: 9 minutes

+ Exercise

Goroutines: lightweight concurrency with the go keyword

A goroutine is a function call that runs concurrently with the rest of your program. You start one by prefixing a function call with go. Goroutines are lightweight: you can create many of them, but you still need structure to avoid leaks, races, and runaway fan-out.

Starting a goroutine

When you write go f(), the current function continues immediately. If the program exits, all goroutines stop, so you typically need a way to wait for them (covered later with sync.WaitGroup).

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		fmt.Println("running in a goroutine")
	}()

	// Give the goroutine time to run (not a good long-term pattern).
	time.Sleep(50 * time.Millisecond)
}

Common pitfalls

  • Data races: two goroutines access the same variable concurrently and at least one write occurs without synchronization. This can produce incorrect results and is hard to debug. Use channels or mutexes to coordinate access.
  • Loop variable capture: closures started in a loop can accidentally capture the same variable. Fix by creating a new variable inside the loop.
  • Uncontrolled fan-out: starting a goroutine per item without limits can overwhelm memory, file descriptors, or downstream services. Prefer bounded worker pools.

Loop variable capture example (and fix)

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		// BAD: captures i; goroutines may all print the same value.
		go func() {
			defer wg.Done()
			fmt.Println("bad:", i)
		}()
	}
	wg.Wait()

	for i := 0; i < 3; i++ {
		wg.Add(1)
		i := i // GOOD: new variable per iteration
		go func() {
			defer wg.Done()
			fmt.Println("good:", i)
		}()
	}
	wg.Wait()
}

Channels: coordinating goroutines with typed communication

Channels let goroutines communicate safely by sending typed values. A send on a channel (ch <- v) and a receive (v := <-ch) form a synchronization point. This often avoids shared-memory races by moving data between goroutines instead of sharing it.

Creating channels and sending/receiving

ch := make(chan int) // unbuffered

go func() {
	ch <- 42 // send blocks until a receiver is ready
}()

v := <-ch // receive blocks until a sender is ready

Unbuffered vs buffered channels

  • Unbuffered: send and receive rendezvous. Great for handoffs and backpressure.
  • Buffered: channel has capacity; sends can proceed until the buffer is full. Useful for smoothing bursts, but too much buffering can hide slow consumers and increase memory usage.
jobs := make(chan int, 10) // buffered: up to 10 queued items
jobs <- 1
jobs <- 2

Closing channels and ranging

Closing a channel signals that no more values will be sent. Receivers can continue draining buffered values. Only the sender should close a channel; closing from the receiver side is a common bug.

ch := make(chan int)

go func() {
	defer close(ch) // sender closes
	for i := 0; i < 3; i++ {
		ch <- i
	}
}()

for v := range ch { // stops when channel is closed and drained
	_ = v
}

If you need to detect closure on a single receive, use the “comma ok” form.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

v, ok := <-ch
if !ok {
	// channel closed
}

Select: multiplexing, timeouts, and cancellation basics

select waits until one of multiple channel operations can proceed. It helps you build responsive concurrent code: timeouts, cancellation, and non-blocking behavior.

Multiplexing receives

select {
case v := <-ch1:
	_ = v
case v := <-ch2:
	_ = v
}

Timeouts with time.After

time.After(d) returns a channel that delivers a time value after duration d. You can use it in a select to avoid blocking forever.

import "time"

select {
case v := <-ch:
	_ = v
case <-time.After(200 * time.Millisecond):
	// timeout
}

Cancellation basics with a done channel

A simple cancellation pattern is a read-only done channel that gets closed to broadcast “stop” to many goroutines. Receivers select on done and exit quickly.

done := make(chan struct{})

go func() {
	// later...
	close(done) // broadcast cancellation
}()

select {
case <-done:
	// stop work
case v := <-ch:
	_ = v
}

This chapter uses this pattern in the mini-project. In larger programs, context.Context is the standard tool for cancellation and deadlines, but the core idea is the same: a signal that propagates through your goroutines.

Synchronization: joining goroutines and protecting shared state

Waiting for goroutines with sync.WaitGroup

sync.WaitGroup lets you wait until a set of goroutines finishes. The pattern is: Add before starting, Done when finishing, and Wait in the parent.

var wg sync.WaitGroup

wg.Add(2)

go func() {
	defer wg.Done()
	// work
}()

go func() {
	defer wg.Done()
	// work
}()

wg.Wait() // blocks until both call Done

Mutex vs channels: when to use which

Both can be correct; choose based on what you are modeling.

  • Use channels when you are coordinating ownership or sequencing of data (pipelines, worker pools, request/response, backpressure). Channels make the flow explicit.
  • Use a mutex when you have shared state that must be accessed by multiple goroutines and channel-based ownership would make the design more complex (caches, counters, maps with frequent reads/writes). A mutex can be simpler and faster for protecting a small critical section.

A common hybrid is: channels for orchestration (starting/stopping work), mutexes for internal shared structures (like a memoization map) when needed.

Practical mini-project: a concurrent worker pool with results, shutdown, and error propagation

This mini-project builds a bounded worker pool that processes jobs concurrently and returns results. The design goals are: (1) bounded concurrency (no uncontrolled fan-out), (2) deterministic cleanup (no goroutine leaks), (3) safe shutdown on error, and (4) clear structure.

Step 1: Define job and result types

We will process integer jobs and return a computed value. Results include either a value or an error.

type Job struct {
	ID    int
	Input int
}

type Result struct {
	JobID int
	Value int
	Err   error
}

Step 2: Implement the worker function

Each worker reads from jobs until the channel is closed or cancellation is signaled. It sends a Result for each job. The worker must also stop promptly if the system is shutting down.

package main

import (
	"errors"
	"fmt"
	"sync"
)

type Job struct {
	ID    int
	Input int
}

type Result struct {
	JobID int
	Value int
	Err   error
}

func worker(id int, jobs <-chan Job, results chan<- Result, done <-chan struct{}, wg *sync.WaitGroup) {
	defer wg.Done()
	for {
		select {
		case <-done:
			return
		case job, ok := <-jobs:
			if !ok {
				return
			}
			// Simulate work with a deterministic rule.
			if job.Input < 0 {
				select {
				case results <- Result{JobID: job.ID, Err: errors.New("negative input")}:
				case <-done:
				}
				continue
			}
			value := job.Input * job.Input
			select {
			case results <- Result{JobID: job.ID, Value: value}:
			case <-done:
				return
			}
		}
	}
}

func main() {
	_ = fmt.Sprintf("worker pool")
}

Step 3: Orchestrate the pool (bounded concurrency + deterministic cleanup)

We will create:

  • jobs channel: producer sends jobs, then closes it.
  • results channel: workers send results; a closer goroutine closes it after all workers exit.
  • done channel: closed to broadcast cancellation when an error occurs or when we decide to stop early.
package main

import (
	"errors"
	"fmt"
	"sync"
)

type Job struct {
	ID    int
	Input int
}

type Result struct {
	JobID int
	Value int
	Err   error
}

func worker(id int, jobs <-chan Job, results chan<- Result, done <-chan struct{}, wg *sync.WaitGroup) {
	defer wg.Done()
	for {
		select {
		case <-done:
			return
		case job, ok := <-jobs:
			if !ok {
				return
			}
			if job.Input < 0 {
				select {
				case results <- Result{JobID: job.ID, Err: errors.New("negative input")}:
				case <-done:
				}
				continue
			}
			value := job.Input * job.Input
			select {
			case results <- Result{JobID: job.ID, Value: value}:
			case <-done:
				return
			}
		}
	}
}

func runWorkerPool(inputs []int, workerCount int) ([]Result, error) {
	jobs := make(chan Job)
	results := make(chan Result)
	done := make(chan struct{})

	var wg sync.WaitGroup
	wg.Add(workerCount)
	for i := 0; i < workerCount; i++ {
		go worker(i, jobs, results, done, &wg)
	}

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

	// Producer: send jobs then close jobs.
	go func() {
		defer close(jobs)
		for i, in := range inputs {
			select {
			case <-done:
				return
			case jobs <- Job{ID: i, Input: in}:
			}
		}
	}()

	var out []Result
	var firstErr error

	// Collector: range until results is closed.
	for r := range results {
		out = append(out, r)
		if r.Err != nil && firstErr == nil {
			firstErr = r.Err
			// Cancel the whole pipeline; workers and producer will stop.
			close(done)
		}
	}

	return out, firstErr
}

func main() {
	inputs := []int{2, 3, -1, 4, 5}
	results, err := runWorkerPool(inputs, 3)

	for _, r := range results {
		if r.Err != nil {
			fmt.Printf("job %d error: %v\n", r.JobID, r.Err)
			continue
		}
		fmt.Printf("job %d value: %d\n", r.JobID, r.Value)
	}
	if err != nil {
		fmt.Println("first error:", err)
	}
}

Step 4: Understand the shutdown and error strategy

  • Bounded concurrency: only workerCount goroutines do work, regardless of how many inputs exist.
  • Safe shutdown: closing done broadcasts cancellation. Workers stop selecting on jobs and stop trying to send results.
  • Deterministic cleanup: results is closed only after all workers exit (wg.Wait()). The collector ranges over results and terminates reliably.
  • Error propagation: the collector records the first error and triggers cancellation. You still may receive some in-flight results that were already computed or already in the send path; this is normal and keeps the system simple and safe.
  • Who closes what: producer closes jobs; coordinator closes results (after workers finish); cancellation closes done. This clear ownership prevents panics from double-closing.

Optional refinement: avoid blocking on results if the collector stops early

If you ever change the collector to stop reading results early (for example, returning immediately on first error), workers could block trying to send. Two common fixes are: (1) keep draining results until results is closed (as shown), or (2) make results buffered enough for worst-case in-flight sends. Draining is usually the most deterministic approach.

Now answer the exercise about the content:

In a bounded worker pool that uses jobs, results, and a done channel, what is the correct shutdown/closure responsibility to avoid leaks and double-close panics?

You are right! Congratulations, now go to the next page

You missed! Try again.

Clear ownership prevents panics and leaks: the sender side closes jobs, results is closed after all workers exit (using WaitGroup), and closing done broadcasts cancellation to stop producer and workers.

Next chapter

Building Production-Style Go Programs: Project Layout, Configuration, and Logging

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.