Back to C Programming
2026-03-278 min read

Memory model and Data races

Learn Memory model and Data races step by step with clear examples and exercises.

Why This Matters

In this extensive lesson, we will delve deep into the intricacies of the memory model in C programming and understand the concept of data races. This understanding is crucial for writing efficient, reliable, and bug-free multi-threaded programs.

Why This Matters

As you progress in your coding journey, you may find yourself working on complex projects that require multiple threads to handle different tasks concurrently. In such scenarios, it's essential to understand the memory model and data races to avoid unintended behavior, ensure the correctness of your program, and prevent security vulnerabilities.

Prerequisites

Before diving into the core concept, let's make sure you have a solid grasp of the following topics:

  1. Basic C programming concepts
  2. Pointers in C
  3. Functions in C
  4. Variables and their scope
  5. Data structures like arrays and structs
  6. Understanding the basics of multi-threading in C
  7. Familiarity with the standard thread library (pthread) for C
  8. Basic understanding of synchronization primitives such as mutexes, condition variables, and semaphores
  9. Knowledge of memory management concepts like stack and heap
  10. Understanding of pointers and their use in C

Core Concept

Memory Model

The memory model defines the semantics of computer memory storage for the purpose of the C abstract machine. The data storage (memory) available to a C program is one or more contiguous sequences of bytes. Each byte in memory has a unique address.

In C, a byte is the smallest addressable unit of memory, defined as a sequence of bits large enough to hold any member of the basic execution character set. C supports bytes of sizes 8 bits and greater. The types char, unsigned char, and signed char use one byte for both storage and value representation.

Memory Location

A memory location is an object of scalar type (arithmetic type, pointer type, enumeration type) or the largest contiguous sequence of bit-fields of non-zero length in a struct. For example:

struct S {
char a; // memory location #1
int b : 5; // memory location #2 (part of a larger int)
int c : 11, // memory location #2 (continued)
d : 8; // memory location #3
struct {
int ee : 8; // memory location #4
} e;
} obj;

The object obj consists of four separate memory locations.

Threads and Data Races

A thread of execution is a flow of control within a program that begins with the invocation of a top-level function by thrd_create or other means. Any thread can potentially access any object in the program, but different threads should not concurrently update the same memory location without proper synchronization.

When an evaluation of an expression writes to a memory location and another evaluation reads or modifies the same memory location, the expressions are said to conflict. A program that has two concurrent evaluations that conflict is said to have a data race. Data races can lead to unpredictable behavior, including incorrect results, crashes, and security vulnerabilities.

Data Races and Concurrency

Concurrency refers to the simultaneous execution of multiple threads within a single program. When threads are concurrently accessing shared resources, it's essential to ensure that they don't interfere with each other, leading to data races.

Synchronization Mechanisms

To prevent data races in multi-threaded programs, we use synchronization mechanisms such as mutexes, condition variables, and atomic operations. These help ensure that multiple threads access shared resources safely and avoid unintended behavior.

Mutexes

A mutex (short for "mutual exclusion") is a synchronization object that allows only one thread to access a critical section at a time. This ensures that data races are avoided when multiple threads are accessing the same shared resource.

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
// ...
pthread_mutex_lock(&lock);
// Critical section
pthread_mutex_unlock(&lock);

Atomic Operations

Atomic operations are indivisible operations that cannot be interrupted by other operations. In C, some built-in types like _Atomic int support atomic operations, which can help prevent data races when working with shared variables.

_Atomic int counter = 0;
// ...
_Atomic int newValue = counter + 1; // Atomically increments counter
counter = newValue;

Worked Example

Let's consider an example of a simple multi-threaded program with a data race:

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

int counter = 0;

void* increment(void *arg) {
for (int i = 0; i < 1000000; ++i) {
counter++;
}
return NULL;
}

int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, &increment, NULL);
pthread_create(&thread2, NULL, &increment, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("Counter: %d\n", counter);
return 0;
}

In this example, we have two threads that increment a shared counter without proper synchronization. This can lead to unpredictable results due to data races. To fix this issue, we can use mutexes or atomic operations to ensure that only one thread modifies the counter at a time:

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

_Atomic int counter = 0;

void* increment(void *arg) {
for (int i = 0; i < 1000000; ++i) {
_Atomic int newValue = counter + 1; // Atomically increments counter
counter = newValue;
}
return NULL;
}

int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, &increment, NULL);
pthread_create(&thread2, NULL, &increment, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("Counter: %d\n", counter);
return 0;
}

Common Mistakes

  1. Forgetting to synchronize access to shared variables: When multiple threads share the same variables, it's essential to use proper synchronization mechanisms like mutexes or atomic operations to prevent data races.
  1. Using non-atomic operations on shared variables without proper synchronization: Even if a single operation seems simple and atomic, it can still lead to data races when performed multiple times by different threads without proper synchronization.
  1. Not understanding the memory model of C: Failing to grasp the memory model can result in unintended behavior, as different platforms may have different implementations that affect the order of operations.
  1. Ignoring race conditions in complex programs: In large and complex multi-threaded programs, it's crucial to identify and address potential data races by using proper synchronization mechanisms and testing for correctness.
  1. Not properly initializing synchronization objects: It's essential to initialize mutexes, condition variables, and other synchronization objects correctly before using them in your program.
  1. Using synchronization mechanisms incorrectly or inappropriately: Proper use of synchronization mechanisms is crucial for preventing data races. Misuse can lead to unintended behavior, such as deadlocks or livelocks.
  1. Not considering the performance implications of synchronization: While synchronization is necessary to prevent data races, it can also have a significant impact on performance. It's essential to use appropriate synchronization mechanisms and optimize their usage for efficient multi-threaded programming.

Practice Questions

  1. Write a multi-threaded program that calculates the factorial of a number using multiple threads and avoids data races by properly synchronizing access to shared variables.
  2. Explain why using ++counter instead of counter++ can lead to data races in multi-threaded programs.
  3. Given the following struct, how many memory locations does it occupy?
struct S {
char a;
int b : 5;
unsigned short c : 16;
};
  1. Write a program that demonstrates a data race using two threads and fix the issue by implementing a mutex.
  2. Explain how atomic operations help prevent data races in multi-threaded programs.
  3. Given the following code snippet, what is the output of the program? How can you ensure that it doesn't have any data races?
#include <pthread.h>
#include <stdio.h>

int counter = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void* increment(void *arg) {
for (int i = 0; i < 1000000; ++i) {
pthread_mutex_lock(&lock);
counter++;
pthread_mutex_unlock(&lock);
}
return NULL;
}

int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, &increment, NULL);
pthread_create(&thread2, NULL, &increment, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("Counter: %d\n", counter);
return 0;
}
  1. What is a deadlock and how can it be avoided in multi-threaded programs?
  2. Explain the difference between a livelock and a deadlock.
  3. Given the following code, explain why it may lead to data races and suggest a solution:
#include <pthread.h>
#include <stdio.h>

int counter = 0;

void* increment(void *arg) {
for (int i = 0; i < 1000000; ++i) {
counter++;
}
return NULL;
}

void* decrement(void *arg) {
for (int i = 0; i < 1000000; --i) {
counter--;
}
return NULL;
}

int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, &increment, NULL);
pthread_create(&thread2, NULL, &decrement, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("Counter: %d\n", counter);
return 0;
}

FAQ

  1. Why are data races harmful in multi-threaded programs?

Data races can lead to unpredictable behavior, incorrect results, crashes, and security vulnerabilities. They occur when multiple threads concurrently access and modify the same memory location without proper synchronization.

  1. What is an atomic operation in C?

An atomic operation is a single, indivisible operation that cannot be interrupted by other operations. In C, some built-in types like _Atomic int support atomic operations, which can help prevent data races when working with shared variables.

  1. What are the different synchronization mechanisms available in C for multi-threaded programming?

Some common synchronization mechanisms in C include mutexes, condition variables, and semaphores. These help ensure that multiple threads access shared resources safely and avoid data races.

  1. How can I test for data races in my multi-threaded program?

You can use tools like Valgrind's Helgrind to detect potential data races in your multi-threaded programs. Additionally, thorough testing and code review can help identify and address potential issues.

  1. Can I use atomic operations to replace mutexes in all cases?

While atomic operations can be useful for preventing data races, they may not always provide the same level of control as mutexes. For more complex synchronization scenarios, using both atomic operations and mutexes together may be necessary to ensure correct program behavior.

  1. What is a deadlock and how can it be avoided in multi-threaded programs?

A deadlock occurs when two or more threads are blocked, waiting for each other to release resources they need. To avoid deadlocks, you should:

  • Avoid creating cycles of resource dependencies between threads
  • Use proper lock ordering (acquire locks in the same order across all threads)
  • Release resources as soon as possible after acquiring them
  1. Explain the difference between a livelock and a deadlock.

A livelock, also known as a busy wait, occurs when multiple threads are continuously checking for a condition that never becomes true. This can result in high CPU usage without making any progress. In contrast, a deadlock results from waiting for resources that will never be released, causing the threads to become blocked and unable to make progress.

  1. Given the following code, explain why