Part V · Generic Code, Data Structures, and Agent Teams

20 A First Look at Concurrency

TeamOptional chapterLab: The shared ring bufferMemoryOwnership

What still holds

Two threads touching the same memory without coordination is undefined behavior, even when it seems to work.

The agent may: propose a locking scheme from your written plan, which you then audit.

Learning objectives

After this chapter you should be able to:

Do unaided:

  1. Draw the interleavings of two threads on one timeline and compute the final value each produces.
  2. Explain the difference between a data race and a race condition, and give a race condition with no data race.
  3. Write small programs with std::thread, join, std::mutex, std::lock_guard and std::atomic, stating for every lock what data it protects and for how long it is held.
  4. Read a ThreadSanitizer report: the two accesses, the two threads, the location and the locks held.
  5. Predict when a second thread will make a program slower, and check with a timing run.

May delegate:

  1. A locking scheme for the station's ring buffer, proposed by a Writer agent from your written locking plan, then audited with ThreadSanitizer, a Tester agent's stress test and your own review of every shared access.

Opening case

A composite case, not a real student or course.

Sam wants the field station to stop losing readings. The logger has one loop: read the sensors, then write to the SD card. When the card is slow, the next reading is late, and a late reading is a gap in the archive. Sam's plan is two threads: a sensor thread that pushes each reading into stn::RingBuffer, and a writer thread that pops readings and writes them out. Sam's RingBuffer, a fixed-size queue on a std::vector that wraps around like Chapter 11's ring buffer, was never meant to be shared, so Sam asks the Writer agent to make it thread-safe.

The chapter continues from here.

The loop in this chapter

Open a step to see what it asks of you and how it plays out in the Team phase.

  1. Predictthe counter's possible final values at 1,000 and 100,000 increments per thread, the interleavings behind them, and how many of 100 runs will be wrong.

    Step 1 of 6 · You, no AI

    What you hand in: A written prediction: the output, the memory state, the operation count, and how sure you are.

    Why this step: A prediction written before the run is the only way to learn whether your model of the machine is right. A miss is the most useful result there is, because it shows exactly where the model is wrong.

    The first prediction is never delegated.

    Ask yourself: What will this print, what will memory look like when it ends, and how many operations will it take?

  2. Writeby hand, the unsafe, mutex and atomic counters, and the fix to the agent's size() and empty().

    Step 2 of 6 · You, no AI

    What you hand in: One small piece written by hand, to build the mental model.

    Why this step: You can only check what you could have built. Writing a small piece yourself gives you the model you will need to judge the agent's version later.

    Ask yourself: Could I write this piece again tomorrow without notes?

  3. Specifythe locking plan: what is shared, who touches it, which lock guards it and for how long.

    Step 3 of 6 · You

    What you hand in: The header or class interface, unit tests, a complexity budget and the ownership rules.

    Why this step: The specification is the contract the code will be held to. Tests written first, a cost budget and ownership rules turn "looks right" into something you can check.

    The ownership and lifetime section of a specification is never delegated.

    Ask yourself: Who allocates, who frees, what does it cost, and what input would break it?

  4. Delegatethe Writer builds the thread-safe buffer from the plan, the Tester writes stress and late-consumer tests, the Reviewer tables every shared access.

    Step 4 of 6 · The agent

    What you hand in: The implementation from your specification, or an explanation in the Tutor phase, with the prompts and transcript saved.

    Why this step: The agent does the typing; you keep the judgment. Saving the prompts and the transcript makes the delegation something you can audit afterward.

    Team Team phase: several agent sessions work in separate roles (Writer, Reviewer, Tester). You direct them and make the final call.

    Ask yourself: Did I hand over my specification and tests, and did I save what the agent said?

  5. AuditTSan under stress, repeated runs under timeout, the Reviewer's table against the plan, and timing with and without the lock.

    Step 5 of 6 · You and the tools

    What you hand in: The Audit Report: tests, sanitizer and Valgrind output, your own cost derivation against a timing plot, the defects found, and a keep-or-rewrite decision.

    Why this step: What the agent says is testimony until an instrument confirms it. Tests, sanitizers, Valgrind and a timing plot are evidence.

    The final judgment that the code is correct is never delegated.

    Ask yourself: What did the sanitizer report, does the timing plot match my derivation, and would I keep this code or rewrite it?

  6. Defendevery lock's scope, every shared variable, and why a passing run is not evidence of no race.

    Step 6 of 6 · You, live

    What you hand in: A five-minute oral defense.

    Why this step: If you can explain it live, you own it. The oral checks that the understanding is yours and not the agent's.

    Ask yourself: Why this bound, what input breaks it, and where is this memory freed?

What agents get wrong here

Patterns observed in agent-written code, to test for, not guarantees. If the agent no longer makes the mistake, your job is to prove it got it right, with the same instrument.

Writes locked, reads not ("reads are harmless")

How to catch it TSan on a stress test, several runs, under load

Section 20.3.1 · ThreadSanitizer, Tests

A lock held across slow I/O

How to catch it Measure the worst wait for the lock; TSan cannot help

Section 20.3.2 · ThreadSanitizer

wait with no predicate, or if instead of while around it

How to catch it Review every wait; a forced-timing test under timeout

Section 20.3.3 · Tests, Timing and log-log plots

"It passed ten runs, so there is no race"

How to catch it TSan output from at least 100,000 operations per thread under load; a table of every shared variable and its guard

Section 20.3.4 · ThreadSanitizer

What the chapter covers

  1. 20.1 The concept, by hand
    • 20.1.1 Why threads
    • 20.1.2 Each thread has its own stack
    • 20.1.3 Starting and joining a thread
    • 20.1.4 Interleavings on a timeline
    • 20.1.5 Data race versus race condition
    • 20.1.6 Mutexes, lock_guard and lock scope
    • 20.1.7 std::atomic, briefly
    • 20.1.8 Producer and consumer: the condition variable
    • 20.1.9 Reading a ThreadSanitizer report
  2. 20.2 Predict and check
  3. 20.3 What agents get wrong here
    • 20.3.1 The write is locked, the read is not
    • 20.3.2 A lock held across slow I/O
    • 20.3.3 The lost wake-up
    • 20.3.4 "It passed ten runs"
  4. 20.4 Working with the agent
  5. 20.5 Lab: The shared ring buffer
  6. 20.6 Defend it

Cost thread

We counted the readings above 25.0 °C among 10 million temperatures (4,990,000 qualify) six ways, on the course container with g++ 13.3 at -O2 and no sanitizers. Each row of Table 20.3 is the median of 12 runs, each the best of 11 repetitions.

Summary

  • Each thread has its own stack and registers; globals, heap blocks and anything reached through a shared reference are shared. The first audit question is which data two threads can reach.
  • A data race is undefined behavior in C++; a race condition is a timing-dependent bug and can exist with every access locked.
  • Compilers optimize as if there were no data races: one spin loop printed the right value at -O0, hung at -O1 and printed an unwritten value at -O2.
  • A mutex protects only data touched while holding it. Lock every access, reads included, across every check-then-act, and release before anything slow.
  • A condition variable must wait for a condition, never for a notify; a notify reaches only threads already waiting.
  • Passing runs are weak evidence (595 of 600 correct for a racy counter). TSan reported that race in every run, but it cannot see race conditions, lost wake-ups or slow critical sections.

Key terms

  • thread: one sequence of execution in a program, with its own stack and registers, sharing globals and the heap.
  • join: waiting for a thread to finish, after which its writes are visible to the joining thread.
  • joinable: a std::thread not yet joined or detached; destroying it ends the program.
  • interleaving: one possible order of the steps of several threads.
  • lost update: a write overwritten by another thread's write based on an older value.
  • data race: two conflicting accesses (same location, at least one a write) from different threads, at least one not atomic, with nothing ordering them; undefined behavior in C++.
  • race condition: a bug whose result depends on the relative timing of threads.
  • memory model: the language rules for which writes a read may see; C++ promises interleaving behavior only to race-free programs.
  • mutex: an object at most one thread can hold at a time.
  • critical section: code run while holding a mutex, so one thread at a time.
  • lock scope: the data a lock protects and the time it is held.
  • atomic operation: an operation on one variable that no other thread can see half-done.
  • condition variable: an object that lets a thread sleep, its mutex released, until notified that a condition may have changed.
  • spurious wake-up: a return from wait with no notify.
  • lost wake-up: a notify sent when no thread is waiting, so a later waiter sleeps though the condition is true.
  • ThreadSanitizer (TSan): the -fsanitize=thread instrument, which reports data races that occur in a run.
  • locking plan: a table of shared data, the threads touching it, its guard and the longest operation under the lock.

Companion code

17 files for this chapter, compiled with the book's flags.

Read the code Download ch20.zip

Connections

Where this chapter and another point to each other most often.

See it on the map

Further reading

  • Boehm (2005), "Threads cannot be implemented as a library." The short paper behind Listing 20.4: why single-thread optimizations break threaded code unless the language defines threads.
  • Boehm and Adve (2008), "Foundations of the C++ concurrency memory model." Where the race-free bargain comes from; Sections 1 and 2 give the idea.
  • Stroustrup and Sutter (2024), C++ Core Guidelines, the CP section. Short numbered rules that make a good review checklist for agent-written code.
  • Serebryany and Iskhodzhanov (2009), "ThreadSanitizer: Data race detection in practice." How the tool decides two accesses are unordered, and why it sees only races in the run you give it.
  • Herlihy and Shavit (2012), The Art of Multiprocessor Programming. The next step: locks built from atomics, lock-free structures and weaker orderings.