Goroutines and Channels in Go: A Fresher-Friendly Guide
A beginner's guide to Go concurrency: what a goroutine actually is, every channel type (unbuffered, buffered, directional), select, and a worker pool example that ties it all together — with diagrams.
TL;DR
A goroutine is a cheap, independently-scheduled function; a channel is the typed pipe goroutines use to hand values to each other safely — learn the four channel flavors (unbuffered, buffered, send-only, receive-only) and you can read almost any concurrent Go program.
What You'll Learn
- What a goroutine actually is, and why it's not the same thing as an OS thread
- Why launching a goroutine without a channel usually loses the result
- The difference between unbuffered and buffered channels, and when each blocks
- How directional channel types (chan<- T and <-chan T) catch bugs at compile time
- How to close a channel safely and range over it
- How to multiplex multiple channels with select, including a timeout
- How to build a small worker pool that combines everything above
Prerequisites
- Basic Go syntax (variables, functions, for loops)
The problem
Here's a function that fetches three things, one after another:
func fetchAll() []string {
var results []string
results = append(results, fetchUser()) // takes ~200ms
results = append(results, fetchOrders()) // takes ~200ms
results = append(results, fetchInvoice()) // takes ~200ms
return results
}None of these three calls depend on each other, but this code still pays for all three, one at a time — about 600ms total. The CPU spends almost that entire time doing nothing but waiting on the network. This is exactly the situation Go's concurrency model was built for: run independent, waiting-heavy work at the same time instead of in a queue.
The two tools for that are goroutines (run this function concurrently) and channels (safely hand values between goroutines). This article covers both, starting from zero.
What is a goroutine?
A goroutine is a function running independently of the one that started it.
You create one by putting go in front of a function call:
go fetchUser()That's it — fetchUser() now runs concurrently, and the line right after
go fetchUser() executes immediately without waiting for it to finish.
It's tempting to think of a goroutine as "a thread," but it's a different, much cheaper thing:
| OS thread | Goroutine | |
|---|---|---|
| Starting cost | ~1-8 MB stack, allocated upfront | ~2 KB stack, grows as needed |
| Who schedules it | The operating system | Go's own runtime scheduler |
| How many you can run | Thousands, realistically | Hundreds of thousands, realistically |
| Created by | OS-level thread APIs | The go keyword |
The Go runtime multiplexes many goroutines onto a small number of real OS
threads, parking a goroutine the instant it blocks on I/O and running
something else in its place. That's what makes go someFunc() cheap enough
to use liberally instead of something you reach for only under pressure.
Your first goroutine — and the bug it hides
func main() {
go fmt.Println("hello from a goroutine")
fmt.Println("hello from main")
}Run this, and you'll very likely see only hello from main — the goroutine's
line might never print at all. main() doesn't wait for goroutines it
launches; the instant main() returns, the whole program exits, mid-flight
goroutines included.
This is the first real lesson: launching a goroutine gets you concurrency, not coordination. You need a way for one goroutine to hand a value (or just a "done" signal) back to another, and wait for that to happen. That's what a channel is for.
What is a channel?
A channel is a typed, thread-safe pipe. One goroutine sends a value into it;
another receives that same value out the other end. Declare one with chan
plus the type of value it carries:
ch := make(chan int) // a channel that carries ints
strs := make(chan string) // a channel that carries stringsRendering diagram…
Go's own design philosophy puts it directly: "Don't communicate by sharing memory; share memory by communicating." Instead of two goroutines both reaching into the same variable and racing each other (a data race), one goroutine sends a value through a channel and stops touching it — the other goroutine receives it and now owns it. There's never a moment when both sides are touching the same piece of memory at once.
Sending and receiving
The <- operator is doing all the work here — its direction tells you
whether it's a send or a receive:
ch <- value // send: value goes into the channel
value = <-ch // receive: a value comes out of the channelFixing the earlier example with a channel:
func main() {
ch := make(chan string)
go func() {
ch <- "hello from a goroutine" // send
}()
msg := <-ch // receive — blocks here until something is sent
fmt.Println(msg)
}This time the output is guaranteed. main() blocks on <-ch until the
goroutine sends its value, and that is the coordination a bare goroutine
didn't give you.
Unbuffered vs. buffered channels
This is the first channel "type" distinction, and it changes when a send blocks.
An unbuffered channel (make(chan int)) has no storage at all. A send
blocks until a receiver is ready to take the value at that exact moment —
it's a handoff, not a mailbox.
Rendering diagram…
A buffered channel (make(chan int, 3)) has room for a fixed number of
values. A send only blocks once the buffer is full; until then, it succeeds
immediately and moves on:
ch := make(chan int, 3) // buffer capacity 3
ch <- 1 // succeeds immediately — buffer: [1]
ch <- 2 // succeeds immediately — buffer: [1, 2]
ch <- 3 // succeeds immediately — buffer: [1, 2, 3], now full
ch <- 4 // blocks — no room until someone receivesNeither one is "the right one" by default. Unbuffered channels are for handoffs and synchronization — you want the sender to wait until the receiver is actually there. Buffered channels are for smoothing out a burst, letting a producer get a little ahead of a consumer without either side blocking on every single value.
Directional channels: the other type
A channel's value type (chan int vs. chan string) is one axis. The
other axis is direction — whether a function is allowed to only send,
only receive, or both:
func producer(out chan<- int) { // send-only: can only do out <- v
out <- 42
}
func consumer(in <-chan int) { // receive-only: can only do v := <-in
v := <-in
fmt.Println(v)
}chan<- int— a send-only channel. Trying to receive from it is a compile error.<-chan int— a receive-only channel. Trying to send to it is a compile error.chan int— bidirectional; this is whatmake(chan int)gives you, and Go automatically converts it to either restricted type when you pass it to a function expecting one.
This isn't just documentation-by-type-signature. If producer accidentally
tried to read from out, the compiler rejects it before the program ever
runs — the same way a wrong argument type would. It's a real, enforced
contract about which side of a conversation a function is allowed to be on.
Closing a channel and ranging over it
A sender can signal "no more values are coming" by closing the channel:
close(ch)Two rules that matter in practice:
- Only the sender should close a channel, and only once every send is done. Closing a channel a receiver is still expecting to receive from — or closing it twice — panics.
- Receiving from a closed channel never blocks. It immediately returns the value type's zero value. Since that's indistinguishable from a real zero being sent, use the two-value receive form to tell them apart:
v, ok := <-ch
// ok is false only when the channel is closed AND drained —
// otherwise ok is true, even if v happens to be the zero value.The common pattern for draining every value until close is range, which
does exactly this loop for you and exits automatically when the channel
closes:
func main() {
ch := make(chan int)
go func() {
for i := 1; i <= 3; i++ {
ch <- i
}
close(ch) // done sending — let the receiver's range loop end
}()
for v := range ch { // reads 1, 2, 3, then exits when ch closes
fmt.Println(v)
}
}select: waiting on multiple channels
select is to channels what a switch is to values — it waits on several
channel operations at once and runs whichever one is ready first:
select {
case v := <-ch1:
fmt.Println("got from ch1:", v)
case v := <-ch2:
fmt.Println("got from ch2:", v)
}If more than one case is ready at the same instant, select picks one at
random — it deliberately doesn't favor any particular case. The most useful
pattern for a beginner to know first is a timeout, using time.After,
which returns a channel that receives a value once the given duration
elapses:
select {
case result := <-resultCh:
fmt.Println("got a result:", result)
case <-time.After(2 * time.Second):
fmt.Println("timed out waiting for a result")
}This is the idiomatic way to say "wait for this, but not forever" without a
single time.Sleep or manual polling loop.
Putting it together: a tiny worker pool
Here's where goroutines, channels, buffering, direction, and closing all show up in one small, genuinely useful pattern: a fixed number of workers pulling jobs off a shared channel.
Rendering diagram…
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs { // exits automatically once jobs is closed
results <- j * j // pretend this is real work
}
}
func main() {
jobs := make(chan int, 9)
results := make(chan int, 9)
// start 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// send 9 jobs, then signal there are no more
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
// collect exactly 9 results
for i := 0; i < 9; i++ {
fmt.Println(<-results)
}
}Notice the function signature does real work here: worker takes
jobs <-chan int (it may only receive jobs) and results chan<- int (it may
only send results) — the types alone document, and enforce, the data flow in
the diagram above.
Common mistakes
- Forgetting to close a channel a
rangeloop depends on. If nothing ever closesjobsabove, every worker'srange jobsblocks forever after the last job — a goroutine leak that doesn't crash anything, it just quietly holds memory forever. - Sending on an unbuffered channel with no receiver.
ch := make(chan int); ch <- 1inmain()with nothing else running deadlocks — Go's runtime actually detects this exact case and panics withall goroutines are asleep - deadlock!. - Closing a channel more than once, or from the receiving side. Both panic. Only the single sender that knows all sends are finished should close it.
- Reaching into a shared variable from two goroutines instead of using a
channel. It'll often look fine in testing and fail unpredictably in
production. Run tests for concurrent code with
go test -race— the race detector catches exactly this class of bug.
Summary
A goroutine is a function that runs concurrently for the cost of a few
kilobytes of stack; a channel is the typed pipe two goroutines use to pass
values without touching the same memory at the same time. Unbuffered
channels hand off immediately; buffered channels absorb a burst up to their
capacity. Directional types (chan<-, <-chan) turn "this function only
sends" from a comment into something the compiler checks. Put a jobs channel,
a handful of workers, and a results channel together, and you already have
the shape of most real concurrent Go programs — Advanced Go
Concurrency picks up right here with bounding
that concurrency and handling errors across it.
Key Takeaways
- A goroutine is a function that runs concurrently — cheap to start, but main() won't wait for it on its own
- An unbuffered channel send blocks until a receiver is ready; a buffered channel only blocks once its buffer is full
- Directional channel types (chan<- T, <-chan T) are a compiler-enforced contract, not just documentation
- Only the sender should close a channel, and only after every send is done
- select lets one goroutine wait on multiple channels at once, and time.After turns that into a timeout
- A worker pool is just: a jobs channel, N goroutines reading from it, and a results channel they write to
funcRelated()[]Article
Advanced Go Concurrency: Worker Pools, Backpressure and Bounded Concurrency
Why unbounded goroutines don't scale: semaphore-based bounded concurrency, backpressure, errgroup, goroutine leaks, and a real benchmark comparing the two.
Building Background Workers in Go with Goroutines and Channels
A production-oriented worker pool in Go: bounded concurrency, graceful shutdown, panic recovery, and why 'go process(job) for every job' is dangerous.
Context in Go: Cancellation, Deadlines and Request Propagation
Understand Go's context.Context: cancellation, deadlines, timeouts and request-scoped propagation, with practical HTTP and database examples.