Concurrency: when two threads share data
Race conditions on a real counter, what a lock actually costs, atomic operations, and why the phrase it passed on my machine proves nothing.
In tier 1 a thread was a unit the scheduler could run, and a process was the box that owned the address space. That distinction now starts to hurt. Two threads inside one process share the same data: the same heap, the same globals, the same counter. Nothing in the hardware stops them from touching it at the same instant, and the moment they do, the arithmetic you wrote is no longer the arithmetic the machine performs.
Start with the smallest possible example. Two threads, each incrementing one shared counter a million times. Every programmer will tell you the answer is 2000000. Run it and read the number.
/* counter.c */
#include <pthread.h>
#include <stdio.h>
static long counter = 0;
static void *worker(void *arg) {
(void)arg;
for (long i = 0; i < 1000000; i++) {
counter = counter + 1;
}
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("%ld\n", counter);
return 0;
}
/* $ gcc -O2 -pthread counter.c -o counter && ./counter
* 2000000 <- never guaranteed
* 1431927 <- typical instead
*/Two threads, one shared counter, no lock. Compile with gcc -O2 -pthread. The printed value changes from run to run.The loop body looks like one action but is three: read the counter into a register, add one, write it back. A tick of the scheduler can land between any two of them, and with two cores the two threads do not even take turns. Here is the smallest schedule that loses an increment.
counter = 0
T1 r1 <- counter r1 = 3417
T2 r2 <- counter r2 = 3417
T1 r1 <- r1 + 1 r1 = 3418
T2 r2 <- r2 + 1 r2 = 3418
T1 counter <- r1 counter = 3418
T2 counter <- r2 counter = 3418
T1 done. T2 done. counter = 3418Both threads read 3417, both add one, both write 3418. Two increments happened and the counter moved by one. This is a race condition: the result depends on the order the scheduler happened to pick, not on the program. On a real run of the program above you get something around 1.4 million instead of 2 million, and a different number every time. The bug is not rare and it is not random noise. It is a deterministic consequence of an interleaving you did not control.
The same loss happens everywhere the read-modify-write pattern appears: a bank balance, a reference count, an item appended to a list, a connection counter. What matters is not the operator. What matters is whether the value is read and then written back as two separate steps that another thread can slip between.
For a single counter there is a lighter answer. Processors have atomic read-modify-write instructions (lock xadd on x86, ldrex and strex on ARM), and the compiler exposes them as atomic operations. No lock object, no waiting thread, no separate critical section.
atomic_long counter = 0;
void *worker(void *arg) {
(void)arg;
for (long i = 0; i < 1000000; i++) {
atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
}
return NULL;
}
/* lock xadd -> uncontended, a few nanoseconds
* contended -> the cache line is ping-ponged between cores
*/What a lock costs depends entirely on contention. Uncontended, an atomic add is a few nanoseconds and a mutex acquire and release is a few tens of nanoseconds: a single instruction plus two barriers. The trouble starts when a second thread wants the same lock. Then the OS has to park that thread, which means a trip into the kernel, a context switch, and later a wake-up. That is microseconds, not nanoseconds - two to three orders of magnitude more than the work being protected. If the critical section is ten instructions long and four threads fight over it, the lock spends most of its time moving threads between cores, and the program gets slower as you add cores. This is why a growing counter is the classic false optimization: the atomic version scales until the cache line starts bouncing between cores, and then it stops too.
Now the uncomfortable part: why did the counter program pass your test? A race needs a particular interleaving, and interleavings are not equally likely. On one idle core, both threads run to completion in long stretches and the bad window is hit maybe once in a million loop iterations. On a busy machine, or with a debugger attached, or in production under load, that window is hit all the time. So the test passes on a laptop and the incident happens on a server with 32 cores. Concurrency bugs are timing-dependent, which means the absence of a failure is not evidence of correctness. Worse, adding a print statement to investigate can change the timing enough to hide the bug entirely.
The honest summary is that humans are bad at this, and the reason is specific. Reasoning about a sequential program means following one path. Reasoning about two threads means reasoning about the set of all interleavings, and their number grows combinatorially with the length of the code. Nobody holds that set in their head. This is why the practical answer is not to be careful but to narrow the surface: keep shared mutable state as small as possible, prefer immutable data passed between threads, use a message queue instead of shared memory where you can, and take a lock only around the few instructions that truly need it. Where the invariants cannot be made obvious, they must be written down and tested with tools built for it - sanitizers, model checkers and stress tests that deliberately vary timing - rather than with ordinary unit tests.
Practice
The counter program loses increments. At which point in the loop body can a second thread do damage?
Hints
- Count the machine steps the compiler emits for counter = counter + 1 and ask how many of them must stay together.
- The instruction that only adds is not the problem; the problem is the pair of instructions around it that move the value between memory and the processor.
Match each fix to the failure it actually removes.
Hints
- One answer eliminates the shared variable altogether, another only closes the window between the read and the write.
- Protecting the entire function is the heaviest tool here, and a value seen half-updated is a different symptom from two increments collapsing into one.
The counter holds 3417 and the two statements from the failure case are counter = counter + 1 running in each thread. Write the step-by-step trace that leaves the counter at 3418, reusing the same two registers r1 and r2.
The code runs on your machine, not on ours — we do not execute it. Check your solution against each requirement and mark the ones it satisfies.
Hints
- Write the six statements in the order the two threads must have executed them, not the order they appear in the source.
- Both threads must read the old value before either of them writes, otherwise the counter comes out right and you have described a different schedule.
Questions about this lesson
What is an operating system, really?
It is the manager between your programs and the hardware. It decides which program gets the processor, hands out memory, and keeps files in order. Every program you write runs on top of it.
Why learn this if I just want to write code?
Because when a program is slow, hangs or runs out of memory, the reason is almost always here. Without this you can only guess; with it you can measure.
Is it free?
Yes. The first level of every subject is free and needs no account. Deeper levels and the senior grades are paid — that is what keeps the project running.
What age is this for?
It is written for curious teenagers from about 12 and for adults starting from zero. Nothing assumes prior programming experience; lessons about networking and physics need no mathematics beyond school level.
Do I need to know programming already?
No. The logic, physics and networking tracks start from nothing. The languages track is where you actually write code, and it also starts from the beginning.
Do I need to install anything?
No. Everything runs in the browser: theory, practice and the interactive simulations. There is nothing to download and no environment to configure.
How long does one subject take?
A lesson takes 10 to 15 minutes. A junior grade is roughly 12 to 20 lessons, so expect a few weeks of steady practice rather than one evening.
Do I have to follow the order?
Within a grade, levels open one after another, because each builds on the previous one. Between subjects you are free: start with logic, physics or networking.