Go Concurrency: What Finally Made It Click for Me

I spent a good amount of time learning concurrency in Go.

At the beginning, I knew what a goroutine was. I knew how to make a channel. I knew that WaitGroup waits for goroutines and that Mutex protects shared data.

But I still felt that I was just using separate tools.

I did not have one clear picture in my head for how all of them work together.

The biggest example was context.

I used it before. I knew how to write:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

but I did not really understand what problem it was solving.

The same happened with worker pools, channel closing, graceful shutdown, and even the scheduler.

After building small examples one by one, making mistakes, fixing them, and connecting the ideas together, it finally started to make sense.

This post is the version I want to keep for myself.

Not a formal Go concurrency guide.

Just the mental models and examples that made it click for me.


The whole picture first

This is roughly how I think about concurrency now:

                        APPLICATION
                            │
                            ▼
                       Goroutines
                            │
              ┌─────────────┴─────────────┐
              │                           │
              ▼                           ▼
          Channels                  Shared Memory
              │                           │
              ▼                           ▼
    coordination / data flow        Mutex / Atomics
              │                           │
              └─────────────┬─────────────┘
                            ▼
                    Synchronization
                            │
                            ▼
                     Context controls
                    operation lifetime
                            │
                            ▼
               Graceful shutdown / cleanup

And under all of that:

Goroutines
   ↓
Go Scheduler
   ↓
G / M / P
   ↓
OS Threads
   ↓
CPU

That is the big picture.

Now I can look at a concurrent program and ask:

  • What work runs concurrently?
  • Who owns each channel?
  • Who closes it?
  • What stops the goroutines?
  • What happens if one part fails?
  • How do I know all goroutines are done?
  • What happens when the process shuts down?
  • Is shared memory synchronized?

Those questions are more useful than memorizing APIs.


1. Goroutines are not threads

At first, I was thinking about goroutines almost like threads.

Something like:

go worker()

felt like:

start another thread.

That is not the best mental model.

A goroutine is work managed by the Go runtime.

The runtime schedules many goroutines on a smaller number of OS threads.

So this:

for i := 0; i < 100_000; i++ {
	go worker(i)
}

does not mean:

100,000 goroutines
=
100,000 OS threads

It means I created a lot of concurrent work and the Go scheduler decides how to run it.

Another thing that mattered early:

func main() {
	go worker()
}

If main() returns, the process exits.

Go does not wait for all goroutines automatically.

That was one of the first important things to make clear.


Concurrency is not parallelism

I used to mix these two ideas together.

Now I think about them like this:

Concurrency:
multiple tasks can make progress during the same period

Parallelism:
multiple tasks are literally executing at the same time

You can have concurrency with only one execution slot.

The runtime can keep switching between goroutines.

Parallelism needs multiple execution resources.

We will come back to this when talking about GOMAXPROCS.


2. Channels started making sense when I stopped thinking of them as just pipes

A channel moves values between goroutines, but the important part is that it also creates synchronization.

The simplest example:

ch := make(chan int)

go func() {
	ch <- 10
}()

x := <-ch
fmt.Println(x)

With an unbuffered channel, the send and receive have to meet.

The sender cannot just put the value somewhere and continue.

That means channels are not only data transfer.

They are coordination.


Unbuffered channels

ch := make(chan int)

A send:

ch <- 10

waits until another goroutine receives.

And a receive:

x := <-ch

waits until someone sends.

This made it easier for me to understand why unbuffered channels naturally create synchronization.


Buffered channels

ch := make(chan int, 10)

Now the sender can continue while there is space in the buffer.

Conceptually:

Producer
   │
   ▼
[ buffered queue ]
   │
   ▼
Consumer

But the buffer is not infinite.

Once it becomes full:

Producer
   ↓
buffer full
   ↓
send blocks

That is where I really understood backpressure.

The consumer speed eventually controls how fast the producer can keep going.


3. Closing channels: ownership matters

One of the most useful rules I learned is:

The goroutine that owns producing values usually owns closing the channel.

If I create an output channel:

func generator() <-chan int {
	out := make(chan int)

	go func() {
		defer close(out)

		for i := 0; i < 10; i++ {
			out <- i
		}
	}()

	return out
}

the generator owns out, so it closes it.

The receiver normally should not close a channel it does not own.


Receiving from a closed channel

I originally had code that kept receiving after a channel was closed.

That taught me this:

v, ok := <-ch

When:

ok == true

I received a real value.

When:

ok == false

the channel is closed and drained.

The easier pattern is usually:

for v := range ch {
	fmt.Println(v)
}

The loop stops automatically when the channel is closed.


4. Pipelines made channels feel useful

A simple pipeline was one of the first examples that connected things together.

func generator() <-chan int {
	out := make(chan int)

	go func() {
		defer close(out)

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

	return out
}

func double(in <-chan int) <-chan int {
	out := make(chan int)

	go func() {
		defer close(out)

		for x := range in {
			out <- x * 2
		}
	}()

	return out
}

Then:

in := generator()
out := double(in)

for x := range out {
	fmt.Println(x)
}

The flow becomes:

generator
   ↓
 channel
   ↓
 double
   ↓
 channel
   ↓
 main

Every stage owns its output channel.

That is a very clean model.


5. Fan-out, fan-in, and the mistake that taught me channel ownership

With fan-out, multiple workers read from the same input channel.

              jobs
               │
      ┌────────┼────────┐
      ▼        ▼        ▼
   worker1  worker2  worker3

One thing that became very clear:

Multiple workers reading from one channel is distribution, not broadcast.

Each job normally goes to one worker.

Not all workers.


My shared result channel mistake

I had multiple workers writing to the same result channel.

At first, I tried closing the result channel inside a worker.

That is wrong because every worker is sharing it.

If one worker closes it while another still wants to send:

panic: send on closed channel

The fix is to wait for all workers first.

var wg sync.WaitGroup

for i := 0; i < 3; i++ {
	wg.Add(1)

	go func() {
		defer wg.Done()
		worker(jobs, results)
	}()
}

go func() {
	wg.Wait()
	close(results)
}()

That pattern became very important:

workers produce results
        ↓
WaitGroup waits for all producers
        ↓
one coordinator closes results

6. Worker pools, bounded concurrency, and backpressure

At one point I experimented with huge numbers of goroutines.

The important lesson was simple:

More goroutines does not automatically mean more speed.

If I have:

10,000,000 jobs

I usually do not need:

10,000,000 workers

A worker pool lets me control concurrency.

many jobs
   ↓
jobs channel
   ↓
fixed number of workers
   ↓
results

Example:

func worker(id int, jobs <-chan int, results chan<- int) {
	for job := range jobs {
		results <- job * job
	}
}

Then create a fixed number:

for i := 0; i < 10; i++ {
	go worker(i, jobs, results)
}

This protects limited resources.

For example:

  • CPU
  • database connections
  • external APIs
  • SMTP connections
  • memory

Worker pools are not only about performance.

They are also about control.


7. select became the control center

Before learning context properly, I already used patterns like:

select {
case job := <-jobs:
	// process job

case <-done:
	return
}

That turned out to be very close to how context works.

select lets a goroutine wait for multiple possible events.

For example:

select {
case job := <-jobs:
	process(job)

case <-ctx.Done():
	return
}

Now the goroutine can react to:

new work
OR
cancellation

That is the heart of a lot of concurrent Go code.


8. WaitGroup does not protect data

This was an important correction.

I knew that WaitGroup waits for goroutines.

But it is easy to accidentally think that waiting somehow makes shared data safe.

It does not.

This is unsafe:

var wg sync.WaitGroup
counter := 0

for i := 0; i < 1000; i++ {
	wg.Add(1)

	go func() {
		defer wg.Done()
		counter++
	}()
}

wg.Wait()
fmt.Println(counter)

The problem happens before Wait() returns.

Many goroutines are doing:

counter++

at the same time.

Roughly:

read
add
write

Two goroutines can read the same old value and overwrite each other.

WaitGroup answers:

Have all goroutines finished?

It does not answer:

Are they accessing shared memory safely?

For that I need a Mutex, atomic operation, channel ownership, or another synchronization strategy.


9. Mutex, RWMutex, Once, sync.Map, and Atomics

These started becoming much easier when I stopped treating them as unrelated APIs.

They are different tools for different kinds of shared state.


Mutex

mu.Lock()
counter++
mu.Unlock()

A mutex protects a critical section.

The important rule:

Keep the locked section as small as possible.

And if there are multiple return paths:

mu.Lock()
defer mu.Unlock()

is often safer.


RWMutex

RWMutex lets multiple readers hold the read lock together:

mu.RLock()
value := data[key]
mu.RUnlock()

but writers still need exclusive access:

mu.Lock()
data[key] = value
mu.Unlock()

I now think of it as something to consider for read-heavy shared state, not something I should automatically use instead of Mutex.


sync.Once

This one is simple:

var once sync.Once

once.Do(func() {
	fmt.Println("initializing...")
})

Even if many goroutines call it, the function runs once.

Good for one-time initialization.


sync.Map

I learned that sync.Map exists, but I should not replace every normal map with it.

My default is still:

map + Mutex/RWMutex

because it keeps types clear and behavior obvious.

sync.Map is useful for particular concurrent access patterns.


Atomics

For something simple like a counter:

var counter atomic.Int64

counter.Add(1)
fmt.Println(counter.Load())

atomics are great.

But if I have multiple related fields that must change together:

balance
status
updatedAt

I would rather use a mutex.


10. Race condition, data race, and deadlock are not the same thing

These words were easy to mix together.

Now I separate them.


Data race

Two goroutines access the same memory concurrently, at least one writes, and there is no proper synchronization.

Example:

counter++

from many goroutines.


Race condition

This is broader.

The correctness of the program depends on timing or ordering.

A race condition does not always have to be a low-level data race.


Deadlock

Nobody can make progress because goroutines are waiting forever.

Classic example:

ch := make(chan int)

ch <- 10

If main tries to send on an unbuffered channel and nobody can receive, it blocks forever.

Another example:

mu.Lock()
mu.Lock()

A normal Go Mutex is not reentrant.


11. Context was the biggest thing that finally clicked

This was probably the most important part of the whole journey.

I had used context before, but I was using syntax without really understanding the idea.

The mental model that finally worked for me was:

Context represents the lifetime of an operation.

And:

Context asks work to stop. It does not kill goroutines.

That changed everything.


WithCancel

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

Calling:

cancel()

does not magically terminate code.

It closes:

ctx.Done()

Then my goroutine has to notice.

func worker(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker stopped:", ctx.Err())
			return

		default:
			// work
		}
	}
}

Cancellation is cooperative.

That word matters.


12. The producer leak was where context became real

Imagine:

func producer(jobs chan<- int) {
	for i := 0; i < 100; i++ {
		jobs <- i
	}
}

And the consumer stops early.

Now the producer may become stuck forever on:

jobs <- i

because nobody is receiving anymore.

This is a goroutine leak.

The fix is not only to give the worker a context.

The producer also needs to understand cancellation.

func producer(ctx context.Context, jobs chan<- int) {
	defer close(jobs)

	for i := 0; i < 100; i++ {
		select {
		case jobs <- i:

		case <-ctx.Done():
			return
		}
	}
}

That was a big moment for me.

It showed that cancellation needs to travel through the whole operation.

Not only the top function.


13. Context must be propagated down

Suppose:

handler
   ↓
service
   ↓
repository
   ↓
database

If I only check context in the handler, but the repository blocks for 30 seconds, cancellation does not help much.

The context needs to move down.

func handler(ctx context.Context) {
	service(ctx)
}

func service(ctx context.Context) {
	repository(ctx)
}

func repository(ctx context.Context) {
	// use context-aware operation
}

For a fake blocking operation:

func process(ctx context.Context) error {
	select {
	case <-time.After(10 * time.Second):
		return nil

	case <-ctx.Done():
		return ctx.Err()
	}
}

Now cancellation can interrupt the wait.

That is the real idea.


14. Timeout and deadline

WithTimeout finally became simple when I thought of it as:

WithCancel, but Go automatically cancels after a maximum lifetime.

ctx, cancel := context.WithTimeout(
	context.Background(),
	2*time.Second,
)
defer cancel()

If the timeout wins:

ctx.Err()

returns:

context deadline exceeded

If I manually cancel first:

context canceled

The important part is that the timeout applies to the whole context lifetime.

It is not a fresh two seconds for every function.


Parent and child contexts

Cancellation flows downward.

parent
  │
  ├── child A
  │
  └── child B

Cancel the parent:

A canceled
B canceled

Cancel only A:

A canceled
parent still alive
B still alive

That makes nested operation lifetimes much easier to reason about.


15. context.WithValue is not a bag for random data

This was another useful rule.

Context values are good for request-scoped metadata.

For example:

request ID
authenticated user ID
trace information

They are not a replacement for normal function arguments.

I should not hide business data like:

email
price
database dependency
mailer dependency
configuration

inside context.

A typed key is also better than a plain string.

type contextKey string

const requestIDKey contextKey = "requestID"

16. r.Context() made HTTP context click

I had an important misunderstanding here.

The browser does not send a Go context.Context.

The browser sends an HTTP request.

Then Go's net/http package creates a request context representing the lifetime of that request.

So:

ctx := r.Context()

means:

give me the context representing this HTTP request's lifetime.

If the client disconnects, that context can be canceled.

Then I can pass it down:

HTTP request
    ↓
r.Context()
    ↓
handler
    ↓
service
    ↓
repository
    ↓
database

That was where context stopped feeling like an abstract Go feature.

It became part of request lifecycle.


17. Context with database calls

The same idea applies to database operations.

The context object itself is not sent to PostgreSQL.

The Go database library uses it to know when the caller no longer cares about the operation.

With database/sql:

db.QueryContext(ctx, query)
db.ExecContext(ctx, query)

With libraries like pgx, the context is usually passed directly to the method.

The important rule for me:

Do not create a new context.Background() inside the repository if the caller already gave me a context.

That would break the cancellation chain.


18. Goroutine leaks

After learning context, goroutine leaks became much easier to see.

A goroutine leak means:

I started a goroutine, but now it has no realistic way to finish.

Example:

func generate() <-chan int {
	ch := make(chan int)

	go func() {
		for i := 0; ; i++ {
			ch <- i
		}
	}()

	return ch
}

Then:

ch := generate()

for i := 0; i < 5; i++ {
	fmt.Println(<-ch)
}

Main stops receiving after five values.

The generator keeps trying:

ch <- i

and blocks forever.

A context fixes the lifecycle:

func generate(ctx context.Context) <-chan int {
	ch := make(chan int)

	go func() {
		defer close(ch)

		for i := 0; ; i++ {
			select {
			case ch <- i:

			case <-ctx.Done():
				return
			}
		}
	}()

	return ch
}

Now the caller has a way to say:

I am done with this stream.


19. errgroup connected cancellation and errors

errgroup was much easier after understanding context.

The pattern:

g, ctx := errgroup.WithContext(parent)

g.Go(func() error {
	return task1(ctx)
})

g.Go(func() error {
	return task2(ctx)
})

g.Go(func() error {
	return task3(ctx)
})

if err := g.Wait(); err != nil {
	fmt.Println(err)
}

The mental model:

start related concurrent tasks
        ↓
one fails
        ↓
derived context canceled
        ↓
other tasks can stop
        ↓
Wait waits for everyone
        ↓
return error

This is much cleaner than manually wiring error channels everywhere.


20. Graceful shutdown was the final piece of lifecycle

The simple definition I use now:

Stop accepting new work, finish the work already accepted, clean up resources, then exit.

A basic HTTP shutdown starts with OS signals.

ctx, stop := signal.NotifyContext(
	context.Background(),
	os.Interrupt,
	syscall.SIGTERM,
)
defer stop()

Then:

<-ctx.Done()

means:

shutdown has started.


HTTP server shutdown

shutdownCtx, cancel := context.WithTimeout(
	context.Background(),
	10*time.Second,
)
defer cancel()

if err := server.Shutdown(shutdownCtx); err != nil {
	server.Close()
}

Shutdown stops new HTTP work and waits for active handlers to finish gracefully.

One thing that mattered:

The shutdown context should normally come from a fresh Background().

Not from the signal context that is already canceled.

Cleanup still needs time to run.


21. Shutdown ordering matters

This was probably the most useful graceful shutdown lesson.

Imagine:

HTTP
 ↓
jobs channel
 ↓
workers
 ↓
database

A bad shutdown could be:

close database
↓
workers are still using database
↓
errors

Or:

close jobs channel
↓
HTTP handler still tries to send
↓
panic: send on closed channel

A better order is:

SIGTERM
   ↓
stop accepting new HTTP work
   ↓
wait for existing handlers
   ↓
close jobs channel
   ↓
workers drain remaining jobs
   ↓
wait for workers
   ↓
close database/resources
   ↓
exit

The rule I want to remember:

Producers stop first. Consumers drain. Dependencies close last.


Do not cancel workers too early

This was another subtle thing.

If workers have already accepted jobs, immediately canceling them on shutdown may throw away work.

Sometimes graceful shutdown should mean:

stop new work
↓
finish in-flight work
↓
then stop

not:

cancel everything immediately

That distinction is important for queues, email workers, background jobs, and message consumers.


22. Concurrent testing: do not test with sleep

A bad concurrent test:

go doWork()

time.Sleep(2 * time.Second)

if result != 10 {
	t.Fatal("wrong result")
}

The test is guessing.

Maybe two seconds is enough.

Maybe CI is slower.

A better test waits for an actual event.

done := make(chan int)

go func() {
	done <- doWork()
}()

result := <-done

Now the test waits for completion, not time.


Testing my pipeline

I wrote a test like:

func TestDouble(t *testing.T) {
	in := make(chan int)

	go func() {
		in <- 1
		in <- 2
		in <- 3
		close(in)
	}()

	out := double(in)

	var got []int

	for x := range out {
		got = append(got, x)
	}

	want := []int{2, 4, 6}

	if !reflect.DeepEqual(want, got) {
		t.Fatalf("got %v, want %v", got, want)
	}
}

No sleep.

The channel lifecycle itself tells the test when the work is done.


23. The race detector is a separate check

A normal test can pass while the program still contains a data race.

That is why:

go test -race ./...

is useful.

On my Windows setup I hit issues with the race detector runtime itself, so I learned another small lesson:

Tooling failure is not automatically a code race.

I can still write correct tests and run normal go test, then use a working environment such as Linux/WSL for race checks if needed.


24. The Go scheduler: G / M / P

This topic sounded complicated at first, but I only needed one practical mental model.

G = Goroutine
M = Machine / OS thread
P = Processor / runtime execution resource

Simplified:

many Gs
  ↓
scheduler
  ↓
Ps
  ↓
Ms
  ↓
CPU

A goroutine is work.

An M is an OS thread.

A P is the runtime resource an M needs to execute Go code.


Why blocking a goroutine does not waste the whole program

If one goroutine blocks:

x := <-ch

the runtime can park that goroutine and run another runnable goroutine.

G1 blocked
G2 runnable
G3 runnable

The scheduler picks useful work.

This is why Go can handle large numbers of goroutines efficiently.


25. GOMAXPROCS

On my machine:

CPUs:       16
GOMAXPROCS: 16

GOMAXPROCS mainly controls how many Ps are available for executing Go code.

It does not mean I can only have 16 goroutines.

I can still have thousands.

It means the runtime has around 16 execution resources for Go code at once.


The experiment that made concurrency vs parallelism obvious

I ran the same CPU-heavy work with four goroutines.

First:

runtime.GOMAXPROCS(1)

Result:

took: 319ms

Then:

runtime.GOMAXPROCS(16)

Result:

took: 81ms

The result of the calculation was identical.

Only the execution time changed.

With one P:

4 goroutines
↓
concurrent
↓
not executing Go code in parallel

With multiple Ps:

4 goroutines
↓
can execute in parallel

That experiment explained the difference much better than a definition.


26. Memory model and happens-before

I did not want to turn this into a formal memory-model study.

The practical question was enough:

When goroutine B reads something, what guarantees that it can see what goroutine A wrote?

If the answer is:

nothing

then I probably have a problem.


Unsafe

var x int

go func() {
	x = 10
}()

time.Sleep(time.Second)

fmt.Println(x)

The sleep does not create a synchronization guarantee.

Maybe the write happens quickly.

Maybe not.

Sleep is not synchronization.


Safe with a channel

var x int
done := make(chan struct{})

go func() {
	x = 10
	close(done)
}()

<-done

fmt.Println(x)

Now:

x = 10
  ↓
close(done)
  ↓
receive from done
  ↓
read x

The synchronization creates an ordering relationship.

That is the practical idea behind happens-before.


Mutex also creates ordering

mu.Lock()
x = 10
mu.Unlock()

Another goroutine:

mu.Lock()
fmt.Println(x)
mu.Unlock()

The mutex is not only stopping simultaneous access.

It also gives the required synchronization and memory visibility.


WaitGroup gives completion ordering, not protection

This is safe:

go func() {
	x = 10
	wg.Done()
}()

wg.Wait()
fmt.Println(x)

But this is still unsafe:

for i := 0; i < 100; i++ {
	go func() {
		x++
		wg.Done()
	}()
}

because the goroutines race with each other before Wait() finishes.

That was a very useful distinction.


27. The questions I now ask when writing concurrent Go

This is probably the part I will come back to the most.

When I write concurrent code, I want to ask:

1. Who starts this goroutine?

2. Who is responsible for stopping it?

3. Can it block forever?

4. Who owns this channel?

5. Who closes the channel?

6. Can multiple goroutines send to it?

7. Can multiple goroutines modify this memory?

8. If yes, what synchronizes that access?

9. What happens when one goroutine returns an error?

10. Does cancellation reach every blocking operation?

11. Can a producer keep running after consumers stop?

12. During shutdown, what stops first?

13. What must finish before I close dependencies?

14. Am I depending on timing or on real synchronization?

If I can answer those clearly, the program is already much easier to reason about.


The final mental model

This is the version I want to remember.

GOROUTINES
    │
    │ concurrent work
    ▼

CHANNELS
    │
    │ communication + synchronization
    ▼

WORKER POOLS
    │
    │ bounded concurrency
    ▼

MUTEX / ATOMICS
    │
    │ protect shared state
    ▼

CONTEXT
    │
    │ controls operation lifetime
    ▼

ERRGROUP
    │
    │ errors + cancellation for related work
    ▼

GRACEFUL SHUTDOWN
    │
    │ stop intake → drain → cleanup
    ▼

SCHEDULER
    │
    │ G / M / P runs the goroutines
    ▼

MEMORY MODEL
    │
    │ synchronization creates ordering
    ▼

CORRECT CONCURRENT PROGRAM

The biggest change for me was that concurrency stopped looking like a list of Go features.

It became one system.

Goroutines create concurrent work.

Channels coordinate it.

Worker pools control how much of it runs.

Mutexes and atomics protect shared state.

Context gives the work a lifetime.

errgroup helps related tasks fail together.

Graceful shutdown makes the whole application stop in the correct order.

And the scheduler runs all of that on the machine underneath.

That is the point where Go concurrency finally started to click for me.