Skip to content
academia.sh

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(&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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close