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 typeatomic.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"] = 42sync.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.Mutex | atomic.Value | |
|---|---|---|
| Best for | Complex critical sections (multi-step operations) | Single value read/write |
| On read | Needs Lock/Unlock | Completely lock-free |
| 10 goroutines reading frequently | Lock contention | No 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:
- Indivisibility: Store is fully written before it becomes visible to other cores
- 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 valueThis 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:
- Network latency when fetching the new token
- The time gap before all goroutines read the new token
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.