Kinoko's TIL Log

atomic.Value & Goroutine Token Refresh

The Point

When multiple goroutines share a value that gets updated periodically (like an auth token), use atomic.Value for lock-free reads and writes – lighter than a mutex. Combined with time.Ticker for periodic refresh and ctx.Done() for goroutine lifecycle control, this forms a complete concurrent token management pattern.

Explanation

The problem

10 goroutines publish events concurrently, each needing an AUTHZ token. Another goroutine refreshes the token every 40s (TTL 60s, refreshing 20s early as buffer).

How do you let the refresher (writer) and 10 publishers (readers) safely share the same token without a data race?

atomic.Value: lock-free shared value

1var token atomic.Value  // declared, zero value is nil
2
3// Write (refresher goroutine)
4token.Store("new-token-string")
5
6// Read (publisher goroutines, can read concurrently, no lock needed)
7t := token.Load().(string)  // type assertion to get the actual type

atomic.Value’s Store/Load are atomic operations – no mutex needed. Multiple goroutines reading concurrently is completely safe, and an occasional Store will not give any reader a corrupted value.

Complete pattern

 1var token atomic.Value
 2
 3// Refresher goroutine: periodically refresh the token
 4go func() {
 5    ticker := time.NewTicker(40 * time.Second)
 6    defer ticker.Stop()  // remember to release ticker resources
 7
 8    for {
 9        select {
10        case <-ticker.C:
11            newToken, err := fetchToken(ctx)
12            if err == nil {
13                token.Store(newToken)
14            }
15        case <-ctx.Done():
16            return  // exit when context is cancelled
17        }
18    }
19}()
20
21// Publisher goroutines: 10 running concurrently
22for i := 0; i < 10; i++ {
23    go func() {
24        for {
25            select {
26            case event := <-eventCh:
27                t := token.Load().(string)  // read current token
28                publish(ctx, t, event)
29            case <-ctx.Done():
30                return  // exit when context is cancelled
31            }
32        }
33    }()
34}

time.Ticker: periodically firing channel

1ticker := time.NewTicker(40 * time.Second)
2// ticker.C is a channel that receives a signal every 40s
3// must call ticker.Stop() to release the underlying timer resources
4defer ticker.Stop()

ctx.Done(): unified exit signal

ctx.Done() returns a channel that gets closed when the context is cancelled. All goroutines listening on it in a select will receive the signal and exit simultaneously.

1select {
2case <-ticker.C:
3    // periodic refresh
4case <-ctx.Done():
5    return  // stop when told to, no goroutine leak
6}

Knowledge Sugar

sync.Mutex: a lock to protect critical sections

When multiple goroutines read and write the same data, a mutex ensures only one goroutine can enter at a time:

 1var mu sync.Mutex
 2var sharedData map[string]int
 3
 4// Write
 5mu.Lock()
 6sharedData["key"] = 42
 7mu.Unlock()
 8
 9// Read (also needs lock because map is not concurrent-safe)
10mu.Lock()
11v := sharedData["key"]
12mu.Unlock()
13
14// Idiomatic: defer unlock to avoid forgetting or leaking on panic
15mu.Lock()
16defer mu.Unlock()
17sharedData["key"] = 42

sync.RWMutex is the upgraded version – allows multiple goroutines to read simultaneously (RLock), but writing is exclusive (Lock):

 1var mu sync.RWMutex
 2
 3// Multiple goroutines can hold RLock simultaneously
 4mu.RLock()
 5v := sharedData["key"]
 6mu.RUnlock()
 7
 8// Write is exclusive
 9mu.Lock()
10sharedData["key"] = 99
11mu.Unlock()

Why atomic.Value instead of mutex?

sync.Mutexatomic.Value
Best forComplex critical sections (multi-step operations)Single value read/write
On readNeeds Lock/UnlockCompletely lock-free
10 goroutines reading frequentlyLock contentionNo contention, better performance

The token scenario: writes are rare (once every 40s), reads are frequent (10 goroutines on every publish) – atomic.Value is the better choice.

What makes atomic operations work?

“Atomic” means indivisible – the operation appears to others as either completed or not started, never “halfway done.”

A normal assignment token = newToken may compile to multiple machine instructions. On a multi-core CPU, another core might read in the middle and get a half-written value (data race).

Atomic operations use special CPU instructions (like x86’s CMPXCHG) to guarantee at the hardware level:

  1. Indivisibility: Store is fully written before it becomes visible to other cores
  2. Memory visibility: adds a memory barrier, ensuring all cores see the latest value instead of getting stuck on a CPU cache
Normal assignment: Core A writes halfway → Core B might read a corrupted value
Atomic:            Core A finishes writing before it is visible → Core B always sees a complete value

This is why atomic.Value is safe without a mutex – the guarantee is made at the CPU instruction level, not through software locks.

Why refresh every 40s with a 60s TTL?

The 20s buffer absorbs:

If you wait until 59s to refresh, any slight delay could cause publishers to send requests with an expired token.

Common cause of goroutine leaks

If a goroutine has no exit mechanism, it runs until the process ends. ctx.Done() is the standard exit signal – once the context is cancelled, all listening goroutines exit cleanly.

#go #concurrency #til

← Back to Main Page