1. What changes when there are two threads
Let's start with motivation, because otherwise multithreading looks like a weird game of “let's make life harder”. Real programs often do several things at once: read data, compute something heavy, show progress, respond to the user, write logs. If you do everything in a single thread, the program can “freeze” and appear hung — even if it honestly works.
Imagine our simple console app for learning (let it be a simple task manager called TaskBook). It has commands like add, list, done, and sometimes a heavy operation: for example, recomputing statistics over large data or importing a task list. Ideally you want the interface not to “die” during a heavy operation and at least print progress.
Here is the key idea of today's lecture: as soon as concurrency appears, the order of events stops being guaranteed. And your main enemy is not the “complicated thread syntax”, but thinking errors: designing logic as if the order is fixed.
Execution thread and shared memory
When we say “thread of execution”, we mean a separate line of executing instructions inside one process. A process can have multiple threads, and they usually share the same memory: global variables, heap data, objects in main(), containers, and so on. So they “live in the same apartment”, they just move through rooms at their own pace.
It's important not to confuse “concurrency” and “parallelism”. For our purposes a simple idea is enough: even if you have a single physical CPU, threads can run “interleaved” (the OS scheduler switches between them very quickly). And if there are multiple CPUs — they can truly run at the same time. In both cases the effect for the programmer is the same: the order of steps between threads is unknown in advance.
It's useful to keep this diagram in mind:
flowchart TD
A["One process (your program)"] --> B["main thread"]
A --> C["worker thread"]
B --> D["Shared memory: variables, containers, heap"]
C --> D
Both threads can access shared memory. And if you haven't agreed “who touches what when”, races begin.
Nondeterminism: the same code — different event orders
The first thing beginners notice about threads: console output can appear in different orders. And this is not a “compiler bug” but normal behavior: the OS scheduler may give the CPU to one thread or to another.
A mini demonstration (here we use std::thread, but without going deep — just as a “way to launch a second line of execution”):
#include <iostream>
#include <thread>
int main() {
std::thread t([] { std::cout << "worker\n"; });
std::cout << "main\n";
t.join();
}
One run might print:
// main
// worker
Another run might print:
// worker
// main
Both outputs are correct. The point: there is no promise of “who goes first” between threads until you build such a rule yourself.
A separate nuance: std::cout is also a shared resource. Even if lines look “mostly correct”, they can interleave if you print them in pieces. Today we treat this as a “symptom of concurrency”: saw garbled console output — remember that threads are interleaving.
Race condition: logical race
Race condition is a logical error where correctness depends on the order in which threads managed to perform their steps. Importantly: a race condition can exist even in a program that is “formally protected” in places (for example, there are locks somewhere) — the logic still depends on order.
Imagine a household analogy: two people edit the same document simultaneously. They both make valid edits and don't tear the paper, but the final result depends on who clicked “save” last. The document is not “physically broken” — but the outcome may not be what you expected.
Let's make an example closer to our TaskBook. Suppose we want to update a status string: one thread writes “Importing…”, another thread writes “Done”. If you assume “Done” will always be last — you are already in the danger zone.
#include <iostream>
#include <string>
#include <thread>
int main() {
std::string status = "starting";
std::thread t1([&] { status = "importing"; });
std::thread t2([&] { status = "done"; });
t1.join();
t2.join();
std::cout << status << '\n'; // importing OR done
}
Even if the program doesn't crash, the result is nondeterministic: you cannot guarantee you'll see done. The reasoning “well, t2 is lower in the code, so it's later” no longer works. Threads don't read your code like a book.
To clarify the distinction between terms, it's useful to compare them in a table:
| Concept | What it is | Main symptom | “How bad” |
|---|---|---|---|
| Race condition | A logic error due to an unpredictable order of events | Sometimes “the wrong result”, sometimes “the wrong branch of the scenario” | Bad: a flaky bug that is hard to reproduce |
| Data race | Incorrect concurrent access to memory (a write without an agreed protocol) | May “work”, may crash, may produce strange values | Very bad: UB, i.e. the program is formally incorrect |
We'll get to data race in a couple of minutes — and it will become clear why it's a separate category of “horrors”.
Data race: concurrent memory access + write → Undefined Behavior
If race condition is “the logic went off the rails”, then data race is “we broke the language rules”. In C++ a data race occurs when two threads access the same memory location concurrently, and at least one thread writes, and there is no agreed protocol for access between those accesses.
The most insidious part: many think that ++counter is “one action”. In code text — yes. At execution level — no. Usually it's at least three steps: read, increment, write. And if two threads do this concurrently — they can overwrite each other's changes.
Here is a classic example (and yes, it's intentionally “bad”):
#include <iostream>
#include <thread>
int main() {
int counter = 0;
auto inc = [&] {
for (int i = 0; i < 100000; ++i) {
++counter; // INCORRECT: data race (UB)
}
};
std::thread t1(inc);
std::thread t2(inc);
t1.join();
t2.join();
std::cout << counter << '\n'; // not required to be 200000
}
You might expect 200000. Sometimes you'll see 200000. Sometimes — less. And sometimes (on a particularly “lucky” day) the program may behave very strangely, because Undefined Behavior means: the language standard is not obliged to explain what will happen.
Here it's important to mentally accept an unpleasant fact: you can't treat UB as a “rare bug”. UB is more like “you left the rules of the game”, and then the compiler and runtime can do anything.
By the way, the very fact that the word race is treated seriously is visible even in wording in standard library materials: you'll find phrases like “promise::set_value() and promise::get_future() should not race”. It's a good marker: races are not an “academic scare story” but a real engineering pain that standards and specs address.
Minimal “safe discipline” for today
One might want to say: “Okay, just put locks everywhere and that's it.” But that will be the next course step. Today our goal is simpler and more honest: learn to think so you don't design races from the start.
The simplest discipline for a beginner looks like this: until you can synchronize shared access, don't share mutable data. That sounds almost like “don't touch the boiling pot”, but it really works.
There are two safe patterns available at our level.
The first pattern — “a worker thread does the work, the main thread reads the result only after join”. In practice this means: one thread writes to a variable, and the other reads it only after join() (we'll cover join() in detail in the next lecture; for today treat it as “wait until the thread finishes”).
#include <iostream>
#include <thread>
int main() {
int result = 0;
std::thread t([&] {
result = 42; // write in worker
});
t.join(); // waited for worker to finish
std::cout << result << '\n'; // 42
}
There is no simultaneous access to result: while the thread works, main() does not read the variable. And when main() reads it — the thread has already finished.
The second pattern — “each thread has its own”. So instead of one shared variable we make two independent ones, and each thread writes only to its own. Later, when both have finished, we combine them.
#include <iostream>
#include <thread>
int main() {
int a = 0;
int b = 0;
std::thread t1([&] { a = 10; });
std::thread t2([&] { b = 32; });
t1.join();
t2.join();
std::cout << (a + b) << '\n'; // 42
}
This still looks “too simple”, but safe designs are built from simple contracts: you decide in advance who owns which data and when it may be read.
If we try to tie this to our TaskBook, a background thread could, for example, compute statistics (how many tasks are done), and the main thread display the UI. But the statistics should update either at well-defined points, or in a separate data area, or via a protocol (which we'll learn later). For now remember: the order is not guaranteed, and shared access without rules is dangerous.
Where the race hides
It's useful to be able to “see” a race with your eyes. Here's a typical picture for ++counter, if you expand it into steps:
Thread 1: read(counter) -> tmp1
Thread 1: tmp1 = tmp1 + 1
Thread 1: write(counter, tmp1)
Thread 2: read(counter) -> tmp2
Thread 2: tmp2 = tmp2 + 1
Thread 2: write(counter, tmp2)
Now imagine these steps are interleaved:
counter = 5
t1: read -> tmp1 = 5
t2: read -> tmp2 = 5
t1: write 6
t2: write 6 (overwrites t1 result)
As a result two increments produced +1 instead of +2. That's a visible problem. But in C++ the trouble is deeper: the compiler can optimize assuming there is no data race (because that's what the language rules state). Then behavior can become even more unexpected.
3. Typical mistakes
Mistake #1: thinking threads execute “like lines of code”.
A very human habit: if in main() thread t1 is created first, then t2, it means t1 is “earlier” and t2 is “later”. In reality both threads may start almost simultaneously, and which one actually runs first is decided by the OS scheduler. So any reasoning like “I'll put this later — and it will run later” does not work in multithreading.
Mistake #2: treating races with delays sleep_for().
A delay sometimes “helps” in the sense the bug stops showing. But this is not a fix — it's masking. You just tuned the thread schedule so the problem appears less often. On another machine, under different load, or with different compiler optimizations it will return. sleep_for() can be used to demonstrate nondeterminism in an example, but not as a synchronization mechanism.
Mistake #3: assuming ++counter is atomic.
Psychologically ++ looks like “one operator”, therefore “one step”. But in practice it's a read-modify-write. In one thread it's safe, in several it's not unless you built an access rule. That's why counters are the most common pedagogical example of races: their simplicity deceives the eye.
Mistake #4: confusing race condition and data race.
Race condition is about logic and event order: “who got there first” and why that changes the result. Data race is about memory and language rules: “simultaneous access with a write and no agreed protocol”, which leads to Undefined Behavior. You can write a program with no data race but with a race condition (the logic still depends on order). And vice versa, you can get a data race where the logic seems “obvious”.
Mistake #5: ignoring that std::cout is a shared resource.
When lines in the console are interleaved, beginners often conclude: “Oh, I have a bug in cout”. Actually this is a useful signal: if your output is already tangled, you almost certainly can also tangle access to shared data. Today we use cout as an indicator of concurrency: saw chaos — remember that the order is not guaranteed.
GO TO FULL VERSION