Concurrency is what makes a Go interview a Go interview. Goroutines and channels are the language’s signature feature, and the standard library packages around them — sync, context — are the supporting cast. This page bundles all of them into one reference: how to start work in the background, how to talk between goroutines, how to wait for them to finish, and how to cancel them when you change your mind.
import "sync"
import "context"
At a glance
| Need to… | Use |
|---|---|
| Run something in the background | go func() { ... }() |
| Send / receive values between goroutines | channels (ch <- v, <-ch) |
| Wait for N goroutines to finish | sync.WaitGroup |
| Protect shared data | sync.Mutex / sync.RWMutex |
| Run something exactly once | sync.Once |
| Cancel a long-running operation | context.WithCancel |
| Set a timeout | context.WithTimeout |
| Choose between multiple channel operations | select |
Goroutines
A goroutine is a function running concurrently with the rest of your program. Cheap to start (a few KB of stack), cheap to switch between, and managed by the Go runtime — not the OS.
Starting a goroutine
func main() {
go sayHello() // returns immediately
fmt.Println("main")
time.Sleep(time.Second) // give the goroutine time to run
}
func sayHello() {
fmt.Println("hello from goroutine")
}
godoesn’t wait. The function runs in the background; the goroutine that called it keeps going.
Sleep is never the real way to wait — it’s just easy in examples. Real code uses channels, WaitGroup, or context.
Loop variable capture — the most-asked gotcha
Before Go 1.22, this was a famous trap:
// PRE-Go 1.22 — all goroutines see i = 3 because they share i
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // prints 3, 3, 3 (probably)
}()
}
The fix was to pass i as an argument:
for i := 0; i < 3; i++ {
go func(i int) {
fmt.Println(i) // prints 0, 1, 2 (in some order)
}(i)
}
Go 1.22+ changes the loop semantics — each iteration gets its own i, so the original buggy code now works correctly. Interviewers may still test the pre-1.22 behavior, so know both.
Anonymous goroutines
go func() {
fmt.Println("inline")
}()
The trailing () calls the function immediately (in the new goroutine). Forget it and nothing runs.
Channels
A channel is a typed pipe between goroutines. One side sends, the other receives.
ch := make(chan int) // unbuffered channel of ints
ch <- 42 // send
v := <-ch // receive
v, ok := <-ch // receive with "is the channel still open?"
Unbuffered vs buffered
Unbuffered (make(chan T)) — send and receive must happen at the same time. The sender blocks until someone receives. The receiver blocks until someone sends. This is synchronous handoff.
Buffered (make(chan T, N)) — holds up to N values without a receiver. Sender only blocks when full; receiver only blocks when empty.
unbuf := make(chan int) // unbuffered — strict handoff
buf := make(chan int, 3) // can hold 3 values
buf <- 1
buf <- 2
buf <- 3 // still doesn't block
buf <- 4 // BLOCKS until someone receives
Default to unbuffered. Reach for buffered when you have a specific reason — usually decoupling producer from consumer or batching.
Closing a channel
close(ch) signals “no more values will be sent.” Reading from a closed channel returns the zero value immediately.
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)
v, ok := <-ch // v=1, ok=true
v, ok = <-ch // v=2, ok=true
v, ok = <-ch // v=0, ok=false (channel is closed and drained)
for ... range over a channel
Range loops over a channel until it’s closed. This is the most idiomatic way to consume values:
ch := make(chan int)
go func() {
defer close(ch)
for i := 1; i <= 5; i++ {
ch <- i
}
}()
for v := range ch {
fmt.Println(v)
}
// prints 1, 2, 3, 4, 5
The receiver doesn’t need to know how many values are coming — close ends the loop.
Who closes the channel?
The sender closes. Never the receiver. Closing a channel you don’t own can crash the program. If multiple senders share a channel, use a coordinator goroutine or a
sync.WaitGroupto know when they’re all done.
The nil-channel trick
Sending to or receiving from a nil channel blocks forever. This sounds useless, but it’s a classic select trick — set a channel to nil to disable that branch:
var done chan struct{} // nil — receiving from it never returns
select {
case <-done:
fmt.Println("never reached")
case <-time.After(time.Second):
fmt.Println("timeout")
}
select
select chooses between multiple channel operations. Like switch, but every case is a send or receive.
select {
case v := <-ch1:
fmt.Println("from ch1:", v)
case v := <-ch2:
fmt.Println("from ch2:", v)
case ch3 <- 42:
fmt.Println("sent to ch3")
case <-time.After(time.Second):
fmt.Println("timeout")
}
Rules:
- If multiple cases are ready, one is chosen randomly.
- If no case is ready,
selectblocks until one is — unless there’s adefault.
default — non-blocking
select {
case v := <-ch:
fmt.Println("got", v)
default:
fmt.Println("nothing ready")
}
Blocking forever — select {}
select {} // blocks the goroutine forever
Sometimes used in main to keep the program alive while goroutines run.
Timeout pattern
select {
case v := <-ch:
handle(v)
case <-time.After(time.Second):
fmt.Println("took too long")
}
This is the cleanest way to put a timeout on a channel operation.
The sync package
For when channels are overkill, or you need to protect mutable state.
sync.WaitGroup — wait for N goroutines
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Println("worker", i)
}(i)
}
wg.Wait()
fmt.Println("all done")
The pattern:
wg.Add(1)before starting each goroutinedefer wg.Done()as the first line inside the goroutinewg.Wait()blocks until the counter hits zero
Always call
Addfrom the parent goroutine, not from inside the spawned one. Otherwise there’s a race betweenWaitandAdd.
sync.Mutex — protect shared data
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
Always defer mu.Unlock() right after mu.Lock(). It’s almost impossible to leak a lock that way.
sync.RWMutex — many readers, one writer
When reads vastly outnumber writes:
type Cache struct {
mu sync.RWMutex
m map[string]string
}
func (c *Cache) Get(k string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.m[k]
}
func (c *Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
RLock allows multiple goroutines to hold a read lock at once. Lock is exclusive.
sync.Once — run exactly once
var (
once sync.Once
instance *Singleton
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{...}
})
return instance
}
Threadsafe lazy initialization in three lines.
The context package
context.Context is the standard way to carry cancellation and deadlines across API boundaries — especially through goroutines.
Roots — Background and TODO
ctx := context.Background() // for main(), tests, top-of-stack
ctx := context.TODO() // when you don't know what context to use yet
These never cancel. Wrap them to add cancellation.
WithCancel — manual cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // release resources even on early return
go worker(ctx)
// later, somewhere:
cancel() // signal worker to stop
WithTimeout and WithDeadline
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := fetch(ctx, url); err != nil {
// err == context.DeadlineExceeded if it timed out
}
WithDeadline(parent, t) is the same but takes an absolute time.
Checking a context inside a goroutine
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("worker canceled:", ctx.Err())
return
default:
// do one unit of work
}
}
}
ctx.Done() returns a channel that closes when the context is canceled. Selecting on it makes the goroutine cancellable.
Pass
ctxas the first parameter to any function that does network, file, or long-running work. It’s the standard Go convention.
WithValue — pass per-request data (use sparingly)
ctx := context.WithValue(parent, userIDKey, "abc123")
func handle(ctx context.Context) {
id := ctx.Value(userIDKey).(string)
}
WithValue is for request-scoped data like user ID, trace ID, auth token — things that pass through many layers. Don’t use it as a backdoor for passing arguments. If a function needs a parameter, give it a parameter.
Common patterns
Worker pool
func workerPool(numWorkers int, jobs []Job) []Result {
in := make(chan Job)
out := make(chan Result)
var wg sync.WaitGroup
// Start workers
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range in {
out <- process(job)
}
}()
}
// Close out when all workers are done
go func() {
wg.Wait()
close(out)
}()
// Feed jobs
go func() {
defer close(in)
for _, j := range jobs {
in <- j
}
}()
// Collect results
var results []Result
for r := range out {
results = append(results, r)
}
return results
}
Three goroutine roles: workers (read jobs, write results), feeder (writes jobs, closes input when done), collector (drains results in main). The wg.Wait() + close(out) trick is what makes the collector’s range end.
Fan-out, fan-in
Same shape as the worker pool: one input channel, many workers, one output channel.
Pipeline
A series of stages, each running in its own goroutine, connected by channels:
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
// Use it:
for v := range square(gen(1, 2, 3, 4, 5)) {
fmt.Println(v) // 1, 4, 9, 16, 25
}
Each stage takes a read-only channel and returns a read-only channel. Easy to compose.
Cancellation propagation
When a parent context is canceled, all goroutines watching its Done channel see it and shut down:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for i := 0; i < 10; i++ {
go worker(ctx, i)
}
// Cancel everyone:
cancel()
All workers selecting on <-ctx.Done() exit.
Concurrency gotchas
Goroutine leaks
Every goroutine that’s started must eventually finish. If a worker blocks forever (waiting on a channel that’s never closed, never receives), it stays in memory.
// LEAK — if nobody receives, this goroutine blocks forever
go func() {
ch <- compute()
}()
Fix by:
- Making sure someone receives, OR
- Using a buffered channel that can hold the result, OR
- Adding a
selectwith<-ctx.Done()so the goroutine can give up
Send on closed channel — panic
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
This is why the “sender closes” rule matters. If multiple senders share a channel, none of them should close — let a coordinator do it.
Closing a channel twice — also panic
close(ch)
close(ch) // panic: close of closed channel
Same rule: only one closer, and only once.
Data race
Two goroutines accessing the same variable, at least one of which is a write — undefined behavior. Always use a mutex or channel to coordinate.
go run -race main.go # detects races at runtime
The race detector is your friend. Use it in tests.
Reading and writing a map concurrently — panic
Maps aren’t safe for concurrent use. Use sync.Mutex to protect them, or sync.Map if reads vastly outnumber writes.
Quick rules of thumb
- Don’t communicate by sharing memory; share memory by communicating. (The Go proverb.) Prefer channels for coordination, mutexes for guarding state.
- Sender closes the channel. Always.
- Default to unbuffered channels. Add buffering only when you have a reason.
defer wg.Done()as the first line in the goroutine.defer cancel()right afterWithCancel/WithTimeout.- Pass
ctx context.Contextas the first parameter in any cancellable function. - Run tests with
-race.
Common mistakes
- Forgetting
()ongo func() {...}()— defines the function, never runs it. - Reading the loop variable in a goroutine pre-Go 1.22 — captures the shared variable; fix by passing as an arg.
- Closing a channel from the receiver side — panics on the next send.
- Closing the same channel twice — panics.
- Calling
wg.Add(1)inside the goroutine — race withwg.Wait(). - Forgetting
cancel()afterWithCancel— leaks the context. - Putting business logic in
context.WithValue— abused as a backdoor for function args. Pass explicit parameters instead. - Map writes without a mutex — panics with “concurrent map writes.”
- Goroutine leaks from unbuffered sends with no receiver — silent until memory grows.