Go Tutorials: Concurrency Made Simple

This tutorial covers:

  1. Goroutines
  2. Channels
  3. Channel Direction
  4. select
  5. Worker Pools
  6. 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:

ConceptThink of it as
GoroutineA lightweight worker
ChannelA pipe between workers
Channel directionMaking a pipe send-only or receive-only
selectWaiting on multiple pipes
Worker poolA fixed number of workers sharing jobs
Fan-out / Fan-inSplit 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

UnbufferedBuffered
make(chan int)make(chan int, 3)
No storageHas storage
Sender and receiver synchronize directlySender may continue until buffer is full
Good for coordinationGood 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:

  1. work arrives
  2. multiple goroutines process it
  3. 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

ConceptMain PurposeSimplest Mental ModelMain SyntaxNeeds Goroutines?Needs Channels?
GoroutineRun work concurrentlyWorkergo function()It is the goroutineNo
ChannelCommunicate between goroutinesPipemake(chan T)UsuallyYes
Channel DirectionRestrict send/receiveOne-way pipechan<- T, <-chan TUsuallyYes
selectWait on multiple channel operationsChannel switchboardselect {}UsuallyYes
Worker PoolLimit number of concurrent workersTeam sharing jobsGoroutines + channelsYesUsually
Fan-out / Fan-inSplit and merge concurrent workBranch and mergeGoroutines + channelsYesYes

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 PoolFan-out / Fan-in
Focuses on worker countFocuses on data flow
Usually one job queueUsually pipeline-oriented
Workers share jobsWork branches into processors
Controls concurrencyParallelizes stages
Results may use one shared results channelResults 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 concurrentlyGoroutine
Send data between goroutinesChannel
Make a function send-onlychan<- T
Make a function receive-only<-chan T
Wait on several channelsselect
Limit concurrent workersWorker Pool
Split work across processorsFan-out
Combine multiple result streamsFan-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.

Related Posts

Go Tutorials: Go Methods

Go methods become much easier once you understand one idea: A method is simply a function attached to a type. And the important rule is: A receiver…

Read More

Go Tutorials: Modules, Packages, Subpackages, Submodules, and Workspaces

This tutorial gives you one complete mental model for: The most important idea is: And sometimes: These two designs are very different. 1. What is a Go…

Read More

Go Tutorials: Numeric Types Beginner Tutorial

Go has four main families of numeric types: The simplest way to remember them is: Type family Stores Example int Whole numbers, positive or negative -10, 0,…

Read More

Go Tutorials: Go Testing, Benchmarking, and Profiling

These three topics become much easier once you separate the questions they answer: Topic Main question Main Go tool Result Testing Does my code work correctly? go…

Read More

Go Tutorials: Generics

1. What are Generics? Generics let you write one piece of code that works with multiple types while keeping Go’s compile-time type safety. Suppose you want a…

Read More

Go Concurrency Management Made Simple

This tutorial covers: The goal is: One concept → one purpose → one complete runnable example. Every example is independent. Save any example as: and run: 0….

Read More