Concurrency means organizing work so several tasks can make progress independently. Parallelism means executing work at the same time. Go supports concurrency with goroutines, functions running under the Go runtime's scheduler. Starting one with go does not promise faster execution or a dedicated operating-system thread.
Every goroutine needs a lifetime: who starts it, what makes it stop, and who waits for it? This lesson uses a fixed worker count, channels for communication, and a context for cancellation. The arithmetic is intentionally simple so you can focus on coordination rather than performance.
Run a bounded worker group
Create a fresh module, save this as main.go, and run go run .:
package main
import (
"context"
"errors"
"fmt"
"sort"
"sync"
)
func squares(ctx context.Context, values []int, workers int) ([]int, error) {
if workers < 1 {
return nil, errors.New("workers must be positive")
}
jobs := make(chan int)
results := make(chan int)
var group sync.WaitGroup
group.Add(1)
go func() {
defer group.Done()
defer close(jobs)
for _, value := range values {
select {
case jobs <- value:
case <-ctx.Done():
return
}
}
}()
for i := 0; i < workers; i++ {
group.Add(1)
go func() {
defer group.Done()
for {
select {
case <-ctx.Done():
return
case value, ok := <-jobs:
if !ok {
return
}
select {
case results <- value * value:
case <-ctx.Done():
return
}
}
}
}()
}
go func() {
group.Wait()
close(results)
}()
output := make([]int, 0, len(values))
for value := range results {
output = append(output, value)
}
if err := ctx.Err(); err != nil {
return nil, err
}
sort.Ints(output)
return output, nil
}
func main() {
values, err := squares(context.Background(), []int{4, 1, 3, 2}, 2)
fmt.Println(values, err)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = squares(ctx, []int{1, 2, 3}, 2)
fmt.Println("canceled:", errors.Is(err, context.Canceled))
}
Expected output:
[1 4 9 16] <nil>
canceled: true
Only two worker goroutines process jobs, regardless of the input length. A producer sends jobs and closes its channel. A coordinator waits for the producer and workers, then closes results. The caller drains results until that closure. Sorting makes the final output deterministic; worker scheduling and arrival order are not deterministic. Small inputs avoid integer overflow in this arithmetic example.
Channels describe communication
A channel carries values of one type. An unbuffered send waits for a receiver, and a receive waits for a sender. A buffered channel can hold a limited number of values before sends block. A larger buffer is not a general solution to a missing receiver or an incorrect shutdown protocol.
Receiving from a closed channel eventually yields the zero value and ok == false. A range loop ends when the channel is closed and drained. Sending to a closed channel panics. A nil channel blocks sends and receives. Here only the producer closes jobs, and only the coordinator closes results after all senders finish. The pipeline article explains why downstream consumers and upstream senders must agree on termination.
Cancellation is cooperative
A context carries cancellation and deadlines across function calls. Pass it as the first parameter to work that needs it. The producer and workers use select to wait for either communication or cancellation. select chooses a ready case; cancellation does not always win when another case is ready, so some work may still occur after cancellation is signaled.
The pre-canceled example checks the returned error, not an exact number of processed jobs. Our contract discards partial output when cancellation is observed at the end. Context does not forcibly kill a goroutine or interrupt arbitrary computation; the work must check it or call an API that honors it.
Use context.WithTimeout to derive a context with a deadline, and call its returned cancel function, usually with defer cancel(), even when work finishes early. Cancellation releases associated resources; it does not wait for workers. In HTTP handlers, propagate r.Context() rather than replacing it with Background. Use context values for request-scoped metadata, not as a general bag of function options. See the context reference.
Waiting and shared memory
WaitGroup.Add registers work before it starts; each Done marks completion, and Wait joins registered work. It does not protect shared variables. The example appends results only in the calling goroutine, avoiding concurrent writes to the output slice.
If goroutines must share mutable state, a sync.Mutex can protect a critical section: lock it before access and unlock afterward. All relevant reads and writes must follow the same synchronization rule. Do not copy a mutex or WaitGroup after use. The sync documentation defines their contracts.
Pitfalls and exercise
time.Sleep is not a reliable way to wait for completion. A program can exit while goroutines are still running. Concurrent access to an ordinary map or slice can race. go run -race . can detect races on executed paths; a clean run does not prove all possible schedules safe. See the race detector guide.
Try empty input, one worker, and an invalid worker count. Then explain why removing cancellation from a blocked send can keep a goroutine alive forever.
Solution guidance
Empty input should return an empty result and nil error. One worker should produce the same sorted results. Zero workers must fail before any goroutines start. For the blocked-send question, trace a consumer that stops receiving: without a cancellation case, the sender cannot reach its deferred Done, so the coordinator cannot complete. Verify cancellation behavior without depending on sleeps or an exact interleaving.
Previous: Generics. Next: Testing and Tooling.

