Concepts / Data Races and Synchronization

Data Races and Synchronization

Shared data between tasks is not a small risk to be accepted.

  • Programming

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?

  • Always 2

  • Always 1

  • 1 or 2, depending on the order of access

Reveal answer
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.

Callerstarts the task and keepsrunningArgumentsthe only input the taskseesTaskworks alongside the callerResultthe only output the taskproducesCombined totalbuilt by the callerpasses inreceived byproducesreturned to caller
How does data travel from the caller into the concurrent task and back out as a result? Each arrow is a deliberate handoff, and there is no loop back into a shared store.

What a Data Race Actually Is

Definition

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

Worked example

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.

  1. 1
    Step 1: Task A reads the total

    Task A reads the shared total and sees 0.

  2. 2
    Step 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.

  3. 3
    Step 3: Task A writes

    Task A computes 0 plus 1 and writes 1 into the shared total.

  4. 4
    Step 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.

Result

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.

Task A reads totalsees 0Task B reads totalsees 0Task A writes totalwrites 1Task B writes totalwrites 1Final total1 instead of 2thenthenthenthen
In what order do the steps of two concurrent tasks run, and how does that interleaving make the final value wrong?

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.

Task A totala name held by Task AShared totala memory location holding 0Task B totala name held by Task Brefers torefers to
Which variables do two tasks each hold, and which of them actually point to the same underlying memory?

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.

In the real world

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 like

    Two tasks update one running total and the programmer reasons that they will only touch it now and then.

    Why it fails

    A 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 instead

    Treat 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 like

    Each task looks correct when read alone, so the programmer concludes the program is correct.

    Why it fails

    The race lives in how the tasks interleave, which a line-by-line reading of one task cannot show.

    Do this instead

    Look 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 like

    Adding into one shared total looks shorter than returning partial results and combining them.

    Why it fails

    The shorter version reintroduces the risk that the separate version removed. Brevity does not remove the shared path.

    Do this instead

    Accept 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.

QuestionShared total designSeparate results design
Where does each task write its contribution?Into one shared variableInto its own result, returned to the caller
Can the tasks interfere through hidden access?Yes, through the shared variableNo, each task speaks only through its output
Is synchronization needed to stay correct?Yes, and it must be arranged carefullyNo shared path exists to arrange
What does the design cost?Less code at the point of writingOne extra combining step in the caller
Which visible contact points remain?The shared variable, easy to missThe arguments and the returned results

Two ways to combine work from concurrent tasks

Best practice

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

MEDIUM

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.

Related concepts

More concepts starting with D