Understanding Data Races in the C Memory Model

In the C programming language, a data race occurs when multiple threads concurrently access the same memory location, at least one access is a write, the operations are non-atomic, and no synchronization establishes an order between them. Introduced formally in the C11 standard (ISO/IEC 9899:2011), the C memory model defines data races strictly to provide a foundation for multithreaded execution. This article examines the precise criteria that constitute a data race in C, explains the underlying concepts of memory locations and happens-before relationships, and outlines the consequence of such races: undefined behavior.

The Formal Definition of a Data Race

According to Section 5.1.2.4 of the C standard, the execution of a program contains a data race if it contains two conflicting actions in different threads, of which at least one is not atomic, and neither happens before the other.

Two memory operations are defined as conflicting if both access the same memory location and at least one of those accesses modifies (writes to) the stored value.

For an execution to be classified as a data race, all of the following conditions must be met simultaneously:

  1. Shared Memory Location: Two or more operations target the exact same memory location.
  2. At Least One Write: At least one of the operations modifies the data. Two or more concurrent reads do not conflict and therefore never cause a data race.
  3. Different Threads: The conflicting accesses originate from distinct threads of execution.
  4. Lack of Atomicity: At least one of the accesses involves a standard, non-atomic object.
  5. Absence of a "Happens-Before" Relation: There is no synchronization mechanism establishing a deterministic execution order between the conflicting accesses.

Memory Locations in C

To evaluate data races, the standard strictly defines what constitutes a "memory location":

  • A scalar type (such as an integer, float, or pointer) represents an individual memory location.
  • In a structure, adjacent bit-fields of non-zero width constitute a single, contiguous memory location. A zero-width bit-field separates adjacent bit-fields into distinct memory locations.
  • Distinct members of a struct or distinct elements of an array (excluding adjacent bit-fields) are considered separate memory locations.

Consequently, two threads modifying distinct members of a struct simultaneously do not race unless those members are adjacent bit-fields sharing the same underlying allocation unit.

The "Happens-Before" Relationship

The C memory model uses a strict ordering framework called the happens-before relation to determine whether an operation is safely synchronized:

  • Sequenced-Before: Within a single thread, evaluation order determines what actions happen before others.
  • Synchronizes-With: Operations such as unlocking a mutex (mtx_unlock) and subsequently locking it in another thread (mtx_lock), thread creation (thrd_create), thread joining (thrd_join), or using atomic operations with appropriate memory ordering constraints (such as memory_order_release paired with memory_order_acquire) create a "synchronizes-with" relationship across threads.

If an operation \(A\) synchronizes with an operation \(B\), then \(A\) happens before \(B\). If two conflicting accesses do not have a happens-before path connecting them, they are concurrent, which triggers a data race.

Atomicity and the <stdatomic.h> Header

Operations declared with the _Atomic type qualifier or accessed via <stdatomic.h> primitives do not generate data races, even when performed concurrently without traditional locks. The C memory model specifies that atomic operations are indivisible and inherently ordered by memory consistency models (such as sequentially consistent, acquire-release, or relaxed ordering). While concurrent atomic writes can cause non-deterministic values depending on execution timing, they are not classified as data races under the standard.

Consequences of a Data Race

Under the C standard, any program execution containing a data race produces undefined behavior.

Because a data race yields undefined behavior, the compiler is permitted to make optimization assumptions that ignore the possibility of concurrent writes. This can lead to:

  • Torn Reads and Writes: Reading partially written bytes from a location if the hardware cannot perform the access in a single bus transaction.
  • Aggressive Compiler Reordering: Compilers may hoist reads out of loops, eliminate writes deemed redundant in single-threaded context, or cache values in registers indefinitely.
  • Memory Corruption and Crashes: Inconsistent state can corrupt pointers, flags, and internal runtime structures.

To avoid data races, developers must ensure that every shared, mutable state is either protected by synchronization primitives like mutexes or declared and accessed using atomic operations with proper memory ordering semantics.