Data Races and Synchronization
Shared data between tasks is not a small risk to be accepted.
Why a Shared Total Looks Safe
Once a program is split into pieces that run at the same time, the hardest question is rarely how to start those pieces. The harder question is how the pieces should relate to each other. A design habit prevents a whole family of errors: keep each task as separate from the others as possible, and make any contact between tasks simple enough that you can see it at a glance.
Many programmers, when they first write concurrent code, adopt a shortcut that feels reasonable. Two tasks need the same running total, so both are given direct access to one shared variable. The reasoning goes like this: the tasks only touch that variable now and then, so they will rarely collide, and if they do collide it is a small risk. Nothing in the code looks wrong when you read it line by line.
This belief is the misconception this article is designed to correct. Shared data between tasks is not a small risk to be accepted. Used without proper synchronization, it is the central source of data races, and a program that depends on them may give different answers from one run to the next.
What do you think happens?
Two tasks each add 1 to a shared total that starts at 0. Both tasks run at the same time and touch the same variable with no coordination. What value can the total end with?
Reveal answer
1 or 2, depending on the order of access
Both tasks may read 0 before either one writes. Each then writes 1, and one increment is lost, leaving 1. If one task finishes its read and write before the other one reads, the total reaches 2. Which outcome you get depends on timing, and that unpredictability is what makes the program unreliable.
A Task as a Function Beside Its Caller
A useful model is to treat a task as a function that runs alongside its caller. The caller starts the task, the task receives arguments, and the task eventually returns a result. The task does its work while the caller keeps going, and the two meet again only at the points where data is handed over.
Seen this way, a well-designed task has a clear boundary. Everything it needs arrives through its arguments, and everything it produces leaves through its result. If a task can only see its inputs and only speaks through its output, then there is no hidden channel through which another task could interfere with it. That absence of hidden channels is exactly what the separation principle is trying to achieve.
The diagram below follows a single task from start to finish. Notice that data travels in one direction at each stage: from the caller into the task as arguments, and from the task back to the caller as a result.
What a Data Race Actually Is
A data race happens when tasks use shared data without proper synchronization. The important word is the combination: sharing alone is not the problem, and running concurrently alone is not the problem. The trouble appears when several tasks reach the same data and nothing arranges the order or the timing of their access.
Because nothing guarantees which access happens first, the program has no guarantee about the outcome, and the outcome depends on chance. This is why a data race can produce results that vary from run to run. The same code may give the right answer on one run and a wrong answer on the next, which makes such bugs hard to reproduce and hard to test for.
Synchronization is the remedy the definition points to: it arranges the order or timing of access to shared data. The source frames the design goal differently, though. Instead of building careful coordination around a shared variable, you can often remove the shared path altogether. That is the approach this article focuses on, because a design with no shared path has no race to manage.
Tracing Two Tasks That Update One Total
Two tasks adding into one shared total
Two tasks each need to add 1 to a shared total that starts at 0. Both run at the same time. Trace the steps in one possible interleaving and state the final value.
- 1Step 1: Task A reads the total
Task A reads the shared total and sees 0.
- 2Step 2: Task B reads the total
Before Task A has written anything, Task B reads the same shared total and also sees 0. Neither task has been told about the other's read.
- 3Step 3: Task A writes
Task A computes 0 plus 1 and writes 1 into the shared total.
- 4Step 4: Task B writes
Task B, still working from the 0 it read earlier, computes 0 plus 1 and writes 1. Task A's write is overwritten.
The total is 1, but two increments were made, so it should be 2. One update was silently lost. A different timing, where Task A finishes before Task B reads, would give 2, so the same code gives different answers depending on timing.
The timeline below shows the same interleaving as a sequence of steps. Read the rows from top to bottom. The two rows marked as writes are the ones that change the stored value, and the final row shows what the program ends with.
Telling Separate Tasks From Coupled Ones
Two tasks are coupled when they both reach the same underlying data. The danger is that coupling is often invisible in the code. Each task may refer to a variable with a plain, innocent-looking name, and two names can quietly lead to the same storage. Reading one task alone will never reveal the problem.
The diagram below shows the shared case. Each task holds its own name for the total, but both names lead to the same stored value. Asking which variables actually point to the same underlying memory is the test for coupling.
The test for separation is the opposite picture. If each task holds its own value and neither name leads anywhere the other can reach, there is no shared path, and the tasks cannot interfere. The source's guiding rule for telling the two cases apart is the one you have already seen: a task that sees only its inputs and speaks only through its output has no hidden channel to another task.
Consider a set of tasks that each compute a partial sum from their own list of numbers. Each task receives its list as an argument and returns its partial sum as a result. The caller adds the partial sums at the end. The tasks are separate: none of them can see the others' lists or sums, so no interference is possible, and the one extra addition in the caller is the only point of contact.
Mistakes When Designing Concurrent Tasks
Common mistake Assuming rare collisions make shared data safe
What it looks likeTwo tasks update one running total and the programmer reasons that they will only touch it now and then.
Why it failsA data race depends on timing, not on how often the tasks touch the data. A rare collision can still produce a wrong answer, and it may appear only on some runs.
Do this insteadTreat any shared mutable data between concurrent tasks as a design problem, and check whether the tasks can be kept separate.
Common mistake Judging concurrent code by reading one task at a time
What it looks likeEach task looks correct when read alone, so the programmer concludes the program is correct.
Why it failsThe race lives in how the tasks interleave, which a line-by-line reading of one task cannot show.
Do this insteadLook at every point where two tasks reach the same data, and ask what order their accesses could happen in.
Common mistake Treating a shared variable as harmless because the code is short
What it looks likeAdding into one shared total looks shorter than returning partial results and combining them.
Why it failsThe shorter version reintroduces the risk that the separate version removed. Brevity does not remove the shared path.
Do this insteadAccept the one extra step of combining results in the caller in exchange for removing the shared path.
Common mistake Letting a task reach outside its arguments without noticing it
A useful warning sign is an arrow in your design that does not fit the pattern of arguments in and results out. The source treats such an arrow as a signal that two tasks are becoming coupled, and that the coupling deserves a second look before the code is written.
Comparing a Shared Total With Separate Results
The two designs for the running total can be compared directly. The shared design is shorter at the point of writing. The separate design has one extra step at the end, when the caller combines results, and that step is what keeps the tasks from interfering with each other.
| Question | Shared total design | Separate results design |
|---|---|---|
| Where does each task write its contribution? | Into one shared variable | Into its own result, returned to the caller |
| Can the tasks interfere through hidden access? | Yes, through the shared variable | No, each task speaks only through its output |
| Is synchronization needed to stay correct? | Yes, and it must be arranged carefully | No shared path exists to arrange |
| What does the design cost? | Less code at the point of writing | One extra combining step in the caller |
| Which visible contact points remain? | The shared variable, easy to miss | The arguments and the returned results |
Two ways to combine work from concurrent tasks
When designing a small set of concurrent tasks, give each one its inputs as arguments and let it return a result. Keep every point of contact between tasks visible in the design, so that any task reaching outside this pattern is noticed immediately. If the contact points are few, simple, and obvious, the design is easy to reason about.
Practice: Spotting Hidden Coupling
Three tasks run concurrently. Task 1 and Task 2 both add into a shared counter that Task 3 also reads. Redesign the arrangement so that the tasks are separate, and describe every point of contact that remains between them.
Hints
- Ask which variables each task can see, and which of them lead to the same stored value.
- Give each task its own input as an argument and let it return its own result.
- Move the combining step into the caller, which is the only place that should bring the results together.
Key Takeaways
- A data race happens when tasks use shared data without proper synchronization, and its outcome depends on timing, so it can differ from run to run.
- A well-designed concurrent task receives everything through its arguments and produces everything through its result, leaving no hidden channel for interference.
- Two tasks are coupled when their variables lead to the same underlying data, and this coupling is often invisible when each task is read alone.
- A separate design may need one extra combining step in the caller, but it removes the shared path where interference happens and keeps every contact point visible.