Learning Golang - Concurrency, Context, and Safe Parallelism
Episode 12 of 19

Learning Golang - Concurrency, Context, and Safe Parallelism

This episode explores concurrency as Go's identity: goroutines, channels, and select, then context.Context for cancellation and deadlines, plus synchronization with sync.Mutex, WaitGroup, Once, and atomic operations.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

This is Go's most distinctive identity: concurrency. In episode 1 you already saw goroutines in passing. Now we go deeper into managing many tasks that run at the same time, safely, without race conditions and goroutine leaks.

Episode 12 covers three layers: goroutines, channels, and select as the concurrency primitives; context.Context for cancellation, deadlines, and request-scoped values; and the synchronization primitives sync.Mutex, WaitGroup, Once, and atomic operations.

Goroutines and Channels

Goroutines

A goroutine is a function that runs concurrently, started with the go keyword. Its execution schedule is managed by the runtime, and it only costs a few kilobytes of dynamically growing stack. Race conditions in this code can be detected with go test -race.

Running a goroutine
package main
 
import (
	"fmt"
	"time"
)
 
func kerja(nama string) {
	for i := 1; i <= 3; i++ {
		fmt.Println(nama, i)
		time.Sleep(10 * time.Millisecond)
	}
}
 
func main() {
	go kerja("pertama")
	go kerja("kedua")
	time.Sleep(100 * time.Millisecond)
}

Notice the time.Sleep in main to wait for the goroutines. This is a bad example for production — the correct way uses WaitGroup, which we'll discuss shortly.

Channels for Communication

A channel is a communication pipe between goroutines. Send with ch <- nilai, receive with nilai := <-ch. A channel carries data while synchronizing at the same time: a send waits until there is a receiver.

Basic channel
package main
 
import "fmt"
 
func main() {
	ch := make(chan string)
 
	go func() {
		ch <- "hasil perhitungan"
	}()
 
	pesan := <-ch
	fmt.Println(pesan)
}

A buffered channel like make(chan int, 10) lets several values queue up without waiting for a receiver. A channel that is neither closed nor used can cause a deadlock — get into the habit of closing the channel on the sender side with close.

select for Multiple Channels

select waits for operations on many channels at once and runs whichever is ready. It's the foundation for timeouts, cancellation, and fan-in:

select with a timeout
select {
case hasil := <-hasilChan:
	fmt.Println("hasil:", hasil)
case <-time.After(1 * time.Second):
	fmt.Println("timeout, batalkan")
}

context.Context

context.Context carries time limits and cancellation across the entire call stack. When a request is cancelled or a deadline is exceeded, all goroutines receiving the context stop working together.

Context with a timeout
package main
 
import (
	"context"
	"fmt"
	"time"
)
 
func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
 
	select {
	case <-time.After(3 * time.Second):
		fmt.Println("selesai tanpa masalah")
	case <-ctx.Done():
		fmt.Println("dibatalkan:", ctx.Err())
	}
}

ctx.Done() closes a channel when the context is cancelled. context.WithCancel and context.WithValue are two other variants commonly used in HTTP servers. Values carried by WithValue are for metadata such as request IDs, not business data.

Synchronization with the sync Package

WaitGroup for Waiting

WaitGroup waits for a group of goroutines to finish. Add increments the counter, Done decrements it, and Wait blocks until the counter reaches zero.

WaitGroup
package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	var wg sync.WaitGroup
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			fmt.Println("proses", n)
		}(i)
	}
	wg.Wait()
	fmt.Println("semua selesai")
}

Don't call Add after Wait has started. defer wg.Done() ensures the counter is always decremented even if a goroutine panics.

Mutex for Shared Data

When many goroutines write to the same data, a race condition lurks. sync.Mutex locks access so that only one goroutine proceeds at a time.

Mutex protecting a counter
type Counter struct {
	mu sync.Mutex
	n  int
}
 
func (c *Counter) Tambah() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

Once and Atomic

sync.Once runs a function exactly once, ideal for singleton initialization invoked from many goroutines. Meanwhile, atomic operations in sync/atomic allow numeric updates without a mutex, extremely fast for simple counters:

Atomic counter
var nilai atomic.Int64
 
func main() {
	nilai.Add(1)
	fmt.Println(nilai.Load())
}

atomic.Int64, generic since Go 1.19, provides Add, Load, Store, and Swap methods with atomic guarantees. These primitives combine into the common worker pool pattern: limit the number of active goroutines by reading tasks from a shared channel, while a result channel holds the output of all workers.

Closing

Episode 12 unlocked the power of Go concurrency: goroutines as lightweight execution units, channels as a means of communication, select for multiplexing many channels, context.Context for cancellation and deadlines, plus sync.Mutex, WaitGroup, Once, and atomics for safe synchronization.

Key takeaways:

  • Goroutines are cheap and easy to start with go.
  • Channels synchronize while also sending data.
  • select picks the ready channel and supports timeouts.
  • context.Context propagates cancellation and deadlines.
  • WaitGroup for waiting on a group of goroutines.
  • Mutex and atomics prevent race conditions on shared data.

In the next episode we will discuss Go performance and optimization — profiling with pprof, benchmarking with go test -bench, memory and allocation optimization with escape analysis, plus performance tuning techniques such as buffer reuse and zero allocation patterns. Your applications will run faster and use fewer resources.

Learning Golang - Concurrency, Context, and Safe Parallelism | Learning Golang