---
title: 'Concurrency Primitives'
source: 'https://academia.sh/en/courses/go-concurrency/concurrency-primitives'
course: Concurrency
language: en
updated: '2026-09-14T10:59:31+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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.

```go
// 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(&timesRun, 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.Once` guarantees that the body given to `Do` runs 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/atomic` protects 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.
