What does the C compiler do to ensure that different but adjacent memory locations are safe to be used on different threads?
Interview Questions practice on Codemia
Over 8,000 real interview questions from top companies, searchable by company and role.
Introduction
The C and C++ standards guarantee that distinct memory locations can be safely accessed by different threads without synchronization, even if they are adjacent. The compiler ensures this by never generating code that writes to a memory location it was not asked to write to. Specifically, the compiler must not use read-modify-write sequences on a wider memory unit (like a full word) when the program only modifies a single byte or bitfield within it. This constraint was formalized in C11 and C++11 and is commonly known as the "no spurious writes" or "no adjacent memory clobbering" guarantee.
The Problem: Adjacent Memory and False Sharing
a and b are adjacent bytes. On most architectures, they share the same cache line and possibly the same machine word. A naive compiler might optimize a write to a by loading the entire 4-byte or 8-byte word, modifying the a byte, and writing the word back. But this read-modify-write clobbers b — a catastrophic data race if thread 2 is simultaneously writing to b.
What the Standard Requires
C11 (section 3.14) and C++11 (section 1.7) define a "memory location" as either a scalar object or a maximal sequence of adjacent bitfields with non-zero width. The rule is:
- Two threads can access different memory locations concurrently without synchronization.
- The implementation must not introduce writes to memory locations the program does not write to.
How Compilers Implement This
Byte-Addressable Stores
On x86, ARM, and most modern architectures, the hardware supports byte-granularity stores. The compiler simply emits a single-byte store instruction:
Bitfields: The Difficult Case
Adjacent bitfields within the same underlying storage unit share a memory location. The compiler typically loads the entire word, modifies the target bits, and stores the word back. This means adjacent bitfields in the same storage unit cannot be safely accessed from different threads.
The zero-width bitfield tells the compiler to start a new storage unit, placing y in a different memory location that can be independently accessed.
Alpha Architecture: The Historic Exception
The DEC Alpha processor lacked byte-granularity stores — it could only write 4-byte or 8-byte words. To write a single byte, the compiler had to use a load-locked/store-conditional (LL/SC) sequence:
This LL/SC loop ensures that if another thread modifies an adjacent byte in the same word between the load and store, the store fails and retries. Modern architectures (x86, ARM, RISC-V) avoid this by supporting native byte stores.
Practical Implications
This is safe by the standard. However, while there is no data race, there may be a performance issue: both fields share the same cache line, causing "false sharing" — the cache coherency protocol bounces the cache line between cores on every write.
Common Pitfalls
- Assuming adjacent bitfields are separate memory locations: Bitfields in the same underlying storage unit are a single memory location. Two threads writing to adjacent bitfields without synchronization is a data race, even though they appear to be separate fields. Use zero-width bitfields or separate integer fields to force distinct storage units.
- Confusing thread safety with cache performance: The compiler guarantees correctness (no spurious writes) but not performance. Two threads writing to adjacent fields on the same cache line cause false sharing — the cache line ping-pongs between cores, severely degrading throughput. Use
alignas(64)or padding to put hot fields on separate cache lines. - Relying on compiler-specific behavior for older architectures: The Alpha architecture required LL/SC sequences for byte stores. Code compiled without proper C11/C++11 support on such architectures may generate word-level stores that clobber adjacent bytes. Always compile with
-std=c11or-std=c++11or later to get the memory model guarantees. - Using volatile instead of atomics for shared data:
volatileprevents compiler reordering but does not provide atomicity or memory ordering guarantees. For data shared between threads, use_Atomic(C11) orstd::atomic(C++11).volatileis for hardware registers, not thread synchronization. - Assuming struct packing is portable:
#pragma pack(1)removes padding, placing fields at byte boundaries. While this does not create data races (byte stores are safe on modern hardware), it can cause unaligned access penalties and breaksalignasdirectives intended to prevent false sharing. Use packing only for serialization, not for thread-shared structures.
Summary
- C11/C++11 guarantee that writes to distinct memory locations are thread-safe without synchronization
- Compilers must not generate word-level read-modify-write sequences for byte or short writes
- Adjacent bitfields in the same storage unit share a memory location — use zero-width bitfields to separate them
- Modern hardware (x86, ARM) supports native byte stores; the Alpha required LL/SC workarounds
- False sharing (not data races) is the practical concern for adjacent fields — use
alignas(64)to separate hot fields onto different cache lines
Related reading
.png&w=3840&q=75)
Tackling System Design Interview Problems
A short course that equips you with the skills to approach system design interviews methodically.
Start the free courseNo course covers this one yet
Basis Lab writes one for you from a sentence about what you want to be able to do, then teaches it and checks you understood. Your first course is free.
Build my courseInterview Questions practice on Codemia
Over 8,000 real interview questions from top companies, searchable by company and role.