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.

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.
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.
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.
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.
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 waits for operations on many channels at once and runs whichever is ready. It's the foundation for timeouts, cancellation, and fan-in:
select {
case hasil := <-hasilChan:
fmt.Println("hasil:", hasil)
case <-time.After(1 * time.Second):
fmt.Println("timeout, batalkan")
}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.
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.
WaitGroup waits for a group of goroutines to finish. Add increments the counter, Done decrements it, and Wait blocks until the counter reaches zero.
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.
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.
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Tambah() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}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:
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.
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:
go.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.