Skip to content
GoBeginner10 min read

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.

GoBackend.dev
GoConcurrencyGoroutinesChannels

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 threadGoroutine
Starting cost~1-8 MB stack, allocated upfront~2 KB stack, grows as needed
Who schedules itThe operating systemGo's own runtime scheduler
How many you can runThousands, realisticallyHundreds of thousands, realistically
Created byOS-level thread APIsThe 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 strings

Rendering 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 channel

Fixing 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 receives

Neither 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 what make(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:

  1. 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.
  2. 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 range loop depends on. If nothing ever closes jobs above, every worker's range jobs blocks 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 <- 1 in main() with nothing else running deadlocks — Go's runtime actually detects this exact case and panics with all 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