← Posts

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:

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