This tutorial covers:
- Goroutines
- Channels
- Channel Direction
select- Worker Pools
- Fan-out / Fan-in
The goal is simple:
One concept → one mental model → one complete example.
Every example is independent. You can copy it into main.go and run:
go run main.go
0. First Understand the Big Picture
Before looking at code, remember these six ideas:
| Concept | Think of it as |
|---|---|
| Goroutine | A lightweight worker |
| Channel | A pipe between workers |
| Channel direction | Making a pipe send-only or receive-only |
select | Waiting on multiple pipes |
| Worker pool | A fixed number of workers sharing jobs |
| Fan-out / Fan-in | Split work between workers, then combine results |
A useful mental picture:
flowchart LR
A[Main Program] --> B[Goroutines]
B --> C[Channels]
C --> D[Channel Direction]
C --> E[select]
B --> F[Worker Pool]
B --> G[Fan-out / Fan-in]
One important distinction:
Concurrency does not necessarily mean things execute at exactly the same instant.
Concurrency means multiple tasks can make progress independently.
1. Goroutines
What?
A goroutine is a function that runs concurrently with other functions.
Normally:
doWork()
Go waits for doWork() to finish.
But:
go doWork()
starts doWork() as a goroutine and continues executing the next line.
Why?
Imagine your application needs to:
- process a request
- save something
- send an email
- perform another independent task
You may not want every task to block everything else.
Goroutines allow independent work to run concurrently.
When?
Use goroutines when tasks can run independently.
Examples:
- handling multiple HTTP requests
- processing background jobs
- making multiple API calls
- reading data from multiple sources
- processing files
- running workers
How?
Put go before a function call:
go myFunction()
Where?
Goroutines are heavily used in:
- web servers
- network services
- background processors
- message consumers
- concurrent applications
Complete Example
package main
import (
"fmt"
"sync"
)
func sayHello() {
fmt.Println("Hello from goroutine")
}
func main() {
var wg sync.WaitGroup
// We are starting 1 goroutine.
wg.Add(1)
go func() {
// Tell WaitGroup that this goroutine
// has finished when the function ends.
defer wg.Done()
sayHello()
}()
fmt.Println("Hello from main")
// Wait until the goroutine finishes.
wg.Wait()
}
Possible output:
Hello from main
Hello from goroutine
You may occasionally see the order reversed.
That is normal.
The goroutine and main() are running concurrently.
Mental Model
Think:
main()
|
|---- worker goroutine
|
|---- main continues
The important syntax is simply:
go function()
2. Channels
Now we have a problem.
If goroutines are independent workers:
How do they safely communicate?
That is where channels come in.
What?
A channel is a typed communication pipe between goroutines.
One goroutine can:
SEND ---> channel ---> RECEIVE
For example:
Goroutine A
|
| "hello"
v
Channel
|
v
Goroutine B
Why?
Goroutines often need to:
- send data
- receive results
- signal completion
- coordinate work
Channels provide a convenient way to do that.
When?
Use a channel when one goroutine needs to communicate with another goroutine.
How?
Create a channel:
ch := make(chan string)
Send into it:
ch <- "hello"
Receive from it:
message := <-ch
The arrow shows the direction the data travels.
Where?
Channels commonly connect:
- producers and consumers
- background workers
- pipelines
- job queues
- result processors
Complete Example
package main
import "fmt"
func main() {
// Create a channel that carries strings.
messageChannel := make(chan string)
// Start a goroutine.
go func() {
// Send a value into the channel.
messageChannel <- "Hello from goroutine"
}()
// Receive the value from the channel.
message := <-messageChannel
fmt.Println(message)
}
Output:
Hello from goroutine
What Actually Happened?
First:
messageChannel := make(chan string)
creates the pipe.
Then the goroutine sends:
messageChannel <- "Hello from goroutine"
Then main() receives:
message := <-messageChannel
Visually:
flowchart LR
A[Goroutine] -->|"Hello"| B[Channel]
B -->|"Hello"| C[main]
Buffered vs Unbuffered Channels
You will frequently hear these terms.
Unbuffered
ch := make(chan int)
There is no storage space.
The sender normally waits until someone receives the value.
Think:
Person A ----hands box directly----> Person B
Buffered
ch := make(chan int, 3)
The channel can temporarily hold three values.
Think:
Person A ---> Mailbox ---> Person B
The sender can leave messages in the mailbox until it becomes full.
Simple Difference
| Unbuffered | Buffered |
|---|---|
make(chan int) | make(chan int, 3) |
| No storage | Has storage |
| Sender and receiver synchronize directly | Sender may continue until buffer is full |
| Good for coordination | Good for temporary queues |
Start with unbuffered channels while learning.
3. Channel Direction
Channels normally support both sending and receiving.
But sometimes a function should only be allowed to do one thing.
For example:
Producer → sends
Consumer → receives
What?
Go lets you declare channels as:
Send-only:
chan<- int
Receive-only:
<-chan int
Why?
It makes your code safer and easier to understand.
If a function should only produce data, there is no reason to allow it to receive data.
When?
Use channel direction when passing channels into functions.
Especially when creating:
- producers
- consumers
- pipelines
- workers
How?
Send-only:
func producer(ch chan<- int)
Receive-only:
func consumer(ch <-chan int)
Where?
Channel direction is especially useful in larger concurrent programs because it documents the intended data flow.
Complete Example
package main
import "fmt"
// This function can ONLY send data.
func producer(ch chan<- int) {
for i := 1; i <= 3; i++ {
ch <- i
}
// Producer owns the sending side,
// so it closes the channel when finished.
close(ch)
}
// This function can ONLY receive data.
func consumer(ch <-chan int) {
for number := range ch {
fmt.Println("Received:", number)
}
}
func main() {
// Normal two-way channel.
numbers := make(chan int)
// Producer receives it as send-only.
go producer(numbers)
// Consumer receives it as receive-only.
consumer(numbers)
}
Output:
Received: 1
Received: 2
Received: 3
Mental Model
Producer
|
| send only
v
Channel
|
| receive only
v
Consumer
The syntax can initially look strange.
Remember:
chan<- int
Arrow points into channel:
SEND
And:
<-chan int
Arrow comes out of channel:
RECEIVE
4. select
Suppose you have two channels.
You don’t know which one will receive data first.
How do you wait for both?
You use:
select
What?
select waits for one of several channel operations to become ready.
It looks similar to switch.
Why?
Without select, managing several channels becomes difficult.
select lets your program say:
Give me whichever channel has data available first.
When?
Use select when dealing with:
- multiple channels
- timeouts
- cancellation
- multiple concurrent operations
How?
Basic structure:
select {
case value := <-channel1:
// channel1 received something
case value := <-channel2:
// channel2 received something
}
Where?
Very common in:
- network servers
- timeout handling
- background workers
- cancellation logic
- concurrent API requests
Complete Example
package main
import (
"fmt"
"time"
)
func main() {
channel1 := make(chan string)
channel2 := make(chan string)
// First goroutine.
go func() {
time.Sleep(500 * time.Millisecond)
channel1 <- "Message from channel 1"
}()
// Second goroutine.
go func() {
time.Sleep(1 * time.Second)
channel2 <- "Message from channel 2"
}()
// We expect 2 messages.
for i := 0; i < 2; i++ {
select {
case message := <-channel1:
fmt.Println(message)
case message := <-channel2:
fmt.Println(message)
case <-time.After(2 * time.Second):
fmt.Println("Timeout")
}
}
}
Output:
Message from channel 1
Message from channel 2
What Happened?
Channel 1 becomes ready after:
500 milliseconds
Channel 2 becomes ready after:
1 second
select handles whichever becomes ready.
flowchart LR
A[Channel 1] --> C[select]
B[Channel 2] --> C
D[Timeout] --> C
C --> E[Handle whichever is ready]
Important
select does not mean:
check channel 1
then
check channel 2
It means:
wait until one is ready
5. Worker Pool
Now we start combining what we learned.
Imagine you have:
100 jobs
You could create:
100 goroutines
But sometimes you don’t want unlimited concurrency.
Instead you could create:
3 workers
and let those three workers process all the jobs.
That is a worker pool.
What?
A worker pool is a fixed number of goroutines processing jobs from a shared channel.
Example:
Worker 1
/
Jobs ---> Worker 2
\
Worker 3
Why?
Worker pools help control concurrency.
Instead of creating thousands of goroutines simultaneously, you choose how many workers should operate.
When?
Use worker pools when you have:
many similar jobs
but want:
limited concurrency
Examples:
- processing files
- database operations
- API requests
- image processing
- queue consumers
- batch processing
How?
Usually you need:
jobs channel
↓
multiple worker goroutines
↓
results channel
Where?
Worker pools are extremely common in backend and distributed systems.
Complete Example
package main
import (
"fmt"
"sync"
"time"
)
// Worker receives jobs and sends results.
func worker(
id int,
jobs <-chan int,
results chan<- int,
wg *sync.WaitGroup,
) {
defer wg.Done()
for job := range jobs {
fmt.Printf(
"Worker %d processing job %d\n",
id,
job,
)
// Simulate some work.
time.Sleep(200 * time.Millisecond)
// Send result.
results <- job * 2
}
}
func main() {
jobs := make(chan int)
results := make(chan int)
var wg sync.WaitGroup
// Create 3 workers.
for workerID := 1; workerID <= 3; workerID++ {
wg.Add(1)
go worker(
workerID,
jobs,
results,
&wg,
)
}
// Send jobs.
go func() {
for job := 1; job <= 5; job++ {
jobs <- job
}
// No more jobs will be sent.
close(jobs)
}()
// Wait for workers and then
// close the results channel.
go func() {
wg.Wait()
close(results)
}()
// Read results.
for result := range results {
fmt.Println("Result:", result)
}
}
Possible output:
Worker 1 processing job 1
Worker 2 processing job 2
Worker 3 processing job 3
Result: 2
Result: 4
Result: 6
Worker 1 processing job 4
Worker 2 processing job 5
Result: 8
Result: 10
The exact worker order may change.
That is normal.
Worker Pool Mental Model
flowchart LR
J[Jobs Channel]
J --> W1[Worker 1]
J --> W2[Worker 2]
J --> W3[Worker 3]
W1 --> R[Results Channel]
W2 --> R
W3 --> R
R --> M[Main]
The key idea is:
Many jobs, limited number of workers.
6. Fan-out and Fan-in
This sounds complicated.
It is actually a simple idea.
Fan-out
One source of work is distributed between multiple workers.
Worker 1
Input -----<
Worker 2
That is:
Fan-out
Fan-in
Multiple result streams are combined into one result stream.
Worker 1 ----\
---> Results
Worker 2 ----/
That is:
Fan-in
What?
Together:
Input
|
+------ Worker 1 -----\
| \
+------ Worker 2 -------> Combined Output
Why?
You can process data concurrently and then bring the results back together.
When?
Useful for processing pipelines where:
- work arrives
- multiple goroutines process it
- results need to be collected
How?
Typical pattern:
Generator
↓
multiple processors
↓
merge
↓
consumer
Where?
Useful in:
- data pipelines
- batch processing
- stream processing
- file processing
- event processing
- search systems
Complete Example
package main
import (
"fmt"
"sync"
)
// Generate numbers.
func generator(numbers ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, number := range numbers {
out <- number
}
}()
return out
}
// Square numbers.
//
// Multiple copies of this function
// can read from the same input channel.
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for number := range in {
out <- number * number
}
}()
return out
}
// Merge multiple channels into one channel.
func merge(channels ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)
wg.Add(len(channels))
// Copy values from each input
// channel into the output channel.
for _, channel := range channels {
go func(ch <-chan int) {
defer wg.Done()
for value := range ch {
out <- value
}
}(channel)
}
// Close output only after
// all input channels finish.
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
// Produce numbers.
input := generator(
1,
2,
3,
4,
5,
6,
)
// FAN-OUT:
// Two goroutines read from the
// same input channel.
worker1 := square(input)
worker2 := square(input)
// FAN-IN:
// Merge both result channels.
results := merge(
worker1,
worker2,
)
for result := range results {
fmt.Println(result)
}
}
Possible output:
1
9
16
25
36
4
The order may change.
But every input number is processed once.
Visual Explanation
flowchart LR
A[Generator]
A --> B[Square Worker 1]
A --> C[Square Worker 2]
B --> D[Merge]
C --> D
D --> E[Main]
The left side is:
FAN-OUT
because one stream goes to multiple workers.
The right side is:
FAN-IN
because multiple streams become one stream.
Now Compare Everything
| Concept | Main Purpose | Simplest Mental Model | Main Syntax | Needs Goroutines? | Needs Channels? |
|---|---|---|---|---|---|
| Goroutine | Run work concurrently | Worker | go function() | It is the goroutine | No |
| Channel | Communicate between goroutines | Pipe | make(chan T) | Usually | Yes |
| Channel Direction | Restrict send/receive | One-way pipe | chan<- T, <-chan T | Usually | Yes |
select | Wait on multiple channel operations | Channel switchboard | select {} | Usually | Yes |
| Worker Pool | Limit number of concurrent workers | Team sharing jobs | Goroutines + channels | Yes | Usually |
| Fan-out / Fan-in | Split and merge concurrent work | Branch and merge | Goroutines + channels | Yes | Yes |
The Most Important Differences
Goroutine vs Channel
A goroutine:
does work
A channel:
moves information
Think:
Goroutine = Worker
Channel = Communication pipe
Channel vs Channel Direction
A normal channel:
chan int
can send and receive.
Send-only:
chan<- int
Receive-only:
<-chan int
So channel direction is not another type of concurrency.
It simply makes channel usage safer.
Channel vs select
A channel handles communication.
Channel = communicate
select handles multiple channel operations.
select = choose whichever channel is ready
Worker Pool vs Goroutine
A single goroutine means:
one concurrent worker
A worker pool means:
a controlled group of concurrent workers
For example:
1000 jobs
but only:
5 workers
Worker Pool vs Fan-out / Fan-in
These are similar, which is why beginners often confuse them.
Worker Pool
Main goal:
Control how many workers process jobs.
Example:
100 jobs
↓
3 workers
↓
results
Fan-out / Fan-in
Main goal:
Split processing across multiple paths and combine the outputs.
Example:
Worker 1
/ \
Input --------< >---- Combined Output
\ /
Worker 2
Quick Comparison
| Worker Pool | Fan-out / Fan-in |
|---|---|
| Focuses on worker count | Focuses on data flow |
| Usually one job queue | Usually pipeline-oriented |
| Workers share jobs | Work branches into processors |
| Controls concurrency | Parallelizes stages |
| Results may use one shared results channel | Results commonly need explicit merging |
They can also exist together.
A worker pool is effectively a common form of fan-out.
But you should initially think of them as different ideas.
How Everything Connects
You should learn these concepts in this exact order:
1. Goroutine
↓
2. Channel
↓
3. Channel Direction
↓
4. select
↓
5. Worker Pool
↓
6. Fan-out / Fan-in
Why?
Because each concept builds on the previous one.
Final Mental Model
Imagine a restaurant.
Goroutine
A chef.
Chef = Goroutine
Multiple chefs can work concurrently.
Channel
The order counter.
Waiter ---> Order Counter ---> Chef
The counter allows information to move between people.
Channel Direction
Maybe one counter is:
Orders IN only
while another is:
Food OUT only
That restriction is channel direction.
select
A chef watches several order counters:
Counter A
Counter B
Counter C
The chef takes whichever order becomes available first.
That is select.
Worker Pool
Instead of hiring one chef for every order:
100 orders
100 chefs
you hire:
100 orders
↓
5 chefs
Those five chefs keep taking orders.
That is a worker pool.
Fan-out
Orders are distributed between several chefs:
Chef 1
Orders ----> Chef 2
Chef 3
Fan-in
Finished food comes back to one serving counter:
Chef 1 ----\
Chef 2 -----> Serving Counter
Chef 3 ----/
The Six Concepts in One Sentence Each
Goroutine
Run a function concurrently.
go function()
Channel
Send information between goroutines.
ch <- value
value := <-ch
Channel Direction
Restrict a function to sending or receiving.
chan<- int
<-chan int
select
Wait for whichever channel operation becomes ready.
select {
case value := <-ch1:
case value := <-ch2:
}
Worker Pool
Let a fixed number of goroutines process many jobs.
Jobs → Workers → Results
Fan-out / Fan-in
Split work between multiple goroutines and merge their results.
Worker
/ \
Input -- -- Output
\ /
Worker
What You Should Master First
Do not try to master all six at the same time.
Start by making these three feel completely natural:
go function()
ch <- value
value := <-ch
Once those make sense, learn:
chan<- T
<-chan T
Then:
select
Only after that should you spend significant time on:
Worker Pools
Fan-out
Fan-in
That progression makes Go concurrency much easier to understand.
Final Cheat Sheet
| If you want to… | Use |
|---|---|
| Run something concurrently | Goroutine |
| Send data between goroutines | Channel |
| Make a function send-only | chan<- T |
| Make a function receive-only | <-chan T |
| Wait on several channels | select |
| Limit concurrent workers | Worker Pool |
| Split work across processors | Fan-out |
| Combine multiple result streams | Fan-in |
The most important relationship to remember is:
Goroutines do the work.
Channels connect the work.
select coordinates channels.
Worker pools control the workers.
Fan-out distributes the work.
Fan-in combines the results.