09 - Concurrency 101¶
What this session is¶
About an hour. You'll learn the two primitives that make Go famous: goroutines (cheap concurrent "things going at the same time") and channels (the way they talk to each other). This page is an introduction - there's much more to learn later - but by the end you'll be able to make programs that do several things at once.
A note before we start: concurrency is the hardest concept in programming. If this page is the most confusing one so far, that's because it's actually the hardest topic, not because the page is bad. Be patient with yourself.
The problem¶
Suppose you need to download three web pages. Each takes 2 seconds. If you do them one after another, total time is 6 seconds. If you start all three at once and wait for them all to finish, total time is ~2 seconds.
That second pattern - doing multiple things at the same time - is concurrency. Go makes it easy.
Goroutines: doing things "at the same time"¶
A goroutine is a function running independently of the rest of your program. To start one, write go in front of a function call:
package main
import (
"fmt"
"time"
)
func say(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg)
time.Sleep(500 * time.Millisecond)
}
}
func main() {
go say("hello")
go say("world")
time.Sleep(2 * time.Second)
fmt.Println("done")
}
Type and run. You should see "hello" and "world" interleaved, then "done."
What's new:
go say("hello")startssayrunning independently and immediately moves on to the next line. Two goroutines are now running.time.Sleep(500 * time.Millisecond)pauses for half a second.time.Sleep(2 * time.Second)inmainis there so the program doesn't exit before the goroutines finish.
That last point is critical. When main returns, the whole program ends - including any goroutines still running. If you remove the time.Sleep(2 * time.Second) and run again, you'll see almost no output: main starts the goroutines, immediately ends, the program quits.
Sleeping is a bad way to "wait for things to finish." We need a real mechanism.
What a goroutine actually is (the mental model)¶
A common misconception: "goroutines are threads, just lighter." Close, but not quite. The real model matters because it explains why goroutines are cheap and why Go can run a million of them.
Go uses a scheduler called the G-M-P model. Three letters, three roles:
- G - a goroutine. The unit of work. Created by every
go funcCall()statement. A G has a tiny stack (starting at 2 KB; grows on demand - you saw this on page 08) and some state about where it's running. - M - a machine, i.e. an OS thread. The thing that actually runs code on a CPU core.
- P - a processor (logical, not physical). A scheduling slot. You have one P per CPU core by default (controlled by
GOMAXPROCS).
The scheduler's job: multiplex many G's onto a small pool of M's, via the P's.
GOMAXPROCS=4 machine, mid-execution:
P0 P1 P2 P3
| | | |
M0 M1 M2 M3 (OS threads, doing the actual running)
| | | |
G(running) G(running) G(running) G(running)
local run queues (per-P):
P0: [G G G G]
P1: [G G]
P2: [] ← will steal from a busy P
P3: [G G G]
global run queue: [G G G G G G]
Each P has its own local queue of runnable goroutines. A G runs on an M (which is bound to a P). When the running G blocks (on a channel, on I/O, on a time.Sleep), the scheduler pops another G off the local queue and runs it instead - same M, same P, no OS context switch.
If a P's local queue empties, it tries the global queue. If that's also empty, it does work-stealing: it picks a random other P and grabs half of its queue. This keeps all CPU cores busy when there's any work to do.
That's why goroutines are cheap:
- 2 KB starting stack, growable, vs ~2 MB for an OS thread. A million goroutines use ~2 GB of stack; a million threads would use ~2 TB.
- No OS scheduler involvement to switch between goroutines on the same M. No kernel call, no context switch - just changing what the M is running.
go funcCall() doesn't start a thread. It creates a G, puts it on the current P's local queue, and immediately returns. The G runs whenever the scheduler picks it.
Try it:
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("CPUs:", runtime.NumCPU())
fmt.Println("goroutines at start:", runtime.NumGoroutine())
for i := 0; i < 100; i++ {
go func() { time.Sleep(time.Hour) }()
}
time.Sleep(100 * time.Millisecond)
fmt.Println("goroutines after spawning 100:", runtime.NumGoroutine())
}
sync.WaitGroup: wait for N things¶
The simplest "wait for goroutines to finish" tool is sync.WaitGroup. Think of it as a counter that goroutines decrement when they're done; main waits until it hits zero.
package main
import (
"fmt"
"sync"
"time"
)
func work(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("worker", id, "starting")
time.Sleep(1 * time.Second)
fmt.Println("worker", id, "done")
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go work(i, &wg)
}
wg.Wait()
fmt.Println("all workers finished")
}
Run it. Output (order may vary):
worker 1 starting
worker 3 starting
worker 2 starting
worker 1 done
worker 3 done
worker 2 done
all workers finished
New things:
var wg sync.WaitGroup- creates a WaitGroup. Zero-valued, ready to use.wg.Add(1)- bumps the counter up before starting each goroutine.defer wg.Done()- schedules aDonecall to happen when this function returns.Donedecrements the counter. (deferis a Go feature: "do this thing right before this function ends, no matter how it ends." Very useful.)wg.Wait()- blocks until the counter hits zero.go work(i, &wg)- note&wg. We pass a pointer so all goroutines share the same WaitGroup (not copies of it).
Total run time: ~1 second, not 3. Three workers ran at the same time, each took 1 second, total was 1 second. That's the win.
Channels: goroutines talking to each other¶
Often a goroutine produces a value and another goroutine needs to receive it. The Go way to pass values between goroutines safely is a channel.
A channel is like a pipe. One goroutine puts values in one end; another takes them out the other end.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 42
}()
value := <-ch
fmt.Println(value) // 42
}
New things:
make(chan int)- creates a channel that carriesintvalues.makeis a built-in for creating channels, slices, and maps.ch <- 42- send the value42into the channel. (Arrow points into the channel.)value := <-ch- receive a value from the channel. (Arrow points out of the channel.)go func() { ... }()- start an inline anonymous function as a goroutine. The()at the end immediately calls it. Common pattern.
Important: the receive <-ch blocks (waits) until something is sent. The send ch <- 42 blocks until something is received. The two goroutines meet at the channel and exchange the value. This is called synchronization.
What a channel actually is (the mental model)¶
A channel isn't magic. In the Go runtime, every make(chan T, N) allocates a small struct (the source calls it hchan) with these parts:
hchan {
buf: ring buffer of N elements (empty for unbuffered)
qcount: how many elements currently in buf
dataqsiz: N (capacity)
sendx: where the next send writes in buf
recvx: where the next receive reads from buf
closed: has close(ch) been called?
sendq: queue of goroutines blocked waiting to send
recvq: queue of goroutines blocked waiting to receive
lock: mutex protecting all of the above
}
Two distinct cases, both built from those pieces:
Unbuffered channel (make(chan int)): the buf is empty. The channel is purely a rendezvous point. When a goroutine sends, the runtime checks recvq for a waiting receiver: - Receiver waiting → hand the value directly to it, wake it up, sender continues. - No receiver → the sender's G is parked into sendq and the runtime puts the M to work on another G. Later, when a receiver arrives, it pulls the sender off sendq, takes the value, and wakes the sender.
Either way, the send and receive synchronize: both goroutines are guaranteed to have reached this point in their code at this moment.
Buffered channel (make(chan int, 5)): the buf is a 5-slot ring buffer. Sending writes into buf[sendx] if there's room (no waiting); if the buffer is full, the sender parks in sendq like the unbuffered case. Receiving reads from buf[recvx] if there's anything there; if the buffer is empty, the receiver parks in recvq.
unbuffered channel (rendezvous):
Sender ───┐ ┌─── Receiver
│ │
└──▶ [ no buf ] ◀──┘
▲
│
wait queues:
sendq: [G1 (parked, holding 42)]
recvq: []
Receiver arrives → pulls G1 off sendq, takes 42, wakes G1.
buffered channel (capacity 3):
buf: [10] [20] [30]
▲ ▲
recvx sendx (full → new senders park)
Receiver pulls 10, recvx advances. Sender can now write at the freed slot.
That's the whole channel mechanism. There's no magic queue - it's a struct, a ring buffer, two wait queues, and a mutex. Once you have this picture, close(), range ch, and select all stop feeling mysterious. (close() sets the closed flag and wakes everyone in sendq and recvq; receives from a closed channel return immediately with the zero value and ok = false.)
Try it: 1. Run a quick experiment: ch := make(chan int, 2); ch <- 1; ch <- 2; ch <- 3. The third send blocks forever (the buffer is full and there's no receiver). The whole program deadlocks. Add <-ch before the third send and it works. 2. Try ch := make(chan int); close(ch); v, ok := <-ch; fmt.Println(v, ok). Prints 0 false. Receives from a closed empty channel return zero values and ok = false immediately. 3. Read the actual hchan source (links at the bottom of Going deeper). It's only a few hundred lines and explains everything you just saw.
A more useful example: fetching things in parallel¶
package main
import (
"fmt"
"time"
)
func fetch(url string, results chan<- string) {
// Pretend we're making an HTTP call.
time.Sleep(500 * time.Millisecond)
results <- "result from " + url
}
func main() {
urls := []string{"a.example", "b.example", "c.example"}
results := make(chan string)
for _, url := range urls {
go fetch(url, results)
}
for range urls {
fmt.Println(<-results)
}
}
Run. Total time: ~500 ms (not 1500 ms). All three "fetches" happen at the same time.
A new piece of syntax: chan<- string. The arrow direction in the type says "this channel can only be sent to, not received from." It's a hint to the reader (and the compiler) about how the channel is used. The opposite is <-chan string (receive-only). Plain chan string allows both.
In the main function, we loop for range urls (no index, no value - just "do this len(urls) times") and receive a result each iteration. We don't know which URL's result comes out when, but we know we get exactly three results because we started three goroutines.
Channels can be closed¶
When you're done sending on a channel, you can close it: close(ch). Receivers can then loop until the channel is empty and closed:
This loop ends when the channel is closed (and drained). Useful when you have an unknown number of values coming in.
Important
only the sender should close a channel, and only when no more values are coming. Closing a channel that you're not the sole sender of is a way to crash your program. For now, keep it simple: one goroutine sends, closes when done; one or more receive.
Common patterns and warnings¶
- Don't write to a closed channel. Panic.
- Don't close a channel from the receiver side. Confusing and error-prone.
- A nil channel blocks forever. If you do
var ch chan intwithoutmake, both send and receive hang forever.
These rules feel restrictive at first. They exist because the alternative is data races (multiple things touching the same memory simultaneously without coordination) which are the worst kind of bug - they appear randomly and are nearly impossible to reproduce.
The slogan¶
Go's tagline for concurrency:
Don't communicate by sharing memory; share memory by communicating.
In other languages, threads talk by reading and writing the same variables, protected by locks. In Go, the idiom is: each thing owns its own data, and passes copies via channels when other things need them. Less subtle, fewer bugs.
You'll still meet mutexes (sync.Mutex) in real Go code - sometimes a lock is the right tool. But for most tasks, channels are first.
Happens-before: why synchronization isn't just about waiting¶
This is the deepest concurrency idea on this page. It's short, and it explains why channels and mutexes are non-negotiable.
Without coordination, writes from one goroutine may never become visible to another. Modern CPUs and compilers reorder reads and writes for performance, and each CPU core has its own cache. A value one goroutine wrote at time T might not appear in another goroutine's view until some time later - or not at all, if the compiler decides the original write can be optimized away entirely.
So this code, which looks like it should work, can fail silently:
var data string
var ready bool
go func() {
data = "hello, world"
ready = true
}()
for !ready {
// wait...
}
fmt.Println(data) // might print "" instead of "hello, world"!
The reading goroutine might observe ready = true before it observes the write to data. There's no rule of physics that says writes complete in source-code order across CPU cores. They don't, by default.
The fix is to introduce a synchronization point - an operation that the language guarantees establishes an ordering between two goroutines. Go's memory model defines exactly which operations do this. The two you've already seen are at the top of the list:
- A channel send happens-before the matching receive completes. Everything the sender did before the send is guaranteed visible to the receiver after the receive.
- An unlock happens-before any subsequent lock of the same mutex. Everything the goroutine did before unlocking is guaranteed visible to the next goroutine that locks.
So the fixed version:
ready := make(chan struct{})
var data string
go func() {
data = "hello, world"
close(ready) // synchronization point
}()
<-ready // happens-after the close
fmt.Println(data) // guaranteed "hello, world"
The receive of the closed channel is synchronized with the close, which is synchronized with the write to data. Now data is guaranteed visible.
From the Go memory model spec:
If you must read the rest of this document to understand the behavior of your program, you are being too clever. Don't be clever.
Translation: always reach for channels, mutexes, or sync primitives to coordinate goroutines. Never trust intuition about memory visibility. The cost of "being clever" is a bug that appears only under heavy load, only on certain machines, with no reproducible trigger.
Try it: Run the broken version above. On most machines it will appear to work - the writes happen to land in order most of the time. Add GOMAXPROCS=8 go run -race main.go. The race detector flags the data race. The fact that it didn't crash is a lie; the bug is still there. The -race flag is the only honest test for this.
Going deeper¶
Production concurrent Go is mostly the basics above plus several patterns and a deeper understanding of the runtime that makes goroutines and channels work. The material below is drawn from the Go memory model spec, the runtime source, and authoritative scheduler write-ups (links at the end).
The four happens-before rules you actually need¶
The Go memory model is precise. There are several rules, but four cover almost all real code:
- Within a single goroutine, reads and writes happen in the order they appear in the source code. (Subject to compiler reordering that's invisible from inside the goroutine. This is the rule that lets the compiler optimize at all.)
- Channel send happens-before the matching receive completes. For unbuffered channels, the receive happens-before the send completes - this is stronger (synchronous rendezvous). For buffered channels with capacity C, the k-th receive happens-before the (k+C)-th send.
- Mutex
Unlockhappens-before any subsequentLockon the same mutex. The n-thUnlockhappens-before the (n+1)-thLockreturns. Same forRWMutex.Unlock→ nextLock, andRUnlock→ nextLock. - A
gostatement happens-before the started goroutine begins executing. Everything in the caller beforego f()is visible insidef.
These four cover ~95% of correctness reasoning in real code. The other rules in the spec are edge cases (specifically: sync.Once, atomics, the relationship between buffered receives and earlier sends).
The practical implication: every shared variable must be guarded by one of these synchronization edges. If you can't draw a happens-before arrow from "the write" to "the read," your code has a data race even if it currently appears to work.
Try it: Take this snippet and try to convince yourself it's wrong:
The writex = 42 is in one goroutine; the read in fmt.Println(x) is in another (main). There's no channel, no mutex, no sync operation between them - no happens-before edge. Run with -race. The race detector reports it. The fix is to add a channel: done := make(chan struct{}); go func() { x = 42; close(done) }(); <-done; fmt.Println(x). Three broken patterns the spec explicitly warns about¶
The memory model spec calls out specific patterns that look right and are wrong. Recognize them.
1. Busy-waiting on an unsynchronized flag.
var ready bool
var data string
go func() {
data = "computed"
ready = true // no synchronization
}()
for !ready { // also no synchronization
runtime.Gosched()
}
fmt.Println(data) // may print ""
Without a channel or mutex, the writes to data and ready can be reordered or invisible to the other goroutine. The fix: replace ready with close(readyCh) and replace the loop with <-readyCh.
2. Double-checked locking (the C++/Java pattern transplanted incorrectly).
var instance *Service
var mu sync.Mutex
func get() *Service {
if instance == nil { // unsynchronized read
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = newService()
}
}
return instance
}
The first if instance == nil is unsynchronized. A racing goroutine might see instance as non-nil but observe an only-partially-constructed Service. Use sync.Once instead:
var instance *Service
var once sync.Once
func get() *Service {
once.Do(func() { instance = newService() })
return instance
}
sync.Once.Do is documented to establish a happens-before edge between the action and every subsequent Do call. This is what you want.
3. Sending on a channel from a select to make default block.
The default runs whenever the send case isn't immediately ready. That's not the same as "channel is full." If no receiver is currently waiting on an unbuffered channel, default runs even when no one is "wrong." This pattern is mostly fine in practice but the reasoning is subtle - if you find yourself relying on it for correctness rather than performance, reconsider.
Try it: Build the busy-waiting example. Run it 100 times in a row in a loop (for i := 0; i < 100; i++ { ... }). On most machines it'll print "computed" most of the time, "" rarely. The bug is real but probabilistic. -race makes it deterministic.
context.Context for cancellation¶
If a goroutine is doing slow work (an HTTP request, a query), you need a way to say "stop, never mind." That's what context is for:
import "context"
func fetchSlow(ctx context.Context, url string) (string, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body), nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := fetchSlow(ctx, "https://slow.example.com")
if err != nil {
fmt.Println("gave up:", err)
}
}
The HTTP client checks ctx periodically; if it's canceled (timeout fired, or someone called cancel()), the request aborts. The convention in Go: any long-running function takes ctx as the first argument. The standard library is built around this.
You'll write func DoStuff(ctx context.Context, ...) constantly in production code.
Buffered vs unbuffered channels¶
The channel we used (make(chan int)) is unbuffered: send blocks until someone is ready to receive. Useful for handoffs.
A buffered channel holds N values:
ch := make(chan int, 5)
ch <- 1 // doesn't block
ch <- 2 // doesn't block
// ... 3 more before sending blocks
Three honest rules: 1. Default to unbuffered. Forces you to think about the handoff. 2. Use buffer = 1 for "I want to send and not wait for a receiver." Common for signaling. 3. Use buffer = N when you know N. A worker pool's job queue. A bounded retry buffer.
A buffered channel is not a queue you should fill up and ignore. If the buffer fills, the sender blocks just like an unbuffered channel.
The select statement¶
select lets a goroutine wait on multiple channel operations at once:
select {
case msg := <-input:
handle(msg)
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
return errors.New("timeout")
}
Whichever case is ready first wins. If none is ready, select blocks. With a default: case, select is non-blocking. This is the workhorse for any goroutine that has more than one thing to wait on - almost always combined with ctx.Done() for cancellation.
Worker pools (bounded concurrency)¶
"Do these 1000 things, but only 10 at a time":
jobs := make(chan Job, len(work))
results := make(chan Result, len(work))
// Start 10 workers.
for i := 0; i < 10; i++ {
go func() {
for j := range jobs {
results <- process(j)
}
}()
}
// Send work.
for _, w := range work {
jobs <- w
}
close(jobs) // workers' range loops will exit when channel drains
// Collect.
for i := 0; i < len(work); i++ {
r := <-results
// ... use r ...
}
close(jobs) lets the workers know there's no more work; their for j := range jobs loops exit cleanly. Without that close, the workers would block forever after the last job.
The golang.org/x/sync/errgroup package wraps this pattern with proper error handling. Use it; don't roll your own.
The race detector¶
Concurrency bugs are the worst kind: rare, hard to reproduce, often invisible until production. Go ships with a race detector. Run your tests with it:
If two goroutines access the same memory without coordination (and at least one is writing), the race detector reports it with full stack traces from both sides. Production servers should not run with -race (it's slow), but every test suite should.
The first time you turn it on in an existing codebase, brace yourself.
Goroutine leaks¶
A goroutine that blocks forever and never gets a chance to return is leaked. It holds memory and other resources. Common patterns:
// Leak: caller forgot to read from the channel
go func() {
ch <- expensiveCompute() // blocks forever if nobody reads
}()
// Leak: no cancellation; channel never closed
go func() {
for msg := range input { // blocks forever if no one closes input
handle(msg)
}
}()
The fix is always the same: every long-running goroutine should be reachable by a ctx.Done() channel or a closed input channel that lets its loop exit. If you can't draw an "and how does this goroutine end" arrow when you write it, you're leaking.
A production trick: dump goroutines with runtime.NumGoroutine() and pprof.Lookup("goroutine").WriteTo(os.Stdout, 1) periodically. A leak shows up as a number that only ever grows.
Mutexes still exist, and that's fine¶
The "share memory by communicating" advice is right most of the time. But sometimes you genuinely need shared state - a counter, a cache, a connection pool. Use sync.Mutex (or sync.RWMutex for read-heavy work):
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
Two rules: hold the lock for as short a time as possible; never call out to user code while holding a lock (deadlock risk). The race detector catches forgotten locks the same way it catches channel-data races.
How the scheduler actually picks goroutines¶
You met G-M-P in the main flow. Going one level deeper:
Each P has a local run queue - a small fixed-size ring buffer (256 slots, in current Go). New goroutines created on this P go to the back of the queue. When the running G blocks or finishes, the P picks the next G from the front.
There's also a global run queue. When a P's local queue is full, half its goroutines are moved to the global queue. When a P's local queue is empty, it checks the global queue before stealing.
Work-stealing. When a P finds nothing in its local queue and nothing in the global queue, it picks a random other P and steals half of that P's local queue. This is the magic that keeps all cores busy: no P stays idle while another has work.
Preemption. A G doesn't run forever. Since Go 1.14, the runtime can interrupt any G after about 10 ms via asynchronous preemption (delivered via OS signal). Before 1.14, preemption only happened at function call boundaries - tight loops without function calls could starve other goroutines. Modern Go doesn't have this problem.
Blocking calls. When a G makes a blocking syscall (file read, network call), the runtime can: - Detach the M from its P, leave the M waiting on the kernel. - Create or take a new M for the P, so other goroutines on that P keep running.
When the syscall returns, the original M tries to get a P back; if none is available, the G is put on a queue for some P to pick up later.
The result: blocking syscalls don't block other goroutines scheduled to the same P. Network I/O in particular is handled even more cleverly - the runtime uses non-blocking syscalls with epoll/kqueue so the M doesn't even park.
Try it: Run a server that handles thousands of concurrent HTTP requests. Watch runtime.NumGoroutine() over time. You'll see thousands of goroutines, all blocked in net.Read, with only a handful of M's actually running. That's the scheduler at work.
Channels in the runtime - the actual source¶
The hchan struct you met in the main flow is in src/runtime/chan.go. The whole file is about 800 lines and surprisingly readable. The key functions:
makechan- allocates the hchan struct plus the buffer (if N > 0).chansend- implementsch <- v. Walksrecvqfirst (handoff to waiting receiver); falls back to the buffer; otherwise parks the sending G insendq.chanrecv- symmetric: walkssendqfirst, then buffer, then parks inrecvq.closechan- setsclosed, wakes everyone in both queues.
The clever bits:
sudog- short for "suspended goroutine." A small struct linked intosendq/recvqto remember which G is waiting and what value it wants to send/receive. The samesudogmechanism is used bysync.Mutex,sync.WaitGroup, etc.- Direct handoff for unbuffered channels. When sender meets receiver, the value is written directly from the sender's stack to the receiver's stack - never through the buffer, since unbuffered channels have no buffer. This saves a copy and is the kind of optimization that makes channels fast.
selectis compiled into a single call toselectgo, which inspects all the cases atomically. There's no "polling" inselect.
Read chan.go once when you want to fully understand channels. You don't need to memorize it - but seeing the structure removes all remaining mystery.
Where this material came from¶
- The Go Memory Model - the authoritative source for happens-before rules and broken-pattern examples.
- Faster Go channels with
hchanandsudog- the runtime source. Surprisingly readable. - Go's work-stealing scheduler - Jaana Dogan's clear write-up of the G-M-P model.
- The Go scheduler - Daniel Morsing's blog post; one of the canonical references for how G-M-P works.
- Go: How Are Deadlocks Triggered with
select- the spec's definition of select semantics.
Exercise¶
In a new file parallel.go:
Write a program that:
- Has a slice of 10 numbers:
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}. - For each number, in a separate goroutine, computes its square and sends the result on a channel.
- In
main, receives all 10 squares from the channel and adds them up. - Prints the total. (Expected: 385, the sum of 1² + 2² + ... + 10².)
Hints: - Use make(chan int) for the results channel. - Loop over nums starting goroutines, then a second loop (for range nums) receiving results. - Add each received value to a running total.
Stretch: make a version using sync.WaitGroup and a regular slice instead of a channel (each goroutine writes to its own slot in a []int of length 10; main waits and sums). Compare which is easier to read.
What you might wonder¶
"Is a goroutine the same as a thread?" No, but close enough for now. A goroutine is much cheaper than an OS thread (you can have millions of goroutines without trouble; you can't have millions of threads). Under the hood, Go's runtime schedules many goroutines onto a small pool of threads. The full picture lives in the "Go Mastery" path; for now, treat a goroutine as "a thing that runs at the same time as other things."
"What happens if two goroutines write to the same variable without a channel or lock?" A data race - undefined behavior, randomly-corrupt results, intermittent crashes. Go has a built-in detector: run your program with go run -race yourfile.go. It reports races at runtime. Run with -race whenever you write goroutines until you trust your code.
"When should I NOT use goroutines?" When the work is fast and sequential. Spinning up a goroutine has small overhead; for sub-microsecond tasks, you'll often be slower. Goroutines pay off when each unit of work takes more than ~10µs, or when units of work can genuinely happen at the same time (waiting on I/O, network, disk).
"What's select?" A way to wait on multiple channels at once - receive from whichever one is ready first. Useful in real programs. Out of scope for an intro page; we'll meet it in real code in page 12.
Done¶
You can now: - Start a goroutine with go funcCall(). - Wait for a known number of goroutines to finish with sync.WaitGroup. - Create and use channels (make(chan T), ch <- v, <-ch). - Range over a channel until it's closed. - Understand the slogan "share memory by communicating." - Recognize the major footguns: closed channels, nil channels, data races.
Concurrency is a big topic and we've only scratched the surface. The good news: you can write quite a lot of useful concurrent code with just these primitives.
Next page: writing tests for your code - so you know it works and you'll know when it stops working.
Next: Tests → 10-tests.md