Goroutines, Channels & Buffers
This is the part of Go that trips up almost everyone coming from another language — and the part where watching it happen matters more than reading about it. Every claim on this page was checked against a real Go 1.26 toolchain and the official language specification before being written down; nothing here is guessed.
Goroutines
A goroutine is a function that runs independently of the one that
started it — at the same time as everything else your program is doing. You don't
manage threads, thread pools, or callbacks yourself; you write ordinary sequential code
and put the word go in front of a function call. That's the entire syntax.
The Go runtime schedules potentially hundreds of thousands of these onto a handful of
real operating-system threads, because each goroutine starts with only a few kilobytes
of stack (which grows as needed) instead of the megabytes an OS thread reserves.
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(time.Duration(4-id) * 50 * time.Millisecond)
fmt.Printf("worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait()
fmt.Println("all workers done")
}
Notice the order: worker 3 finishes first, then worker 2, then worker 1
— the reverse of the order they were started in, because each one sleeps for a
deliberately different amount of time and Go makes no promise about the order independent
goroutines finish in. sync.WaitGroup is the standard way to wait for a batch
of them: call Add before starting each one, Done (usually
deferred) when it finishes, and Wait blocks the caller until the count is
back to zero.
Forgetting wg.Wait() (or not passing the WaitGroup by pointer) means
main can exit before the goroutines finish. Go does not wait for
stray goroutines when main returns — it just ends the program.
Goroutine leaks: forgotten work that never finishes
A goroutine that blocks forever is a leak — it never exits,
never gets garbage collected, and holds onto whatever memory and channels it
references. The most common cause: sending to a channel that nobody will ever
receive from, or receiving from a channel that nobody will ever send to. You can
detect leaks with runtime.NumGoroutine():
package main
import (
"fmt"
"runtime"
"time"
)
func leak() {
ch := make(chan int)
go func() {
ch <- 1 // blocks forever: no receiver
}()
}
func main() {
fmt.Println("goroutines before:", runtime.NumGoroutine())
leak()
time.Sleep(50 * time.Millisecond)
fmt.Println("goroutines after:", runtime.NumGoroutine())
}
By default, GOMAXPROCS equals the number of CPU cores available,
which means Go can run that many goroutines truly simultaneously on
separate OS threads. You rarely need to change this — the Go scheduler
handles multiplexing goroutines onto threads automatically. But knowing it exists
helps when reasoning about performance: on a 4-core machine, up to 4 goroutines
execute at the exact same instant, while the rest wait their turn. Setting
runtime.GOMAXPROCS(1) forces all goroutines onto a single thread,
which is sometimes useful for deterministic testing but not for production.
Channels: a synchronous handoff
A goroutine that computes something needs a way to hand the result back. A
channel is a typed pipe: one goroutine sends a value in with
ch <- value, another receives it with <-ch. Create one
with make(chan T). With no extra argument, the channel has zero capacity
— it's unbuffered.
“If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender and receiver are ready.” An unbuffered channel holds nothing — it's a rendezvous point, not a mailbox. A send that has no receiver waiting simply blocks until one shows up.
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string) // unbuffered
go func() {
fmt.Println("sender: waiting to send...")
ch <- "hello"
fmt.Println("sender: sent, handoff complete")
}()
time.Sleep(200 * time.Millisecond)
fmt.Println("main: about to receive")
msg := <-ch
fmt.Println("main: received:", msg)
time.Sleep(50 * time.Millisecond)
}
Read the output against the visual above: the sender goroutine prints
"waiting to send..." immediately, then goes silent for 200ms
— that's the send blocking, exactly as the spec describes, because no receiver is
ready yet. Only once main reaches <-ch does the handoff
happen and both sides continue. This is why unbuffered channels are often used not just
to move data, but to synchronize two goroutines at a specific point.
Channel directions: restricting send and receive
When you pass a channel to a function, you can restrict what that function is
allowed to do with it. A send-only channel (chan<- T)
can only be sent to; a receive-only channel (<-chan T)
can only be received from. This is a compile-time guarantee — the compiler
catches accidental misuse:
package main
import "fmt"
// sendOnly can only send to the channel
func producer(out chan<- int) {
for i := 0; i < 3; i++ {
out <- i * 10
}
close(out)
}
// receiveOnly can only receive from the channel
func consumer(in <-chan int) {
for v := range in {
fmt.Println("received:", v)
}
}
func main() {
ch := make(chan int, 3)
go producer(ch)
consumer(ch)
}
A function that accepts <-chan int tells callers "I only read from
this channel" — it can't accidentally close it or send to it. Similarly,
chan<- int says "I only write here." This is the same principle as
unexported fields restricting access: use the type system to prevent bugs at
compile time rather than at runtime. The range loop in
consumer also automatically exits when the channel is closed, which
is why the producer calls close(out).
Buffered channels: a queue with capacity
Add a number when creating the channel and you get a buffer: a fixed-size queue that can
hold values even before anyone receives them. make(chan T, 3) creates a
channel that can hold 3 values. A send only blocks once the buffer is full; a receive
only blocks once it's empty.
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
fmt.Println("sent 1 -> len:", len(ch), "cap:", cap(ch))
ch <- 2
fmt.Println("sent 2 -> len:", len(ch), "cap:", cap(ch))
ch <- 3
fmt.Println("sent 3 -> len:", len(ch), "cap:", cap(ch))
fmt.Println("received:", <-ch, "-> len:", len(ch))
fmt.Println("received:", <-ch, "-> len:", len(ch))
fmt.Println("received:", <-ch, "-> len:", len(ch))
}
len(ch) is how many values are sitting in the buffer right now;
cap(ch) is the fixed size you gave make. None of these three
sends blocked, because the buffer never filled up before the corresponding receive.
A send to a full buffered channel doesn't error — it blocks until something is received. If nothing ever will be, that's a deadlock, and the Go runtime actually detects this specific case and crashes loudly instead of hanging forever silently:
package main
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
ch <- 4 // buffer full (len == cap == 3), no receiver anywhere -> deadlock
}
The exact memory address and line offset will vary by machine and Go version —
the message text itself, fatal error: all goroutines are asleep - deadlock!,
is the stable part, and comes verbatim from the Go runtime source.
Closing channels
A channel can be closed to signal “nothing more is coming.”
Closing is done by the sender with close(ch), never the receiver, and only
ever once. Receiving from a closed channel never blocks: buffered values already
inside come out first, in order, and after that every receive returns the element
type's zero value immediately.
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)
v, ok := <-ch
fmt.Println(v, ok)
v, ok = <-ch
fmt.Println(v, ok)
v, ok = <-ch
fmt.Println(v, ok)
for v := range ch {
fmt.Println("range:", v)
}
fmt.Println("range over closed+drained channel exited cleanly")
}
The two-value receive form v, ok := <-ch is how you tell a real value
from a zero-value-because-closed: ok is true for the first two
receives (real sent values), then false once the channel is drained and
closed — note the value is 0, the zero value for int, not
some sentinel. for range over a channel receives until it's closed and
drained, then exits the loop on its own — which is why the final line prints with no
"range: ..." lines above it here: there was nothing left to range over.
Unlike receiving, sending on a closed channel is not safe — it panics immediately:
package main
func main() {
ch := make(chan int)
close(ch)
ch <- 1 // should panic: send on closed channel
}
A channel variable that was only declared, never make'd, holds
nil — and a nil channel behaves completely differently from a closed
one. Sending or receiving on a nil channel never panics; it blocks forever.
This is a common source of a silent, permanent hang, usually from forgetting to
initialize a channel field on a struct.
for range over a channel: clean iteration
The for range loop you know from slices and maps also works on
channels — it receives each value until the channel is closed and
drained, then exits the loop automatically. This is the idiomatic way to consume
all values from a producer:
package main
import "fmt"
func main() {
ch := make(chan string, 3)
ch <- "alpha"
ch <- "beta"
ch <- "gamma"
close(ch)
for msg := range ch {
fmt.Println(msg)
}
fmt.Println("channel closed and drained")
}
range needs a closed channel
If you forget to call close(ch), the for range loop will
block forever after draining the buffer — it doesn't know no more values are
coming. The sender must close the channel to signal completion. This is different
from the two-value receive form (v, ok := <-ch), which lets the
receiver detect a closed channel without the sender needing to cooperate.
Select: waiting on multiple channels
What if a goroutine needs to wait on more than one channel at once, and react to
whichever one is ready first? That's what select is for — it looks
like a switch, but each case is a channel operation.
The most common use of select is a timeout, using time.After as one of the cases:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(300 * time.Millisecond)
ch <- "result"
}()
select {
case res := <-ch:
fmt.Println("got:", res)
case <-time.After(100 * time.Millisecond):
fmt.Println("timed out")
}
}
The goroutine needed 300ms; the timeout case fires at 100ms, so it wins. Now the interesting case — what happens when two cases are ready at the exact same moment:
package main
import "fmt"
func main() {
ch1 := make(chan int, 1)
ch2 := make(chan int, 1)
ch1 <- 1
ch2 <- 2
select {
case v := <-ch1:
fmt.Println("got from ch1:", v)
case v := <-ch2:
fmt.Println("got from ch2:", v)
}
}
“If one or more of the communications can proceed, a single one that can proceed
is chosen via a uniform pseudo-random selection.” That output above is the
actual result of running that exact program 10 times in a row — not a
simulation. Count them: 5 and 5. select does not favor the first case
written, or either channel — which is exactly what the multiplexer visual above
demonstrates every time you click it.
default: non-blocking channel operations
A select with a default case never blocks — if none
of the channel cases are ready, the default runs immediately. This is
useful for "try without waiting" patterns like polling or background checks:
package main
import "fmt"
func main() {
ch := make(chan int)
select {
case ch <- 1:
fmt.Println("sent")
default:
fmt.Println("channel not ready, skipping send")
}
fmt.Println("continues immediately without blocking")
}
The send to ch would block forever (no receiver), but the
default case catches it and the program continues. This pattern is
common in background goroutines that check for work without blocking on it.
The done channel pattern: broadcasting shutdown
A common need is to tell multiple goroutines to stop at once. Closing a channel
broadcasts to every goroutine waiting on it — all of them receive the
zero value simultaneously. A chan struct{} (zero-size, no data carried)
is the idiomatic choice for a pure signal channel:
package main
import (
"fmt"
"sync"
)
func worker(id int, done <-chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-done:
fmt.Println("worker", id, "stopped")
return
default:
// simulate work
}
}
}
func main() {
done := make(chan struct{})
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, done, &wg)
}
// Broadcast stop signal to all workers
close(done)
wg.Wait()
fmt.Println("all workers stopped")
}
chan struct{} and not chan bool?
A struct{} takes zero bytes of memory — it's the
smallest possible type in Go. When you only need a signal (open/closed) and not a
value, chan struct{} communicates intent clearly: "this channel carries
no data, only a lifecycle signal." It also avoids any ambiguity about what
true vs false might mean in the signal context.
Interview Questions
Common interview questions about goroutines, channels, and select — organized by experience level. Click a tab to filter.
Q1. What is a goroutine?
A goroutine is a lightweight function invocation managed by the Go runtime rather than the OS, started by prefixing a call with the go keyword. Many goroutines are multiplexed onto a small number of OS threads by Go's scheduler, so you can create thousands of them cheaply — each starts with only a few KB of stack that grows as needed.
Q2. How do you start a goroutine, and does the program wait for it to finish?
You write go f() to run f concurrently. The calling goroutine does not wait — execution continues immediately to the next statement. If main returns before the goroutine finishes, the goroutine is simply abandoned, so you need an explicit synchronization mechanism like sync.WaitGroup or a channel.
Q3. What is sync.WaitGroup used for?
sync.WaitGroup lets one goroutine wait for a collection of other goroutines to finish. You call Add(n) to set the count of goroutines to wait for, each goroutine calls Done() when finished, and the waiting goroutine calls Wait(), which blocks until the count reaches zero.
Q4. What is a channel in Go?
A channel is a typed conduit created with make(chan T) that goroutines use to send and receive values with the <- operator. Channels provide both data transfer and synchronization between goroutines, avoiding the need for goroutines to share memory directly.
Q5. What is the difference between an unbuffered and a buffered channel?
An unbuffered channel, made with make(chan T), has no capacity — a send blocks until another goroutine performs a matching receive, forming a synchronous handshake. A buffered channel, made with make(chan T, n), has room for n values, so a send only blocks once the buffer is full.
Q6. How do you close a channel, and why would you?
You call close(ch) to signal that no more values will be sent on it. It is typically used to tell one or more receiving goroutines that a stream of values is complete, most commonly so a range loop over the channel can terminate.
Q7. What does the "comma ok" idiom tell you when receiving from a channel?
Writing v, ok := <-ch receives a value and sets ok to true if the value came from an active send, or false if the channel is closed and drained, in which case v is the zero value of the channel's type. It's the only reliable way to distinguish a real zero value from a closed channel.
Q8. What happens when you range over a channel?
for v := range ch receives values from ch one at a time, blocking between each until a value arrives, and the loop exits automatically when the channel is closed and all buffered values have been received. It's a convenient shorthand for repeated receives with the comma-ok check.
Q9. What is a select statement used for?
select lets a goroutine wait on multiple channel operations at once, proceeding with whichever case is ready first. It's the primary tool for multiplexing several channels — for example waiting on a result channel and a timeout channel simultaneously.
Q1. What is a goroutine leak, and how does it typically happen?
A goroutine leak occurs when a goroutine blocks forever — usually on a channel send or receive that will never be matched — and is never garbage collected because the runtime can't prove it's unreachable while blocked. A common cause is a goroutine sending on an unbuffered channel whose only receiver has already returned, or a worker reading from a channel that the producer forgot to close.
Q2. Why is it usually the sender's responsibility to close a channel, not the receiver's?
Only a sender knows when it's truly done producing values; if a receiver closes the channel, a subsequent send from the producer will panic. Closing should also happen exactly once and typically by whichever goroutine "owns" the channel, so multiple senders generally require a separate coordination mechanism (like a sync.WaitGroup) to decide when it's safe to close.
Q3. What happens if you send on a closed channel? What about closing a channel twice?
Sending on a closed channel panics with send on closed channel. Calling close on an already-closed channel also panics, with close of closed channel. Both are why channel ownership and close semantics need to be unambiguous in concurrent code.
Q4. What happens if you receive from a nil channel, or use one in a select?
A receive (or send) on a nil channel blocks forever, since there's no channel to synchronize with. This is actually useful in a select: a nil channel case is never selected, which lets you disable a case dynamically by setting its channel variable to nil.
Q5. What is the purpose of the default case in a select statement?
If none of a select's channel cases are ready and a default case is present, default runs immediately instead of blocking. This turns select into a non-blocking channel check, useful for polling a channel without stalling the goroutine.
Q6. How would you implement a timeout on a channel receive?
Use a select with the channel receive as one case and <-time.After(d) as another. time.After returns a channel that receives a value after duration d, so whichever fires first wins; if the original receive doesn't happen in time, the timeout case runs instead.
Q7. Give an example of a simple deadlock involving channels, and how Go reports it.
Sending on an unbuffered channel with no goroutine ever receiving from it — for example ch := make(chan int); ch <- 1 in main with nothing else running — blocks forever. If every goroutine in the program is blocked like this, the Go runtime detects it and terminates with fatal error: all goroutines are asleep - deadlock!
Q8. What's a common mistake when calling wg.Add() with goroutines, and why does it cause bugs?
Calling wg.Add(1) inside the goroutine itself, rather than before starting it, creates a race: Wait() might run before Add executes, so the count could still be zero and Wait() returns early while goroutines are still pending. Add should always be called in the parent goroutine before the corresponding go statement.
Q9. Why can looping variables captured by goroutines cause bugs, and how is it fixed?
Historically, a for loop reused the same variable across iterations, so goroutines closing over it by reference could all see the final value once they actually ran, rather than the value at the iteration when they were launched. Since Go 1.22 the loop variable is scoped per iteration, fixing this automatically, but code targeting older toolchains still needs to shadow the variable inside the loop body or pass it as a parameter to the goroutine's function.
Q1. If multiple cases in a select are ready simultaneously, which one runs?
Go picks uniformly at random among the ready cases, specifically to prevent starvation and to stop programs from accidentally depending on evaluation order. This means code that assumes a particular case "wins" when several channels are ready simultaneously is relying on undefined behavior.
Q2. What is a data race, and how does it differ from a deadlock?
A data race is when two or more goroutines access the same memory location concurrently, at least one of them a write, without synchronization — it produces undefined, often nondeterministic behavior rather than necessarily hanging. A deadlock, by contrast, is goroutines permanently blocked waiting on each other; it's a liveness problem, not a memory-correctness problem, and the two can occur independently of each other.
Q3. How does Go's race detector work, and what are its limitations?
Running with go run -race or go test -race instruments memory accesses and goroutine synchronization events at compile time to detect races dynamically as the program executes. It only catches races on code paths that actually run during that execution, so it can miss races in untested paths, and it adds significant memory and CPU overhead, making it unsuitable for production use.
Q4. Explain the phrase "don't communicate by sharing memory; share memory by communicating."
Rather than having goroutines mutate shared variables protected by locks, Go encourages passing ownership of data between goroutines over channels, so at any moment only one goroutine is actively working with a given piece of data. This doesn't eliminate sync.Mutex or atomics as tools — they're still appropriate for protecting simple shared counters or caches — but channels are favored for coordinating higher-level control flow and data ownership transfer.
Q5. What is the fan-out/fan-in pattern, and what problem does it solve?
Fan-out starts multiple goroutines reading from the same input channel to parallelize work across them; fan-in merges multiple result channels into a single channel, typically by starting a goroutine per input channel that forwards values into a shared output channel, closed once a sync.WaitGroup confirms all forwarders are done. Together they let you scale up worker concurrency while still consuming results through one unified channel.
Q6. When would you use context.Context cancellation instead of just closing a channel to signal goroutines to stop?
context is preferable when cancellation needs to propagate across API boundaries, carry a deadline or timeout, or fan out to a tree of goroutines and downstream calls that didn't create the original channel — ctx.Done() is itself a channel closed on cancellation, so it composes naturally with select. Closing a plain channel works well for simple, localized "stop" signals within a single package where you control both ends.
Q7. Why is closing a channel a broadcast, and how is that used to signal multiple goroutines at once?
A closed channel immediately and repeatedly yields the zero value with ok == false to every goroutine that receives from it, unlike a single value sent on it which only one receiver can consume. This makes close(done) a common way to broadcast a stop signal to an arbitrary number of goroutines simultaneously, each selecting on <-done.
Q8. What determines the size of a buffered channel's queue, and how does full/empty affect send and receive?
The capacity is fixed at creation with make(chan T, n) and cannot be changed afterward. A send blocks only when the buffer holds n unreceived values (it's full), and a receive blocks only when the buffer is empty, so a correctly sized buffer can decouple producer and consumer speed without either goroutine blocking on every operation.
Q9. Between an unbuffered channel and a mutex-protected shared variable, what tradeoffs would push you toward one over the other?
A mutex is typically cheaper and simpler for protecting a small piece of shared state accessed briefly and frequently, like a counter or cache entry, with no need to model control flow. A channel is preferable when you're coordinating handoff of ownership, sequencing steps between goroutines, or building pipelines, since it expresses "this goroutine is done with this data, the next one may proceed" directly rather than through ad hoc lock discipline.