Go Tutorials – Go Concurrency Beginner Tutorial

Go concurrency becomes much easier when you separate these ideas:

Concurrency
    ↓
Goroutines = workers doing work concurrently
    ↓
Channels = communication path between goroutines
    ↓
Messages = values sent through channels
    ↓
WaitGroup = waits until goroutines finish

A useful mental model is a restaurant kitchen:

Go conceptSimple analogy
ConcurrencySeveral orders being handled at the same time
GoroutineOne cook
WorkerA cook assigned to process jobs
ChannelCounter/conveyor where jobs are placed
MessageThe actual order placed on the counter
WaitGroupManager waiting for all cooks to finish

1. Concurrency

What is concurrency?

Concurrency means:

Multiple tasks can make progress during the same period of time.

In Go, concurrency is mainly implemented using goroutines.

Suppose you have three jobs:

Job 1 → 2 seconds
Job 2 → 2 seconds
Job 3 → 2 seconds

Without concurrency:

Job1 → Job2 → Job3

2 sec + 2 sec + 2 sec = ~6 seconds

With concurrency:

Job1 ─────┐
Job2 ─────┼── running together
Job3 ─────┘

≈ 2 seconds

Why?

Concurrency is useful when your program spends time waiting for things such as:

  • APIs
  • databases
  • files
  • network requests
  • timers
  • user requests

When?

Use concurrency when multiple independent operations can happen at the same time.

How?

Most commonly:

go someFunction()

Where?

Very common in:

Web Servers
APIs
Microservices
Network applications
Background processing
Worker systems
File processing

Complete Example — Concurrency

Save as:

main.go
package main

import (
	"fmt"
	"time"
)

func task(name string) {

	fmt.Println(name, "started")

	// Simulate some work
	time.Sleep(2 * time.Second)

	fmt.Println(name, "finished")
}

func main() {

	// Start three tasks concurrently.
	go task("Task 1")
	go task("Task 2")
	go task("Task 3")

	// Keep main alive long enough for this simple demo.
	time.Sleep(3 * time.Second)

	fmt.Println("Program finished")
}

Run:

go run main.go

Possible output:

Task 3 started
Task 1 started
Task 2 started
Task 2 finished
Task 1 finished
Task 3 finished
Program finished

Notice that the order is not guaranteed.

That is normal in concurrent programming.


Important

This:

task("Task 1")

means:

Run the function normally and wait for it to finish.

This:

go task("Task 1")

means:

Start task() as a goroutine and continue without waiting for it.


2. Goroutine — Concurrent Worker

A goroutine is a lightweight concurrent function managed by Go.

You can think:

function + go keyword = goroutine

For example:

worker()

Normal function call.

But:

go worker()

starts it concurrently.


What?

A goroutine is a function running independently/concurrently with other goroutines.

Why?

It lets multiple pieces of work progress without forcing one to completely finish before another starts.

When?

Use it when tasks are independent.

Examples:

Call API A
Call API B
Call API C

Instead of waiting:

A → B → C

they could run:

A ──────────
B ──────────
C ──────────

How?

Simply add:

go

before a function call.

Where?

Common places include:

  • HTTP servers
  • API calls
  • database operations
  • background jobs
  • queues
  • worker pools

Complete Example — Goroutine Worker

package main

import (
	"fmt"
	"time"
)

// worker represents some work we want done.
func worker(id int) {

	fmt.Println("Worker", id, "started")

	time.Sleep(2 * time.Second)

	fmt.Println("Worker", id, "finished")
}

func main() {

	// Each worker runs as its own goroutine.
	go worker(1)
	go worker(2)
	go worker(3)

	// Only for this basic example.
	time.Sleep(3 * time.Second)

	fmt.Println("All done")
}

The important lines are:

go worker(1)
go worker(2)
go worker(3)

They create three goroutines.

Conceptually:

flowchart TD
    A["main()"] --> B["go worker(1)"]
    A --> C["go worker(2)"]
    A --> D["go worker(3)"]

    B --> E["Worker 1"]
    C --> F["Worker 2"]
    D --> G["Worker 3"]

Function vs Goroutine

Normal functionGoroutine
worker()go worker()
Caller waitsCaller continues
SequentialConcurrent
One completes before next statementWork can overlap
Simple executionConcurrent execution

For example:

worker(1)
worker(2)
worker(3)

means:

Worker 1
   ↓
Worker 2
   ↓
Worker 3

But:

go worker(1)
go worker(2)
go worker(3)

means approximately:

Worker 1 ──────────
Worker 2 ──────────
Worker 3 ──────────

3. Worker + Channel + Message

This is one of the most important Go concurrency patterns.

There are three pieces:

Worker
Channel
Message

Think about it like this:

Message = work that needs to be done

Channel = path used to send the work

Worker = goroutine receiving and processing the work

For example:

Jobs
  ↓
Channel
  ↓
Worker

What is a worker?

A worker is usually a goroutine that waits for jobs.

Example:

func worker(jobs chan string)

What is a channel?

A channel lets goroutines safely communicate.

Create one:

jobs := make(chan string)

Send data:

jobs <- "Job 1"

Receive data:

job := <-jobs

The arrow tells you the direction the value travels.


What is a message?

A message is simply the data flowing through the channel.

It could be:

string
int
struct
error

For example:

jobs <- "Process customer 1001"

Here:

Channel = jobs
Message = "Process customer 1001"

Complete Example — Worker + Channel + Message

package main

import (
	"fmt"
	"time"
)

// Worker receives messages from the jobs channel.
func worker(jobs chan string) {

	// Keep receiving messages until the channel is closed.
	for job := range jobs {

		fmt.Println("Worker received:", job)

		time.Sleep(1 * time.Second)

		fmt.Println("Worker completed:", job)
	}
}

func main() {

	// Create a channel that carries strings.
	jobs := make(chan string)

	// Start worker as a goroutine.
	go worker(jobs)

	// Send messages into the channel.
	jobs <- "Job 1"
	jobs <- "Job 2"
	jobs <- "Job 3"

	// No more jobs will be sent.
	close(jobs)

	// Only for this basic example.
	time.Sleep(4 * time.Second)
}

The important part:

jobs := make(chan string)

creates the channel.

Then:

jobs <- "Job 1"

sends a message.

And:

for job := range jobs

receives messages.


Visualization

flowchart LR
    A["main()"] -->|"Job 1"| C["jobs channel"]
    A -->|"Job 2"| C
    A -->|"Job 3"| C

    C --> W["Worker Goroutine"]

    W --> R["Process Job"]

Think:

main
 │
 │ sends jobs
 ▼
+-------------+
|   channel   |
+-------------+
      │
      │ receives
      ▼
+-------------+
|   worker    |
+-------------+

Channel Syntax Cheat Sheet

OperationCode
Create channelmake(chan string)
Sendch <- value
Receivevalue := <-ch
Closeclose(ch)
Read until closedfor value := range ch

The easiest way to remember:

value goes toward the arrow

Send:

jobs <- "hello"
jobs ← "hello"

Receive:

message := <-jobs
message ← jobs

4. Without Goroutine vs With Goroutine — Performance

This is probably the best experiment for understanding why goroutines exist.

We will execute 5 tasks.

Every task takes:

1 second

Sequential execution should therefore take roughly:

5 × 1 second
=
5 seconds

Concurrent execution should take roughly:

1 second

because the simulated waiting overlaps.


Complete Example — Performance Comparison

Copy-paste this entire program:

package main

import (
	"fmt"
	"sync"
	"time"
)

func work(id int) {

	fmt.Println("Working on job", id)

	// Simulate slow work such as API/database/network calls.
	time.Sleep(1 * time.Second)
}

func main() {

	// --------------------------------
	// WITHOUT GOROUTINES
	// --------------------------------

	fmt.Println("WITHOUT GOROUTINES")

	start := time.Now()

	for i := 1; i <= 5; i++ {
		work(i)
	}

	fmt.Println(
		"Sequential time:",
		time.Since(start),
	)

	// --------------------------------
	// WITH GOROUTINES
	// --------------------------------

	fmt.Println("\nWITH GOROUTINES")

	start = time.Now()

	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {

		wg.Add(1)

		go func(id int) {

			defer wg.Done()

			work(id)

		}(i)
	}

	// Wait until all goroutines finish.
	wg.Wait()

	fmt.Println(
		"Concurrent time:",
		time.Since(start),
	)
}

Typical output:

WITHOUT GOROUTINES

Working on job 1
Working on job 2
Working on job 3
Working on job 4
Working on job 5

Sequential time: 5.00s


WITH GOROUTINES

Working on job 5
Working on job 2
Working on job 1
Working on job 4
Working on job 3

Concurrent time: 1.00s

Why?

Sequential:

flowchart LR
    A["Job 1<br/>1 sec"] --> B["Job 2<br/>1 sec"]
    B --> C["Job 3<br/>1 sec"]
    C --> D["Job 4<br/>1 sec"]
    D --> E["Job 5<br/>1 sec"]

Total:

1 + 1 + 1 + 1 + 1
≈ 5 seconds

Concurrent:

flowchart LR
    A["main()"] --> B["Job 1"]
    A --> C["Job 2"]
    A --> D["Job 3"]
    A --> E["Job 4"]
    A --> F["Job 5"]

All five wait concurrently:

Job 1 ───────── 1 sec
Job 2 ───────── 1 sec
Job 3 ───────── 1 sec
Job 4 ───────── 1 sec
Job 5 ───────── 1 sec

Total ≈ 1 second

Very Important: Goroutine Doesn’t Automatically Mean Faster

Our example uses:

time.Sleep()

to simulate operations such as:

API call
Database request
Network request
Disk operation

These are excellent candidates for concurrency because much of the time is spent waiting.

But:

goroutine = faster

is not always true.

For tiny CPU operations, creating lots of goroutines can actually add overhead.

So think:

Use concurrency when work can meaningfully overlap, not merely because goroutines exist.


5. WaitGroup

You probably noticed a problem with our earlier examples.

We did this:

time.Sleep(3 * time.Second)

That is not a good way for a real program to wait for workers.

How do we know that 3 seconds is enough?

Maybe the worker needs:

1 second
5 seconds
20 seconds

We shouldn’t guess.

That is why Go provides:

sync.WaitGroup

What?

A WaitGroup waits for a group of goroutines to finish.


Why?

Because main() does not automatically wait for other goroutines.

Consider:

func main() {

	go worker()

	fmt.Println("Done")
}

main() might finish before worker() completes.

When main() ends:

program ends

and remaining goroutines disappear.


When?

Use WaitGroup when:

You start multiple goroutines and need to wait for all of them to complete.


How?

Three main operations:

wg.Add(1)
wg.Done()
wg.Wait()

Think:

Add()  = one worker started
Done() = one worker finished
Wait() = wait until counter becomes zero

Complete Example — WaitGroup

package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, wg *sync.WaitGroup) {

	// Tell WaitGroup this worker is finished
	// when this function returns.
	defer wg.Done()

	fmt.Println("Worker", id, "started")

	time.Sleep(2 * time.Second)

	fmt.Println("Worker", id, "finished")
}

func main() {

	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {

		// One more goroutine will start.
		wg.Add(1)

		go worker(i, &wg)
	}

	fmt.Println("Waiting for workers...")

	// Block here until all workers call Done().
	wg.Wait()

	fmt.Println("All workers completed")
}

Possible output:

Waiting for workers...
Worker 3 started
Worker 1 started
Worker 2 started
Worker 2 finished
Worker 1 finished
Worker 3 finished
All workers completed

Understand WaitGroup Counter

Initially:

counter = 0

Then:

wg.Add(1)
wg.Add(1)
wg.Add(1)

Counter becomes:

3

Worker 1:

wg.Done()
3 → 2

Worker 2:

2 → 1

Worker 3:

1 → 0

Then:

wg.Wait()

can continue.


flowchart TD
    A["WaitGroup = 0"] --> B["Add(1)<br/>counter = 1"]
    B --> C["Add(1)<br/>counter = 2"]
    C --> D["Add(1)<br/>counter = 3"]

    D --> E["Worker 1 Done<br/>counter = 2"]
    E --> F["Worker 2 Done<br/>counter = 1"]
    F --> G["Worker 3 Done<br/>counter = 0"]

    G --> H["Wait() released"]

Why defer wg.Done()?

You will very commonly see:

defer wg.Done()

instead of:

wg.Done()

at the bottom.

defer means:

Run this when the function is about to return.

So:

func worker() {

	defer wg.Done()

	// work...
}

means:

worker starts
    ↓
do work
    ↓
function finishes
    ↓
wg.Done()

It makes it much harder to accidentally forget to mark the worker finished.


Putting Everything Together

Now let’s connect every concept.

Imagine this program:

10 image-processing jobs

We create:

3 workers

The architecture could be:

flowchart LR
    M["main()"]

    M -->|"Job 1"| C["jobs channel"]
    M -->|"Job 2"| C
    M -->|"Job 3"| C
    M -->|"..."| C

    C --> W1["Worker 1<br/>goroutine"]
    C --> W2["Worker 2<br/>goroutine"]
    C --> W3["Worker 3<br/>goroutine"]

    W1 --> WG["WaitGroup"]
    W2 --> WG
    W3 --> WG

    WG --> END["Program finishes"]

This is the core idea behind many Go systems.


Complete Worker Pool Example

This section combines everything into one practical example.

package main

import (
	"fmt"
	"sync"
	"time"
)

// worker receives jobs from the jobs channel.
func worker(
	id int,
	jobs <-chan int,
	wg *sync.WaitGroup,
) {

	defer wg.Done()

	// Continue until jobs channel is closed.
	for job := range jobs {

		fmt.Println(
			"Worker", id,
			"processing job", job,
		)

		time.Sleep(1 * time.Second)
	}

	fmt.Println("Worker", id, "finished")
}

func main() {

	// Channel containing jobs/messages.
	jobs := make(chan int)

	var wg sync.WaitGroup

	// Start 3 workers.
	for i := 1; i <= 3; i++ {

		wg.Add(1)

		go worker(i, jobs, &wg)
	}

	// Send 6 jobs.
	for job := 1; job <= 6; job++ {
		jobs <- job
	}

	// Tell workers there are no more jobs.
	close(jobs)

	// Wait for workers to finish.
	wg.Wait()

	fmt.Println("All jobs completed")
}

Possible output:

Worker 3 processing job 1
Worker 1 processing job 2
Worker 2 processing job 3

Worker 2 processing job 4
Worker 3 processing job 5
Worker 1 processing job 6

Worker 1 finished
Worker 2 finished
Worker 3 finished

All jobs completed

This example is extremely important because it represents a real worker pool.


What Exactly Happened?

First:

jobs := make(chan int)

creates the job channel.

Then:

go worker(1, jobs, &wg)
go worker(2, jobs, &wg)
go worker(3, jobs, &wg)

conceptually creates three workers.

They are all waiting here:

for job := range jobs

Then main() sends:

jobs <- 1
jobs <- 2
jobs <- 3

Workers receive them.

Conceptually:

                    jobs channel

Job 1 ────────────→ Worker 1
Job 2 ────────────→ Worker 2
Job 3 ────────────→ Worker 3
Job 4 ────────────→ whichever worker becomes free
Job 5 ────────────→ whichever worker becomes free
Job 6 ────────────→ whichever worker becomes free

Finally:

close(jobs)

means:

No more jobs are coming.

And:

wg.Wait()

means:

Don’t finish main() until every worker finishes.


Worker vs Goroutine

These terms are related but are not exactly the same thing.

A goroutine is a Go language/runtime mechanism.

A worker is a programming design concept.

For example:

go worker()

Here:

worker()
    =
function containing worker logic

go worker()
    =
worker running inside a goroutine

So:

A worker is usually implemented using a goroutine.

But every goroutine is not necessarily called a worker.

Example:

go logSomething()

That’s a goroutine, but you probably wouldn’t describe it as a worker pool worker.


Worker vs Channel vs Message vs WaitGroup

This is the comparison worth remembering.

ConceptWhat is it?Main purposeExample
ConcurrencyOverall programming modelMultiple tasks progress togetherMany API calls
GoroutineLightweight concurrent executionRun a function concurrentlygo worker()
WorkerFunction/goroutine processing jobsPerform workworker(1)
ChannelCommunication pipeTransfer data between goroutinesjobs := make(chan int)
MessageData sent through channelRepresent job/event/datajobs <- 10
WaitGroupSynchronization mechanismWait for goroutineswg.Wait()

Exact Relationship

flowchart TD

    C["Concurrency"]

    C --> G["Goroutines"]

    G --> W1["Worker 1"]
    G --> W2["Worker 2"]
    G --> W3["Worker 3"]

    CH["Channel"] -->|"Messages / Jobs"| W1
    CH -->|"Messages / Jobs"| W2
    CH -->|"Messages / Jobs"| W3

    W1 --> WG["WaitGroup"]
    W2 --> WG
    W3 --> WG

    WG --> M["main() continues"]

The Five Things You Should Remember

If everything else feels confusing, remember these five lines:

// 1. Start something concurrently
go worker()
// 2. Create communication pipe
jobs := make(chan int)
// 3. Send message
jobs <- 100
// 4. Receive message
job := <-jobs
// 5. Wait for goroutines
wg.Wait()

That is most of the foundation.


One Mental Model for Everything

Think about an Amazon warehouse.

Orders
   ↓
+-------------------+
|   Conveyor Belt   |  ← Channel
+-------------------+
   ↓      ↓      ↓
Worker  Worker  Worker
   1       2       3
   ↓       ↓       ↓
Process Process Process
 Orders  Orders  Orders
   ↓       ↓       ↓
      WaitGroup
          ↓
     All finished

Mapping:

WarehouseGo
Warehouse operationConcurrency
EmployeeWorker
Employee working independentlyGoroutine
Conveyor beltChannel
Package/orderMessage
Supervisor waiting for everyoneWaitGroup

If this picture is clear, Go concurrency becomes much easier.


Sequential vs Concurrent vs Worker Pool

Finally, distinguish these three architectures.

Sequential

Job 1 → Job 2 → Job 3 → Job 4

Code:

worker(1)
worker(2)
worker(3)
worker(4)

One after another.

Goroutine per job

Job 1 ─────────
Job 2 ─────────
Job 3 ─────────
Job 4 ─────────

Code:

go worker(1)
go worker(2)
go worker(3)
go worker(4)

Potentially many concurrent goroutines.

Worker pool

                    ┌── Worker 1
Jobs → Channel ─────┼── Worker 2
                    └── Worker 3

Even if there are:

10,000 jobs

you might deliberately have only:

10 workers

This lets you control concurrency.


Final Comparison

QuestionSequentialGoroutinesWorker Pool
Concurrent?
Uses go?
Uses channel?Usually noNot requiredUsually yes
Number of workers controlled?1Often one/job✅ Yes
Good for few independent jobs?Sometimes✅ ExcellentPossible
Good for thousands of jobs?SlowCan create too much concurrency✅ Excellent
ComplexityLowestLowMedium

The learning order I recommend is:

1. Normal function
       ↓
2. Goroutine
       ↓
3. WaitGroup
       ↓
4. Channel
       ↓
5. Worker + Channel
       ↓
6. Worker Pool

The single most important sentence is:

A goroutine performs concurrent work, a channel communicates between goroutines, a message is the data traveling through that channel, and a WaitGroup waits for goroutines to finish.

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