Concepts / Designing Reliable Concurrent Programs

Designing Reliable Concurrent Programs

Concurrent tasks should be kept separate, with contact between them limited to simple and obvious communication.

  • Programming

Predicting a Shared Total

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. This article looks at one design habit that 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, it is a small risk. Nothing in the code looks wrong when you read it line by line. Try to predict what happens before reading on.

What do you think happens?

Two tasks each add 1 to a shared total that starts at 0, and both run at the same time. What final value can the total have?

  • Always 2

  • Always 0

  • Sometimes 1 and sometimes 2, depending on timing

Reveal answer
Answer:

Sometimes 1 and sometimes 2, depending on timing.

The program has no guarantee about which access happens first, so the outcome depends on chance and can differ from one run to the next. Neither answer is guaranteed, which is exactly what makes this design unreliable.

A Task as a Function Alongside Its Caller

A useful model is to treat a task as a function that runs alongside its caller. The task receives arguments, and it returns a result. This model gives you a clear picture of contact: data goes into the task through its arguments, and data comes back out through its result. Between those two handoffs, the task does its own work.

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. Every arrow is a deliberate handoff, so every point of contact between the caller and the task is visible in the design.

CallerArgumentsinput onlyTaskruns alongside callerResultoutput onlypasses inreceived byproducesreturned to
How does data flow from the caller into the task and back out, and at what points do the two run independently?

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. The absence of hidden channels is exactly what the separation principle is trying to achieve.

The Separation Principle

Definition

Concurrent tasks should be kept separate, with contact between them limited to simple and obvious communication.

The principle asks two things of a design. First, tasks should share as little as possible. Second, whatever contact does exist should be easy to see. A design that passes both tests can be checked by reading the arrows between tasks. A design that fails them requires you to reason about timing, which is much harder.

Tracing a Data Race Step by Step

Consider the shortcut from the opening: two tasks both add into one shared total. Each addition is really two actions, a read of the current value and a write of a new value. When both tasks run at the same time, their actions can interleave. The diagram below shows one possible order of events.

Task ATask BShared totalstarts at 01. read 02. read 03. write 14. write 1
In what order do the two tasks' reads and writes happen, and why does the final value come out wrong when they interleave?
Worked example

Two Tasks Adding to One Total

Each of two tasks adds 1 to a shared total that starts at 0. Both tasks run at the same time, with no synchronization between them.

  1. 1
    Both tasks read the total

    Task A reads 0. Before it writes anything back, Task B also reads 0, because nothing has arranged for the two accesses to happen in order.

  2. 2
    Task A writes its result

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

  3. 3
    Task B writes its result

    Task B was working from the old value 0, so it also computes 0 plus 1 and writes 1. Task A's update is overwritten.

Result

The total ends at 1 instead of 2. One update was lost. This is one possible interleaving, and a different timing could produce 2, which is why the program's answer varies from run to run.

Why Shared Data Is Not a Small Risk

The belief that shared data is a small risk to be accepted is the misconception this article is designed to correct. Used without proper synchronization, shared data is the central source of data races, and a program that depends on them may give different answers from one run to the next.

Definition

A data race happens when tasks use shared data without proper synchronization. The trouble appears when several tasks reach the same data and nothing arranges the order or the timing of their access. The program then has no guarantee about which access happens first, so the outcome depends on chance.

Notice what is not the problem. Sharing data alone is not the problem, and running concurrently alone is not the problem. Both conditions must hold, and no mechanism must be arranging access. Remove any one of those conditions and the race goes away. A separate design removes the shared data, which is usually the simplest of the three to remove.

Spotting Hidden Sharing

Sharing is often hidden rather than written out. Two tasks may both reach the same variable, object, or mutable reference, even though neither one's code names the other. To judge whether two tasks are separate, ask which data each task actually reaches. If the answer includes anything that another task also reaches, the two tasks are sharing data, however independent they look.

Task AShared totalreached by bothTask Breads and writesreads and writes
Which data does each task actually reach, and which objects or variables are referenced by more than one task?

A useful test follows the arrows. If a task's data comes only from its arguments and goes out only through its result, it has no hidden contact. If a task writes to something another task can read, or reads something another task can write, the arrow is a sign of coupling. The source describes this as a signal that deserves a second look: a design that needs an arrow outside the simple argument-and-result pattern is becoming coupled.

A Separate Design, Step by Step

The separate version of the same goal avoids the shared total entirely. Each task works only on its own data, and the combining happens afterward in the caller. The source is explicit about the trade: this design costs one extra step at the end, and in return the tasks cannot interfere with each other.

Worked example

Counting Items With Separate Tasks

Two tasks each count the items in their own half of a list, and the caller needs the combined count.

  1. 1
    Pass each task its input

    Each task receives its own half of the list as an argument. Neither task receives a reference to a shared counter.

  2. 2
    Each task computes a result

    Each task counts only inside its own arguments and produces one number as its result. It writes to nothing another task can read.

  3. 3
    The caller combines the results

    After both results have returned, the caller adds the two numbers. This is the one extra step the separate design requires.

Result

The combined count is correct on every run, because no task ever writes to data another task reads. The only point of contact is the explicit handoff of arguments in and results out.

AspectShared total designSeparate design
How tasks get dataBoth reach one variable directlyEach receives its data as arguments
Where results combineInside each task, during updatesIn the caller, after results return
Interference riskData race possible without synchronizationNo shared path to interfere through
Code effortLooks shorterOne extra combining step at the end

Two designs for the same goal, compared by how they handle contact between tasks.

Common Mistakes When Designing Concurrent Tasks

  • Common mistake

    Assuming rare access means safe

    What it looks like

    Two tasks touch a shared total only now and then, so the team decides the collisions are too rare to matter.

    Why it fails

    Rare collisions still produce results that vary from run to run, and the program has no guarantee about access order.

    Do this instead

    Keep each task's data separate from the others, so there is no shared path to collide on.

  • Common mistake

    Judging safety one line at a time

    What it looks like

    Each line in both tasks looks correct when you read it alone.

    Why it fails

    The danger sits in the shared path between tasks, which a line-by-line reading does not reveal.

    Do this instead

    Trace which data each task reaches, and check which objects or variables more than one task references.

  • Common mistake

    Treating a shared reference as harmless

    What it looks like

    A mutable object is passed to two tasks, and each task only modifies a field it is handed.

    Why it fails

    A reference handed to two tasks is still shared data, even though each task only sees it through its own argument.

  • Common mistake

    Reaching for a global variable inside a task instead of accepting the value through an argument

Designing Two Separate Tasks

MEDIUM

Two tasks each scan half of a list of numbers and need to report how many values are negative. Design the two tasks so that they do not share a counter. Describe what each task receives as arguments, what it returns, and where the final count is computed.

Hints
  • Start by listing what each task needs as arguments.
  • Ask whether either task must write to something the other task can read.
  • The caller is the natural place to combine the results.

Key Takeaways

  • Sharing data between concurrent tasks without proper synchronization is not a small risk. It is the central source of data races, which can produce results that vary from run to run.
  • A well-designed concurrent task is a function that runs alongside its caller. It receives everything it needs as arguments and returns its output as a result, so it has no hidden channel for interference.
  • Sharing alone and concurrency alone are not the problem. The danger is their combination with no arrangement of the order or timing of access.
  • A separate design may need one extra combining step in the caller, but it removes the shared path where interference happens. Check designs by tracing which data each task reaches.

Related concepts

More concepts starting with D