Go Concurrency — Goroutines, Channels and Sync
Go was designed with concurrency as a first-class feature. Rather than relying on OS threads and shared-memory locking as the primary model, Go provides goroutines (lightweight threads managed by the Go runtime), channels (typed conduits for communication between goroutines), and a select statement for multiplexing channel operations. The standard library rounds this out with a context package for cancellation and deadlines, and a sync package for traditional mutual exclusion when channels are not the right fit. This tutorial walks through all five building blocks, starting from basic goroutines and working up to a concurrent cache protected by read-write locks and atomics.
Goroutines and WaitGroup
A goroutine is a function that runs concurrently with other goroutines in the same address space. You start one with the go keyword followed by a function call. Goroutines are multiplexed onto a small number of OS threads by the Go runtime scheduler, so spawning thousands of them is cheap — each one starts with a stack of only a few kilobytes.
Starting a Goroutine
The simplest way to launch a goroutine is with an anonymous function. Here, the main goroutine creates a channel, launches a background goroutine that sends a value, and then blocks on a receive:
1
2
3
4
5
6
7
8
func part1_basicGoroutine() {
done := make(chan bool)
go func() {
fmt.Println(" hello from a goroutine!")
done <- true
}()
<-done
}
The go func() { ... }() syntax launches the anonymous function as a new goroutine. The channel receive <-done blocks main until the goroutine sends a value, ensuring we see the printed message before the program exits.
The Broken Version — No Synchronization
If you launch goroutines and do not wait for them, the main function may exit before they finish. This is a common mistake:
1
2
3
4
5
6
7
8
9
func part2_brokenNoSync() {
for i := 0; i < 3; i++ {
go func(id int) {
fmt.Printf(" worker %d: processing (might not print!)\n", id)
}(i)
}
time.Sleep(10 * time.Millisecond)
fmt.Println(" main continued immediately — goroutines may or may not have finished")
}
The time.Sleep gives the goroutines a brief window to run, but there is no guarantee. On a fast machine they might finish; on a loaded machine they might not. Sleeping is never a correct synchronization mechanism.
WaitGroup
A sync.WaitGroup is the standard tool for waiting on a set of goroutines to complete. You call Add to register work, Done to signal completion, and Wait to block until all work is done:
1
2
3
4
5
6
7
8
9
10
11
12
13
func part3_waitGroup() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
time.Sleep(time.Duration(id*50) * time.Millisecond)
fmt.Printf(" worker %d: done\n", id)
}(i)
}
wg.Wait()
fmt.Println(" all workers finished!")
}
wg.Add(1) is called before launching each goroutine, not inside it — this avoids a race where Wait could return before all goroutines have registered. defer wg.Done() ensures the counter is decremented even if the goroutine panics. After wg.Wait() returns, all three goroutines have completed.
Closure Variable Capture
When launching goroutines in a loop, the loop variable must be passed explicitly as a function argument. Otherwise, all goroutines share the same variable and may all see the final value:
1
2
3
4
5
6
7
8
9
10
11
func part4_closureCapture() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf(" worker id=%d\n", id)
}(i)
}
wg.Wait()
}
The expression go func(id int) { ... }(i) copies the current value of i into the parameter id. Each goroutine gets its own copy. If you wrote go func() { fmt.Println(i) }() instead, all three goroutines would close over the same i variable, and you would likely see 3, 3, 3 instead of 0, 1, 2.
Concurrent Workers
A common pattern is to fan out work across multiple goroutines and collect results. Since each goroutine writes to a distinct index in the results slice, no lock is needed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func part5_concurrentWorkers() {
batches := [][]string{
{"INSERT users alice", "INSERT users bob"},
{"UPDATE orders ord-1", "DELETE orders ord-2"},
{"INSERT events evt-1", "INSERT events evt-2", "INSERT events evt-3"},
}
var wg sync.WaitGroup
results := make([]int, len(batches))
for i, batch := range batches {
wg.Add(1)
go func(id int, items []string) {
defer wg.Done()
for _, item := range items {
fmt.Printf(" worker %d: %s\n", id, item)
}
results[id] = len(items)
}(i, batch)
}
wg.Wait()
total := 0
for _, count := range results { total += count }
fmt.Printf(" all workers done. total items processed: %d\n", total)
}
Each goroutine receives its own id and items slice. The results slice is pre-allocated with one slot per worker, so concurrent writes to different indices are safe without synchronization. After wg.Wait(), the main goroutine aggregates the counts.
Channels
Channels are Go’s primary mechanism for communication between goroutines. A channel is a typed conduit — you send values into it and receive values out of it. Channels enforce synchronization: a send on an unbuffered channel blocks until another goroutine receives, and vice versa. This “communication by sharing” model is the foundation of Go’s concurrency philosophy.
Unbuffered Channels
An unbuffered channel has no internal storage. A send blocks until a receiver is ready, and a receive blocks until a sender is ready. This provides a synchronization point between two goroutines:
1
2
3
4
5
6
7
8
9
func part1_unbuffered() {
ch := make(chan string)
go func() {
ch <- "hello from goroutine"
}()
time.Sleep(50 * time.Millisecond)
msg := <-ch
fmt.Printf(" receiver: got %q\n", msg)
}
The goroutine’s ch <- "hello from goroutine" blocks until main executes msg := <-ch. The sleep in main demonstrates that the sender will patiently wait — there is no data loss.
Buffered Channels
A buffered channel has internal capacity. Sends do not block until the buffer is full, and receives do not block as long as the buffer is non-empty:
1
2
3
4
5
6
7
8
9
10
func part2_buffered() {
ch := make(chan int, 3)
ch <- 10
ch <- 20
ch <- 30
fmt.Printf(" sent 3 values without blocking (len=%d, cap=%d)\n", len(ch), cap(ch))
fmt.Printf(" received: %d\n", <-ch)
fmt.Printf(" received: %d\n", <-ch)
fmt.Printf(" received: %d\n", <-ch)
}
make(chan int, 3) creates a channel with a buffer of three. All three sends succeed without blocking because the buffer has room. The len function returns how many values are currently in the buffer, and cap returns the total capacity.
Directional Channels
Channel parameters can be restricted to send-only or receive-only. This makes the intended data flow explicit and catches mistakes at compile time:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func produce(out chan<- string) {
changes := []string{"INSERT alice", "UPDATE bob", "DELETE charlie"}
for _, c := range changes {
out <- c
}
close(out)
}
func consume(in <-chan string) {
for msg := range in {
fmt.Printf(" consumer received: %s\n", msg)
}
}
func part3_directional() {
ch := make(chan string, 5)
go produce(ch)
consume(ch)
}
The chan<- type means the function can only send to the channel. The <-chan type means the function can only receive. A bidirectional chan string is automatically convertible to either direction when passed as an argument.
Close and Range
Closing a channel signals that no more values will be sent. A for range loop over a channel receives values until the channel is closed:
1
2
3
4
5
6
7
8
9
10
func part4_closeAndRange() {
ch := make(chan int, 5)
for i := 1; i <= 5; i++ { ch <- i * 10 }
close(ch)
for val := range ch {
fmt.Printf(" got: %d\n", val)
}
val, ok := <-ch
fmt.Printf(" receive after close: val=%d, ok=%t <- zero value, ok=false\n", val, ok)
}
After close(ch), the for range loop drains all remaining values and then exits. A receive on a closed, empty channel returns the zero value immediately with ok=false. Only the sender should close a channel — closing a channel that has already been closed, or sending on a closed channel, will panic.
Producer-Consumer Pipeline
Combining goroutines, channels, and close gives you a clean producer-consumer pattern. The producer sends structured data, closes the channel when done, and the consumer processes everything until the channel is drained:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type TxBatch struct {
Table string
Changes []string
}
func part5_producerConsumer() {
ch := make(chan TxBatch, 5)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
batches := []TxBatch{
{Table: "users", Changes: []string{"INSERT alice", "INSERT bob"}},
{Table: "orders", Changes: []string{"INSERT ord-1"}},
{Table: "users", Changes: []string{"UPDATE alice"}},
{Table: "events", Changes: []string{"INSERT evt-1", "INSERT evt-2"}},
}
for _, b := range batches { ch <- b }
close(ch)
}()
wg.Add(1)
go func() {
defer wg.Done()
total := 0
for batch := range ch {
fmt.Printf(" consumer: processing %s batch (%d changes)\n", batch.Table, len(batch.Changes))
total += len(batch.Changes)
}
fmt.Printf(" consumer: done, processed %d total changes\n", total)
}()
wg.Wait()
}
The producer goroutine sends four batches and then closes the channel. The consumer goroutine ranges over the channel, processing each batch as it arrives. The WaitGroup ensures main waits for both goroutines to finish. This pattern scales naturally — you can add more consumers by launching additional goroutines that range over the same channel.
Select and Ticker
The select statement lets a goroutine wait on multiple channel operations simultaneously. It looks like a switch, but each case is a channel send or receive. When multiple cases are ready, Go picks one at random — this prevents starvation. Combined with tickers and timeouts, select is the building block for event loops, polling, and graceful shutdown.
Basic Select
A select with multiple cases executes whichever channel operation is ready. If multiple are ready, one is chosen at random:
1
2
3
4
5
6
7
8
9
10
11
func part1_basicSelect() {
ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
ch2 <- "hello from ch2"
select {
case msg := <-ch1:
fmt.Printf(" received from ch1: %s\n", msg)
case msg := <-ch2:
fmt.Printf(" received from ch2: %s\n", msg)
}
}
Only ch2 has a value ready, so the second case executes. If both channels had values, either case could run — the runtime makes a pseudo-random choice.
Ticker
A time.Ticker delivers ticks at regular intervals on its C channel. Combined with select, it creates a periodic loop that can also respond to other events:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func part2_ticker() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
done := time.After(350 * time.Millisecond)
count := 0
for {
select {
case t := <-ticker.C:
count++
fmt.Printf(" tick %d at %v\n", count, t.Format("15:04:05.000"))
case <-done:
fmt.Printf(" ticker stopped after %d ticks\n", count)
return
}
}
}
The ticker fires every 100ms, and the time.After channel fires once after 350ms to shut the loop down. Always call ticker.Stop() to release the ticker’s resources. Without the done channel, this loop would run forever.
Timeout with time.After
time.After returns a channel that receives a single value after the specified duration. Inside a select, it acts as a deadline for any other channel operation:
1
2
3
4
5
6
7
8
9
10
11
12
13
func part3_timeout() {
slowCh := make(chan string)
go func() {
time.Sleep(500 * time.Millisecond)
slowCh <- "slow result"
}()
select {
case result := <-slowCh:
fmt.Printf(" got result: %s\n", result)
case <-time.After(100 * time.Millisecond):
fmt.Println(" timed out! (100ms elapsed before result arrived)")
}
}
The goroutine takes 500ms but the timeout is 100ms, so the timeout case wins. This pattern is useful for one-off operations, but for anything more complex, the context package (covered below) provides better cancellation semantics.
Non-Blocking Select with Default
Adding a default case makes a select non-blocking. If no channel operation is immediately ready, the default case runs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func part4_nonBlocking() {
ch := make(chan string, 1)
select {
case msg := <-ch:
fmt.Printf(" received: %s\n", msg)
default:
fmt.Println(" no message ready (default executed)")
}
ch <- "buffered message"
select {
case msg := <-ch:
fmt.Printf(" received: %s\n", msg)
default:
fmt.Println(" no message ready")
}
}
The first select finds an empty channel, so default runs. After sending a value, the second select succeeds on the receive case. Non-blocking selects are useful for polling or try-send patterns where you want to make progress without waiting.
Event Loop Pattern
Combining select with multiple channels creates an event loop — a goroutine that reacts to different kinds of events as they arrive. This is the backbone of many server and pipeline designs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func part5_eventLoop() {
ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond)
defer cancel()
input := make(chan string, 10)
go func() {
changes := []string{"INSERT users alice", "UPDATE users bob", "DELETE sessions old"}
for _, c := range changes {
time.Sleep(80 * time.Millisecond)
input <- c
}
}()
ticker := time.NewTicker(150 * time.Millisecond)
defer ticker.Stop()
var buffer []string
for {
select {
case <-ctx.Done():
if len(buffer) > 0 {
fmt.Printf(" [shutdown] flushing %d remaining items\n", len(buffer))
}
return
case change := <-input:
buffer = append(buffer, change)
fmt.Printf(" [input] buffered: %s (buffer size: %d)\n", change, len(buffer))
case <-ticker.C:
if len(buffer) > 0 {
fmt.Printf(" [ticker] flushing %d items\n", len(buffer))
buffer = buffer[:0]
} else {
fmt.Println(" [ticker] nothing to flush")
}
}
}
}
This loop handles three kinds of events: incoming data (buffered for batch processing), periodic flushes (driven by the ticker), and shutdown (driven by the context timeout). Each iteration of the loop blocks in select until one of the three channels is ready. The context timeout ensures the loop eventually terminates, and the shutdown case flushes any remaining buffered items.
Context
The context package provides a standard way to carry deadlines, cancellation signals, and request-scoped values across API boundaries and between goroutines. Almost every Go program that does I/O or spawns goroutines should use contexts. The core idea is simple: a parent creates a context, passes it to child goroutines, and can cancel it at any time — all children see the cancellation immediately.
WithCancel
context.WithCancel returns a new context and a cancel function. Calling cancel closes the context’s Done channel, which unblocks any goroutine waiting on it:
1
2
3
4
5
6
7
8
9
10
func part1_withCancel() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
<-ctx.Done()
fmt.Printf(" goroutine: context cancelled (%v)\n", ctx.Err())
}()
time.Sleep(50 * time.Millisecond)
cancel()
time.Sleep(10 * time.Millisecond)
}
The goroutine blocks on <-ctx.Done() until the main goroutine calls cancel(). After cancellation, ctx.Err() returns context.Canceled. This is the simplest cancellation mechanism — the parent decides when to stop, and the children listen.
WithTimeout
context.WithTimeout creates a context that cancels itself automatically after a duration. If the work finishes before the deadline, the deferred cancel call releases resources early:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func part2_withTimeout() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
select {
case <-ctx.Done():
fmt.Printf(" context done: %v\n", ctx.Err())
}
ctx2, cancel2 := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel2()
result := make(chan string, 1)
go func() {
time.Sleep(50 * time.Millisecond)
result <- "computation complete"
}()
select {
case r := <-result:
fmt.Printf(" got result before timeout: %s\n", r)
case <-ctx2.Done():
fmt.Printf(" timed out: %v\n", ctx2.Err())
}
}
The first context times out after 100ms because nothing else happens — ctx.Err() returns context.DeadlineExceeded. The second context has a 200ms deadline but the goroutine completes in 50ms, so the result arrives in time. Always defer cancel() even with timeouts — it frees internal timers immediately instead of waiting for the deadline.
Cancellation Cascade
Contexts form a tree. When a parent context is cancelled, all of its children and grandchildren are cancelled too. This is how you propagate shutdown signals through a hierarchy of goroutines:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func part3_cascading() {
parent, parentCancel := context.WithCancel(context.Background())
child1, child1Cancel := context.WithCancel(parent)
defer child1Cancel()
child2, child2Cancel := context.WithCancel(parent)
defer child2Cancel()
grandchild, grandchildCancel := context.WithCancel(child1)
defer grandchildCancel()
parentCancel()
time.Sleep(10 * time.Millisecond)
fmt.Printf(" parent err: %v\n", parent.Err())
fmt.Printf(" child1 err: %v\n", child1.Err())
fmt.Printf(" child2 err: %v\n", child2.Err())
fmt.Printf(" grandchild err: %v\n", grandchild.Err())
}
After parentCancel(), all four contexts report context.Canceled. Cancelling child1Cancel() would only affect child1 and grandchild, leaving parent and child2 untouched. This tree structure mirrors the natural structure of request handling — an HTTP handler creates a context, passes it to a database call, which passes it to a retry loop, and cancelling the handler cancels everything downstream.
ctx.Err() — Cancellation vs Timeout
The Err() method on a context tells you why it was cancelled. There are exactly two possible non-nil values:
1
2
3
4
5
6
7
8
9
10
11
func part4_ctxErr() {
ctx1, cancel1 := context.WithCancel(context.Background())
cancel1()
fmt.Printf(" after cancel(): ctx.Err() = %v\n", ctx1.Err())
ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel2()
time.Sleep(10 * time.Millisecond)
fmt.Printf(" after timeout: ctx.Err() = %v\n", ctx2.Err())
fmt.Printf(" context.Canceled == ctx1.Err()? %t\n", ctx1.Err() == context.Canceled)
fmt.Printf(" DeadlineExceeded == ctx2.Err()? %t\n", ctx2.Err() == context.DeadlineExceeded)
}
context.Canceled means someone called the cancel function explicitly. context.DeadlineExceeded means the timeout or deadline elapsed. This distinction is important in practice — a cancellation usually means “the caller no longer cares about the result,” while a deadline exceeded usually means “the operation was too slow and should be retried or reported as an error.”
Per-Attempt Deadlines
A powerful pattern combines a long-lived parent context with short-lived per-attempt contexts. Each attempt gets its own timeout, but the parent context enforces an overall deadline:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func part5_perAttemptDeadline() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
for attempt := 1; ; attempt++ {
attemptCtx, attemptCancel := context.WithTimeout(ctx, 120*time.Millisecond)
result, err := simulateReceive(attemptCtx, attempt)
attemptCancel()
if err != nil {
if ctx.Err() != nil {
fmt.Printf(" attempt %d: parent context done, shutting down\n", attempt)
break
}
fmt.Printf(" attempt %d: timed out, retrying...\n", attempt)
continue
}
fmt.Printf(" attempt %d: received %q\n", attempt, result)
}
}
func simulateReceive(ctx context.Context, attempt int) (string, error) {
delay := time.Duration(attempt*80) * time.Millisecond
select {
case <-time.After(delay):
return fmt.Sprintf("WAL message #%d", attempt), nil
case <-ctx.Done():
return "", ctx.Err()
}
}
The parent context has a 500ms deadline. Each attempt gets 120ms. Early attempts complete quickly and succeed. As the simulated delay grows, individual attempts start timing out. Eventually the parent context expires, and the loop exits. The key check is ctx.Err() != nil — this distinguishes “this attempt timed out but we can retry” from “the overall operation is done.”
Sync Primitives and Atomics
Channels are the preferred communication mechanism in Go, but sometimes you need shared mutable state. The sync package provides mutual exclusion locks, and the sync/atomic package provides lock-free operations on individual values. Both are lower-level than channels and should be used when the overhead of a channel is too high or when the access pattern does not fit the message-passing model.
Mutex
A sync.Mutex provides mutual exclusion. Only one goroutine can hold the lock at a time. All others block on Lock() until the holder calls Unlock():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func part1_mutex() {
var mu sync.Mutex
counter := 0
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Printf(" counter after 100 goroutines: %d (expected 100)\n", counter)
}
Without the mutex, concurrent counter++ operations would race — counter++ is not atomic; it reads, increments, and writes, and two goroutines could read the same value and both write the same incremented value, losing an update. The mutex serializes access so every increment is visible.
RWMutex
A sync.RWMutex distinguishes between readers and writers. Multiple goroutines can hold a read lock simultaneously, but a write lock is exclusive — no readers or other writers can proceed while it is held. This is ideal for data structures that are read far more often than they are written:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type SchemaCache struct {
mu sync.RWMutex
schemas map[string][]string
ops atomic.Uint64
}
func NewSchemaCache() *SchemaCache {
return &SchemaCache{schemas: make(map[string][]string)}
}
func (c *SchemaCache) Get(table string) ([]string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
c.ops.Add(1)
cols, ok := c.schemas[table]
return cols, ok
}
func (c *SchemaCache) Set(table string, columns []string) {
c.mu.Lock()
defer c.mu.Unlock()
c.ops.Add(1)
c.schemas[table] = columns
}
Get uses RLock/RUnlock — multiple readers can call Get concurrently without blocking each other. Set uses Lock/Unlock — it waits for all readers to finish and then holds exclusive access while writing. This is significantly more efficient than a plain Mutex when reads vastly outnumber writes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func part2_rwMutex() {
cache := NewSchemaCache()
cache.Set("users", []string{"id", "name", "email"})
cache.Set("orders", []string{"id", "user_id", "total"})
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
if cols, ok := cache.Get("users"); ok {
fmt.Printf(" reader %d: got users schema (%d columns)\n", id, len(cols))
}
}(i)
}
wg.Add(1)
go func() {
defer wg.Done()
cache.Set("events", []string{"id", "type", "payload", "timestamp"})
}()
wg.Wait()
fmt.Printf(" total cache ops: %d\n", cache.TotalOps())
}
Ten reader goroutines and one writer goroutine run concurrently. The readers can all proceed in parallel, and the writer waits its turn. The atomic operation counter tracks total operations without needing the mutex.
Atomics
The sync/atomic package provides lock-free operations on integer and pointer types. Atomics are faster than a mutex for simple counters or flags because they use hardware-level atomic instructions instead of OS-level locking:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func part3_atomics() {
var lsn atomic.Uint64
lsn.Store(0)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := uint64(100); i <= 500; i += 100 {
lsn.Store(i)
time.Sleep(10 * time.Millisecond)
}
}()
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 5; i++ {
time.Sleep(15 * time.Millisecond)
fmt.Printf(" consumer: read LSN = %d\n", lsn.Load())
}
}()
wg.Wait()
fmt.Printf(" final LSN: %d\n", lsn.Load())
}
One goroutine writes increasing values with Store, and another reads them with Load. These operations are individually atomic — the reader will never see a partially written value. However, atomics only protect individual reads and writes; if you need to read-modify-write multiple fields together, use a mutex.
The Add method performs an atomic increment, which is the most common use case:
1
2
3
4
5
6
7
8
9
10
11
var counter atomic.Uint64
var wg2 sync.WaitGroup
for i := 0; i < 1000; i++ {
wg2.Add(1)
go func() {
defer wg2.Done()
counter.Add(1)
}()
}
wg2.Wait()
fmt.Printf(" atomic counter after 1000 increments: %d\n", counter.Load())
All 1000 goroutines increment the counter concurrently, and the final value is exactly 1000 — no lock needed.
Cache with Full Concurrency
Putting it all together, here is the SchemaCache under realistic concurrent load — one writer populating the cache while multiple readers query it simultaneously:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func part4_cacheWithConcurrency() {
cache := NewSchemaCache()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
tables := map[string][]string{
"users": {"id", "name", "email", "created_at"},
"orders": {"id", "user_id", "total", "status"},
"events": {"id", "type", "payload"},
"sessions": {"id", "user_id", "token", "expires_at"},
}
for table, cols := range tables {
cache.Set(table, cols)
time.Sleep(10 * time.Millisecond)
}
}()
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
tables := []string{"users", "orders", "events", "sessions", "missing"}
for _, t := range tables {
time.Sleep(8 * time.Millisecond)
if cols, ok := cache.Get(t); ok {
fmt.Printf(" reader %d: %s has %d columns\n", id, t, len(cols))
}
}
}(i)
}
wg.Wait()
fmt.Printf(" total ops: %d\n", cache.TotalOps())
}
The writer goroutine adds tables one at a time with a small delay between each. Five reader goroutines query for tables concurrently — some queries will find the table (if the writer has added it already), and some will miss (if the writer has not reached it yet or if the table does not exist). The RWMutex ensures that reads never see a partially written map entry, while the atomic.Uint64 counter tracks total operations without requiring the lock. This pattern — a read-heavy cache protected by an RWMutex with atomic counters for metrics — is a common building block in Go services.