Go — One Real Program: Goroutines and Concurrency
Go — One Real Program: Goroutines and Concurrency
Most Go concurrency tutorials start with theory. Threads. Locks. Race conditions. By the time they show you code, you’ve read three pages about what could go wrong and still have no idea what a goroutine looks like in the wild.
This course works differently.
The program already exists. It runs. You will see its output.
The Program
racer is a Go CLI tool. It launches five workers at the same time. Each worker sleeps for a random duration, then reports back. You watch them finish in an order nobody chose.
go run main.go
worker 3 finished in 127ms
worker 5 finished in 203ms
worker 1 finished in 341ms
worker 4 finished in 412ms
worker 2 finished in 489ms
Run it again:
worker 2 finished in 88ms
worker 4 finished in 201ms
worker 3 finished in 310ms
worker 1 finished in 388ms
worker 5 finished in 451ms
Different order. Every time.
That is the destination.
Before You Begin
You need:
Go installed. Version 1.21 or later. Check with go version. If it’s not
there, get it from go.dev/dl.
A terminal. Nothing else. No external packages — racer uses only Go’s standard library.
Create the project:
mkdir racer && cd racer
go mod init racer
Create main.go and paste in the full source below. Then run it:
go run main.go
See it work. Then come back.
The Full Program
package main
import (
"fmt"
"math/rand"
"time"
)
type result struct {
id int
duration time.Duration
}
func worker(id int, ch chan<- result) {
duration := time.Duration(rand.Intn(400)+100) * time.Millisecond
time.Sleep(duration)
ch <- result{id: id, duration: duration}
}
func main() {
const n = 5
ch := make(chan result, n)
for i := 1; i <= n; i++ {
go worker(i, ch)
}
for i := 0; i < n; i++ {
r := <-ch
fmt.Printf("worker %d finished in %v\n", r.id, r.duration)
}
}
Launching Goroutines
What does go actually do?
Find this loop in main:
for i := 1; i <= n; i++ {
go worker(i, ch)
}
The keyword go before a function call launches that function as a goroutine.
A goroutine is a lightweight thread managed by the Go runtime — not by the
operating system. You can run thousands of them with no meaningful overhead.
Without go, each call to worker would block until it finished. main would
wait for worker 1, then worker 2, then worker 3 — sequential, predictable, slow.
With go, main fires all five workers immediately and moves on. All five are
now running at the same time. main does not wait for any of them.
This is the core idea. The go keyword says: start this, don’t wait for it.
The Channel
How do goroutines send results back?
A goroutine is fire-and-forget. You can’t call it like a function and get a return value — it’s running on its own. You need a way for it to hand something back.
That’s what a channel is.
ch := make(chan result, n)
make(chan result, n) creates a channel that carries values of type result.
The second argument, n, is the buffer size — this channel can hold up to n
values before a sender has to wait. Because you launch exactly n workers and
each sends exactly one result, a buffer of n means no worker ever has to
wait. They all send immediately when they’re done.
Inside worker:
ch <- result{id: id, duration: duration}
<- is the send operator. The worker puts its result into the channel and exits.
Back in main:
for i := 0; i < n; i++ {
r := <-ch
fmt.Printf("worker %d finished in %v\n", r.id, r.duration)
}
<-ch is the receive operator — the mirror of send. main waits here until
a result arrives, takes it, prints it, then waits for the next one. It does
this n times — once for each worker.
You launched n goroutines. You receive n results. When the loop finishes,
you’re done.
The channel is the only connection between main and its workers. No shared
variables. No locks. Just values flowing through a pipe.
The Struct
Why define a result type?
type result struct {
id int
duration time.Duration
}
Each worker needs to report two things: which worker it was, and how long it slept. A channel carries one value per send. A struct bundles multiple fields into one value — so one send carries everything.
id is an int. duration is a time.Duration, which is just a named
integer type representing nanoseconds. time.Millisecond is a constant — the
number of nanoseconds in one millisecond. Multiplying it by an integer gives
you a duration you can pass to time.Sleep.
When you write %v in a format string and pass a time.Duration, Go formats
it as a human-readable string: 127ms, 1.2s. You don’t parse it. You just
print it.
The Worker
What does each goroutine actually do?
func worker(id int, ch chan<- result) {
duration := time.Duration(rand.Intn(400)+100) * time.Millisecond
time.Sleep(duration)
ch <- result{id: id, duration: duration}
}
Three lines.
Line 1: rand.Intn(400) returns a random integer between 0 and 399.
Adding 100 gives a range of 100 to 499. Multiplying by time.Millisecond
converts that integer into a duration. Every worker gets a different sleep
time, chosen at random.
Line 2: time.Sleep pauses the goroutine for that duration. While this
goroutine sleeps, every other goroutine runs freely. The Go runtime schedules
them — you don’t manage that.
Line 3: The goroutine sends its result into the channel and exits.
Notice the channel parameter type: chan<- result. The <- is part of the
type, not a value. chan<- result means send-only — this function can put
values into the channel but cannot receive from it. The direction is enforced
by the compiler. It’s a contract: workers produce results, they don’t consume
them.
In main, the channel is chan result — bidirectional. You can both send and
receive. When you pass it to worker, Go narrows the type automatically. The
worker only ever needs one direction.
Why the Order Changes
What makes the output different every run?
Each worker picks a random sleep duration. Whichever worker picks the shortest
duration finishes first and sends to the channel first. main receives in
arrival order — whoever gets there first, wins.
You don’t control this. The Go runtime schedules goroutines across available CPU cores. The randomness of sleep times plus the scheduler’s decisions produce output that can never be predicted in advance.
This is the defining characteristic of concurrency: independent things running at their own pace, finishing when they finish, not when you expected them to.
Sequential code is deterministic by design. Concurrent code is not. Understanding that difference — not fighting it — is what concurrency is about.
Exercise
Add a sync.WaitGroup to main and remove the counting loop.
A WaitGroup is a counter the Go runtime provides for tracking goroutines.
You call wg.Add(1) before launching each goroutine. Inside the goroutine,
you call wg.Done() when it finishes. In main, wg.Wait() blocks until the
counter reaches zero.
Requirements:
- Import
sync - Declare
var wg sync.WaitGroupinmain - Call
wg.Add(1)before eachgo worker(...)call - Pass
&wgtoworkerand callwg.Done()before the worker exits — usedefer - Replace the receive loop with
wg.Wait()followed byclose(ch), then range over the closed channel to print results
This exercise uses: the sync package, pointer arguments, defer, closing a
channel, and ranging over a channel.
The Module
go.mod:
module racer
go 1.21.0
Two lines. No dependencies. fmt, math/rand, time — all standard library.
Five things running at once, results flowing through a channel, with nothing
installed beyond Go itself.
What You’ve Covered
One program. Here’s what you now understand:
Goroutines: the go keyword, what it means to launch and not wait, why
they’re cheap, how the scheduler owns them once you start them.
Channels: make(chan T, n), send with <-, receive with <-, buffered
vs unbuffered, directional types (chan<-), using a channel as the sole
connection between concurrent pieces.
Concurrency vs sequential: what changes when things run at the same time, why output order is not guaranteed, why that’s correct behavior and not a bug.
Structs as channel payloads: bundling multiple values into one send, using
time.Duration and time.Millisecond, formatting with %v.
Nothing here came from a syntax drill. Every concept earned its place because the program needed it.
Written by Ben Santora — bensantora.com