Lesson 13 / 14
Concurrency Primitives
Thirty goroutines call the same initialization, but the once contract guarantees the body runs exactly one time. Thirty goroutines, each with five hundred increments, increment the same counter once with atomic operations and once with a lock; both give 15000. The limiting measurement models, with an order forced through a channel, how two separate atomic variables (balance and transaction count) can each stay consistent on their own while producing an inconsistent pair when read together: the reading side sees balance=100, transactionCount=0.
Published
In this lesson
- Measures with a counter that the body of a once, called by thirty goroutines, runs exactly one time
- Increments the same counter separately with atomic operations and with a lock, and distinguishes why the two results come out equal
- Shows, by modeling an order forced through a channel, how two separate atomic variables can produce an inconsistent pair when read together
Contents
The previous lesson’s closing drew a boundary: across two lessons, the lock looked like the
only tool for protecting shared state, but it is not. This lesson builds the same protection
with two separate primitives — sync.Once, the once that guarantees a body runs exactly
one time no matter how many times it is called; and sync/atomic, the atomic operations
that make operations on a single variable safe without using a lock. Both look like they do
the same job as the lock, but both carry a narrower contract than the lock: the lock could
wrap any piece of code at all, while each of these two primitives covers only one narrow
situation.
The shared definition’s third claim still holds in this lesson: what stops a goroutine is not the name of the tool, but a step taken by another goroutine. In the once, this step is the first call finishing the body — waiting goroutines enter the waited-for-arrived class, just as with the lock. In an atomic operation there is no waiting at all: an atomic call returns without waiting for any goroutine to do anything, without ever blocking — a step that finishes on its own, dependent on no one. Both represent a single narrow form of the lock, but one still makes something wait, the other never does.
Once
A step that needs to run only once at a program’s start — setting up a connection pool,
reading a configuration — can be requested by more than one goroutine at the same time.
sync.Once reduces this request to a single call: the function given to the Do method
runs exactly one time over its entire lifetime, no matter how many goroutines call Do;
every later call returns immediately without doing anything. A sync.Once‘s zero value is
also ready to use, just like the lock: writing var once sync.Once is enough, no separate
setup is needed — because inside Once there is already a counter that tracks whether the
first call has happened, and its own lock protecting that counter. Once itself is a tool
built using the previous lesson’s primitive.
The result does not change even if Do calls belonging to the same once variable are
given different functions — only the first call’s function runs, and the functions
given to later calls are skipped without ever running. In this example this distinction does
not show, because all thirty goroutines give the same function, but the contract already
guarantees it: Once‘s memory is not “which function was given,” only a single-bit answer
to the question “was I called once already.” Which goroutine’s own Do call actually runs
the body is also not measured and cannot be measured — thirty goroutines call Do at the
same time, and which one “wins” depends on timing; but because all of them give the same
function, this uncertainty has no effect at all on the result, just as in the first lesson’s
unlocked setup.
// main.go — once, comparing an atomic counter with a locked counter, modeling two atomic variables
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
fmt.Println("-- once: N goroutines call it, the body runs once --")
var once sync.Once
var timesRun int64
var initializedValue int64
const goroutineCount = 30
var group sync.WaitGroup
group.Add(goroutineCount)
for i := 0; i < goroutineCount; i++ {
go func() {
defer group.Done()
once.Do(func() {
atomic.AddInt64(×Run, 1)
atomic.StoreInt64(&initializedValue, 42)
})
}()
}
group.Wait()
fmt.Printf("how many times the body ran: %d\n", timesRun)
fmt.Printf("initialized value: %d\n", initializedValue)
fmt.Println()
fmt.Println("-- does the atomic counter give the same result as the locked counter --")
const incrementCount = 500
var atomicCounter int64
var group2 sync.WaitGroup
group2.Add(goroutineCount)
for i := 0; i < goroutineCount; i++ {
go func() {
defer group2.Done()
for j := 0; j < incrementCount; j++ {
atomic.AddInt64(&atomicCounter, 1)
}
}()
}
group2.Wait()
var lock sync.Mutex
lockedCounter := 0
var group3 sync.WaitGroup
group3.Add(goroutineCount)
for i := 0; i < goroutineCount; i++ {
go func() {
defer group3.Done()
for j := 0; j < incrementCount; j++ {
lock.Lock()
lockedCounter++
lock.Unlock()
}
}()
}
group3.Wait()
fmt.Printf("atomic counter : %d\n", atomicCounter)
fmt.Printf("locked counter : %d\n", lockedCounter)
fmt.Printf("are they equal : %v\n", atomicCounter == int64(lockedCounter))
fmt.Println()
fmt.Println("-- limiting measurement: modeling the joint consistency of two atomic variables --")
var balance, transactionCount int64
step1 := make(chan struct{})
done := make(chan [2]int64)
go func() {
<-step1
readBalance := atomic.LoadInt64(&balance)
readTransactions := atomic.LoadInt64(&transactionCount)
done <- [2]int64{readBalance, readTransactions}
}()
atomic.AddInt64(&balance, 100)
step1 <- struct{}{}
interim := <-done
atomic.AddInt64(&transactionCount, 1)
fmt.Printf("when the writer finished balance=%d transactionCount=%d\n",
atomic.LoadInt64(&balance), atomic.LoadInt64(&transactionCount))
fmt.Printf("what the reader saw in between balance=%d transactionCount=%d (inconsistent pair)\n",
interim[0], interim[1])
}
-- once: N goroutines call it, the body runs once -- how many times the body ran: 1 initialized value: 42 -- does the atomic counter give the same result as the locked counter -- atomic counter : 15000 locked counter : 15000 are they equal : true -- limiting measurement: modeling the joint consistency of two atomic variables -- when the writer finished balance=100 transactionCount=1 what the reader saw in between balance=100 transactionCount=0 (inconsistent pair)
SS9. Thirty goroutines make the same once.Do(...) call at nearly the same time, but
timesRun is only 1. sync.Once‘s contract works differently here than the wait group’s
did: the wait group relied on calls that raised and lowered a counter from the outside,
while Once decides on its own, internally, that once it has run once it will never run
again. Only one of the thirty calls actually runs the body; the remaining twenty-nine wait
for the moment Do returns — immediately if the body already ran, or until the body
finishes if it is still running. This is another mechanism of the waited-for-arrived class:
the waiting goroutines do not stop on their own; the first call finishing the body sets them
free.
initializedValue’s output shows the second half of this guarantee: it is not enough for
the body to run once; the value the body writes also has to be visible to every
goroutine. Only one of the thirty goroutines writes 42 to initializedValue, but every
goroutine returning from Do — whether its own call ran the body or not — reads the same
42. This is a consequence of sync.Once’s own internal synchronization: returning from
Do carries not only the information “the body ran somewhere,” but every value the body
wrote along with it.
SS10. The second measurement produces the same total through two separate paths:
atomicCounter is protected with atomic.AddInt64 and touches no lock at all;
lockedCounter is protected with a Lock/Unlock pair, as in the previous lesson. Thirty
goroutines, each with five hundred increments, apply fifteen thousand increments in total,
and both counters reach exactly 15000. This equality differs from the first lesson’s
locked/unlocked comparison: there, the unlocked setup never touched a single shared memory
location at all, while here atomicCounter genuinely is a single shared memory location and
thirty goroutines genuinely write to it at the same time. An atomic operation excludes the
shared-state race not by not running it, but by making it safe — through the indivisible
read-modify-write operation the hardware provides, with no lock required.
The atomic operation concept closed in M01/K05 Operating System Concepts applies here
without change: an operation being indivisible means no other goroutine can step in between.
Go’s sync/atomic package provides this indivisibility, backed by hardware, for a specific
set of integer and pointer types; the AddInt64 call itself is never left half-done, and no
goroutine can ever step in between its read and its write.
This guarantee comes at a cost: what sync/atomic protects is only a specific handful of
types — int32, int64, uint32, uint64, pointers — and every call operates on only
one of these types. The lock defined no such boundary — a struct, a slice, a map, or an
arbitrary-length piece of code updating more than one variable could all sit between a
Lock/Unlock pair. The atomic operation’s narrow contract is exactly the price of giving
up part of that flexibility: protecting a single integer needs no lock at all, but the
moment what needs protecting goes beyond a single integer, the atomic operation falls short.
Alongside AddInt64 and LoadInt64, used in this lesson, sync/atomic also carries a
compare-and-swap call: it writes the new value if the given variable equals the expected
value, and reports this without doing anything otherwise — another indivisible operation no
goroutine can step into between the read and the write. This lesson does not run that call;
what matters here is that sync/atomic offers every operation on a single integer (adding,
reading, writing, conditional replacement) with the same indivisibility guarantee, but what
it offers always stays limited to one variable.
Limiting Measurement: Holding Two Atomic Variables Together
SS11. The third setup uses two variables — balance and transactionCount — instead of
one, and both are read and written atomically on their own. But there is no joint protection
between them: the writing side updates balance first, then transactionCount; the moment
left between these two updates can show an outside-looking goroutine an inconsistent
pair. This gap is modeled with an order forced through a channel: the reading goroutine
waits for a signal on the step1 channel, main sets balance to 100 and sends the signal
first, and the reading goroutine reads both balance and transactionCount at exactly this
moment — balance=100 but transactionCount is still 0. Only after this does main
increment transactionCount.
This is a boundary in the same family as the lock scope model from the previous lesson, but
it sits in a different place: there, the problem was the lock’s scope being drawn
incorrectly; here, the two variables have no shared scope at all — each is atomic on its
own, but not the two together. balance carries a defined value the moment it is read, and
transactionCount also carries a defined value the moment it is read; the reading result is
not wrong, only that a write by another goroutine can slip in between the two reads, and
this can turn two individually correct values into a false story when taken together. An
atomic operation exists to protect a single variable; when more than one variable needs to
stay consistent together, an atomic operation is not enough, and a lock takes that
protection back over.
This boundary itself is not a design flaw, but a generalization of the previous lesson’s
lock scope example. There, the problem was a single counter’s read-compute-write triple
spilling outside the lock; here, two separate counters are not both under a single
scope. Both ask the same question: where does the operation that needs protecting begin and
end? Because balance and transactionCount are atomic separately, this question cannot be
noticed by looking at the source — both variables look correct on their own line, and the
fact that the relationship between them is not protected only shows up once a piece of code
trying to read the two variables together is written and run.
Lock, Once, and Atomic Operations
The three tools measured across three lessons give three separate narrow answers to the
same question. The lock answers “which piece of code should run under exactly one goroutine
at a time?” and draws this scope itself, through the Lock/Unlock pair — if the scope is
drawn wrong (the first lesson’s limiting measurement), the lock does not notice. Once
answers “has this body already run?” and remembers this answer forever; a second Do call
never runs the body again, because the question was already answered once. An atomic
operation answers “how many goroutines can touch this single variable at the same time?” and
guarantees the answer at the hardware level, but only asks the question for one
variable.
All three protect shared state, but none of the three’s scope stands in for the others: it is not possible to keep protecting a counter continuously with a once, nor to make more than one step look like a single operation with an atomic operation. Which primitive gets chosen in a codebase reveals the nature of what needs protecting — whether it is an event, a single value, or an arbitrary piece of code. This is the shared conclusion of these three lessons: there is no single correct way to protect shared state, only three separate narrow contracts that vary with the shape of what needs protecting.
These three narrow contracts can also nest inside one another. sync.Once itself was built
with a lock; a type like the previous lesson’s ProtectedCounter can carry its own lock
while also carrying a sync.Once field, initializing a field only on first access. An
atomic operation does not replace the lock either — it leaves the narrowest situations,
the ones a lock could stand in for, without a lock. All three are one view of the same
measurement axis: how a goroutine accesses shared state is measured by how much of that
state it holds under protection, and for how long.
Summary
sync.Onceguarantees that the body given toDoruns exactly one time no matter how many goroutines call it; only one of thirty calls ran the body.- Waiting goroutines stay blocked until
Once‘s body finishes — a mechanism of the waited-for-arrived class distinct from the lock’s and the wait group’s. sync/atomicprotects operations on a single variable with the hardware’s indivisible read-modify-write support; thirty goroutines applied fifteen thousand increments, and the atomic counter gave the same result (15000) as the locked counter.- Two separate atomic variables, even though each stays consistent on its own, can produce an inconsistent pair when read together; this was modeled with an order forced through a channel, not run.
- An atomic operation protects a single variable; when more than one variable needs to stay consistent together, lock scope is still the tool required.
- The lock, wait group, once, and atomic operation measured across three lessons are four separate views of the same measurement axis: each is defined by how much of the shared state it holds under protection, and for how long.
Next Step
Across three lessons, shared state was protected with a lock, a wait group, and now once and atomic operations — but every measurement knew from the start that the protection existed, because we wrote the source. None of these three lessons ran an actual race condition: the first lesson excluded it with separate memory and a forced model, this lesson excluded it with a forced model as well. In a real codebase, who says whether protection was forgotten, without reading the source? The course’s final lesson asks this question of the race condition detector that ships with the language’s own distribution: an unprotected share, a locked share, and a transfer through a channel are all run through the same detector, the detector’s answer (found or not found) is read, and the detector’s own boundary — its inability to see a race in a path that never runs — emerges as the course’s final measurement.
To keep your progress and take notes, Log in
My notes
Log in to take notes.