Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 18

Identifying Data Races and Outcomes in Concurrent C Traces

Recommended resources

Hello. This module turns concurrent-code symptoms into precise diagnoses: first identifying races, then choosing synchronization, reasoning about ordering, and debugging more complex failures. Today the goal is narrower and foundational: given a C execution trace, determine whether it contains a data race, explain the plausible symptom, and distinguish that symptom from what the C language actually guarantees.

In a storage path, this matters when a completion handler, timeout worker, and request-submission thread can all touch the same command state. A log showing an unexpected value is useful evidence, but the stronger question is: which accesses conflict, and what orders them?


From an unexpected result to a data race

Start with a visual account of a lost update. The example uses a shared sum rather than a storage command, so the individual memory accesses are easy to see.

Multithreading Code - Computerphile

Watch “Multithreading Code” from Computerphile for its C example and its breakdown of a shared update into separate operations.

Watch the race demonstration. Begin where the presenter discusses unpredictable thread scheduling, follow the two-thread summation example, and stop after the load/add/store explanation, before the discussion of a locking remedy. Focus on what each thread reads before either writes.

The key observation is that counter++ is not one indivisible action on an ordinary C int. Conceptually, a thread reads the value, calculates a new one, and writes it back. Two threads can each base their calculation on the same old value. If both read zero and both write one, an update is lost.

That describes a plausible machine-level symptom. To decide whether the program has a C data race, we need a more precise test. Read the standard-based definition and its example:

CON43-C. Do not allow data races in multithreaded code | CERT Secure Coding

Read CERT Secure Coding’s rule to separate the formal definition of a data race from the lost-update symptom.

In the opening section, read the quoted C Standard definition from the formal test. Then, under “Noncompliant Code Example,” read the explanatory paragraph beginning “Assume this simplified code is part of a multithreaded bank system” through its lost-update example, immediately before “Non-compliant code.” Notice why declaring the shared variable volatile does not fix the problem.

Apply the definition as a three-part test:

  1. Find two accesses to the same memory location in different threads. Two reads alone do not conflict; a read and a write, or two writes, do.
  2. Check whether at least one conflicting access is non-atomic. An ordinary int access is non-atomic for this purpose; volatile int is not a substitute for an atomic object.
  3. Look for a happens-before relationship between those accesses. This is an ordering established by the program’s synchronization rules—not merely a guess about which instruction ran first according to a clock.

If all three conditions hold, the execution has a data race, and C specifies undefined behavior. “The result might be one instead of two” is therefore an illustration, not a complete list of allowed outputs. Once a C data race exists, you cannot use a simple interleaving diagram to promise that only particular values are possible.



Read a trace without overclaiming

Consider two worker threads, A and B, each executing counter++ once on a shared, initially zero, ordinary int. This table is a conceptual load-and-store trace, not a guarantee about how a compiler must implement the C expression.

Event Thread A Thread B Shared value
Initial state 0
1 Reads 0 0
2 Reads 0 0
3 Writes 1 1
4 Writes 1 1

The table explains how a lost update could arise. To diagnose the data race, identify the actual conflict: A’s write and B’s read or write touch counter, at least one access is non-atomic, and there is no synchronization ordering the workers’ accesses.

Now imagine instead that A’s write happens before B’s read in elapsed time, and B calculates two. If the program still establishes no happens-before relationship between the workers, observing that apparently orderly run does not make the shared accesses safe. Wall-clock order and happens-before are different claims.

Suppose the main thread calls pthread_join() on both workers and then prints counter. Successful joins order each worker’s earlier actions before that later print in main. They do not, by themselves, order A’s access to counter relative to B’s. This distinction is especially important when an incident trace includes thread-start and thread-exit events: those events alone do not show that the conflicting accesses were protected.

The same test applies to a command structure. If a completion thread writes an ordinary cmd->status while a timeout thread reads it without shared synchronization, the read/write pair is the candidate conflict. A trace in which the timeout thread “usually runs later” does not supply the missing ordering.



Recognize the conflict in a sanitizer report

ThreadSanitizer (TSan) can point to conflicting accesses while you test a user-space C program. First, check its short build guidance:

ThreadSanitizer — Clang 22.0.0git documentation

Read Clang’s ThreadSanitizer documentation for the compilation flags needed to obtain useful race reports.

Under “Usage,” read from the build guidance. Focus on the sanitizer flag and the debug-information flag; you do not need to build Clang from the documentation’s earlier “How to build” section.

Here is a sample TSan report. It comes from a C++ program, but its read/write evidence illustrates the same race-identification test you are applying to C.

ThreadSanitizer reports a write by thread T2 and a previous read by thread T1 at the same global variable, `x`; the source locations identify the two accesses.

Read the report from the conflicting accesses outward. It names a write of size 4 by T2 in set_x(int) and a previous read of size 4 by T1 in print_x(). It then identifies their location as the global x. The thread-creation stacks help locate where those workers came from; they are not evidence that the read and write were ordered. Likewise, the displayed x is 0 does not establish that the code is safe.

A practical reading order for a report is: locate the two access stacks, confirm they refer to the same object, classify each as a read or write, and then inspect the program for synchronization that could order them. TSan’s warning is evidence of a defect to investigate—not a substitute for explaining the conflicting pair.

A short user-space investigation

On your Linux VM, save this as race.c. Assume the thread-creation and join calls succeed; production code should check their return values.

#include <pthread.h>
#include <stdio.h>

static int counter;

static void *worker(void *unused)
{
    (void)unused;

    for (int i = 0; i < 10000; ++i)
        counter++;

    return NULL;
}

int main(void)
{
    pthread_t a, b;

    pthread_create(&a, NULL, worker, NULL);
    pthread_create(&b, NULL, worker, NULL);

    pthread_join(a, NULL);
    pthread_join(b, NULL);

    printf("counter=%d\n", counter);
    return 0;
}

Compile and run it with a compiler that supports ThreadSanitizer:

cc -std=c11 -O1 -g -pthread -fsanitize=thread race.c -o race
./race

Allow about 10–12 minutes to build it, inspect any report, and relate the reported source lines to the three-part test. With proper synchronization, the expected count is 20,000. In this version, whether a particular run prints 20,000 or a smaller number is not the criterion for safety: the workers’ ordinary accesses to counter conflict without ordering. TSan may report that race even on a run whose printed count looks right. Conversely, a run without a warning does not prove a concurrent program race-free.

For a direct comparison, declare a mutex beside counter and protect the increment in both workers:

static pthread_mutex_t guard = PTHREAD_MUTEX_INITIALIZER;

/* Inside the loop in worker(): */
pthread_mutex_lock(&guard);
counter++;
pthread_mutex_unlock(&guard);

Keep the joins before the print. The mutex protects each increment; the joins make the completed workers’ result available to the later print. This is a comparison of racy and ordered access, not yet a general recommendation to lock every increment in a performance-sensitive path. We will compare synchronization choices in the next lesson.



Key takeaways

A surprising value can prompt an investigation, but a data race is identified by a conflicting cross-thread access, at least one non-atomic access, and no happens-before ordering. Load-and-store traces explain symptoms such as lost updates; because a C data race is undefined behavior, those traces do not bound all possible program outcomes. ThreadSanitizer helps locate the accesses, while your job is to explain why they conflict and whether synchronization orders them.

Next, you will choose among mutexes, reader-writer locks, spinlocks, semaphores, and atomic operations for different access patterns—including the constraints that matter in systems code.