Concepts / Synchronizing Access to Shared Data

Synchronizing Access to Shared Data

Brief or infrequent access is not the test.

  • Programming

Why a Shared Whiteboard Seems Harmless

When people first meet concurrency, they often picture concurrent tasks as workers in the same room. If two workers need the same whiteboard, why not let both write on it directly? The whiteboard is already there, it is convenient, and it seems to save effort. Many beginners carry this instinct into their programs. They create one variable, a list, or a counter, and let every task read and change it whenever it needs to.

This article looks at why that instinct is risky and at the simple design principles that replace it. The guiding idea is that tasks should stay as separate from one another as possible, and that any contact between them should be simple and easy to see.

What do you think happens?

Two concurrent tasks each add one to the same counter many times. Both start from zero, run at the same time, and never coordinate with each other. Will the final count be exactly the total number of additions?

  • Yes, it will always be exact

  • No, it may come out lower than the total

  • It depends only on how many tasks there are

Reveal answer
Answer:

No, it is not guaranteed to be exact. Without coordination, one task's write can overwrite another task's update, so the final count may be lower than the total number of additions.

The danger does not come from one dramatic failure. It comes from two ordinary-looking reads and writes happening in an unlucky order.

Tracing a Shared Counter

A belief often causes trouble here: if two tasks only touch a shared variable briefly, only read it, or only change it a little, then nothing bad can happen. The reasoning is that danger seems to appear only when something dramatic goes wrong, so ordinary-looking access must be harmless. That belief is mistaken. Brief or infrequent access is not the test. The real question is whether two tasks can touch the same data without coordination.

Adding one to a counter is not a single step. Each task reads the current value, computes the new value, and writes it back. Those three parts are separate moments, and the other task can act between them. The diagram below traces one possible order of events in which both tasks read the counter before either has written to it.

Task Aadds oneTask Badds oneShared counterfinal value 11. reads 02. reads 03. writes 14. writes 1
Both tasks read 0 before either writes, so both write 1 and the final value is 1 instead of 2. The order shown is one possible interleaving, not the only one.

In this ordering, Task B's read happens before Task A's write, so Task B works from a stale value. Task B's write then overwrites Task A's update, and one of the two additions is lost. Nothing in either task looks wrong when read alone. The fault lives in the timing between them, which is exactly why this kind of error is hard to spot.

Tasks as Functions Running Alongside Their Caller

Definition

A concurrent task can be thought of as a function that runs concurrently with its caller. Arguments go in, and a result comes back.

This function-like view is useful because functions are already familiar. A well-behaved function takes what it needs as arguments and reports what it found as a return value. If it does not reach out and modify variables that belong to other parts of the program, it is easy to reason about. The same discipline carries over to concurrent tasks.

The timeline below shows the rhythm of such a task. The caller starts the task and keeps going. The task runs on its own. The two sides meet only at the moment the result is handed back, which is the one place where they exchange information.

Caller starts taskarguments suppliedCaller keeps runningother work continuesTask runsworks on its own inputResult returnedthe meeting pointCaller receivesresultexchange complete
The caller starts the task and keeps running. The task runs separately, and the two meet only when the result is returned to the caller.

The aim is to keep each task as independent as possible. Tasks may still need to exchange information, but that exchange should happen through a small number of simple and obvious channels, rather than through data that several tasks can reach at any moment.

Before and After: Shared Total Versus Returned Count

Consider a program in which several concurrent tasks each count how many items they have processed, all writing into one shared total. The goal is to redesign it so that no task writes to shared data while another task might be using it. The diagram below compares the two data paths.

Task Areads and writes totalShared totaltouched by both tasksCallersupplies itemsReturned countresult handed backTask Breads and writes totalTaskworks on its own inputread and writeread and writeitems incount out
Before, two tasks read and write one shared total. After, each task receives its items as an argument and returns its count, so the only crossings are the input going in and the result coming out.
Before
total = 0

def count_shared(items):
    global total
    for item in items:
        total = total + 1

The function reads and writes a variable that other tasks can also reach, so its result depends on timing.

After
def count_items(items):
    count = 0
    for item in items:
        count = count + 1
    return count

The function receives its items as an argument and returns its count. Its only interaction with the rest of the program is the input and the output.

In the revised version, the caller hands each task its items and later receives each count as a returned value. The caller can then combine the counts itself, in one place, after the results arrive. No task ever writes into a value another task may be reading.

Mistakes That Keep Hidden Coupling

  • Common mistake

    Assuming brief or read-only access to shared data is safe

    What it looks like

    Two tasks each read a shared list only once, and one task appends to it near the end.

    Why it fails

    The danger depends on whether the two tasks can touch the same data without coordination, not on how often or how dramatically they do it.

    Do this instead

    Remove the shared data from the design, or pass each task the values it needs as arguments.

  • Common mistake

    Redesigning the code but keeping a shared total

    What it looks like

    Each task receives its items as an argument but still adds to one global total.

    Why it fails

    The task still writes to a location other tasks can reach, so the hidden coupling remains.

    Do this instead

    Have each task return its own count, and let the caller combine the returned values.

  • Common mistake

    Treating every exchange of information as forbidden

    What it looks like

    A developer removes all communication, even a single clear hand-off of a result.

    Why it fails

    Communication is expected. The principle targets hidden, tangled, or unsynchronized contact.

    Do this instead

    Keep communication through a small number of simple and obvious channels, such as arguments in and a result out.

  • Common mistake

    A function that modifies a variable belonging to another part of the program is hard to reason about, even when no concurrency is involved.

Checking a Design for Hidden Shared Data

A task design still depends on shared data when the task reaches outside itself to read or change something other tasks can also reach. The checks below help reveal that dependence, and each check points to a revision step.

  1. 1
    Check whether the task writes to any variable, list, or counter defined outside its own body. If it does, move that value into a parameter or a locally created result.
  2. 2
    Check whether the task reads any value that another task might change. If it does, pass that value in as an argument at the moment the task starts.
  3. 3
    Check whether the task's output is communicated by changing shared state. If it is, have the task return the output as its result instead.
  4. 4
    Check whether the caller reads the task's output only through a shared location. If it does, have the caller receive the returned result and combine it itself.
Worked example

Revising a shared total

Three concurrent tasks each count the items they process, and all three add to one shared total. Revise the design so that no task writes to shared data.

  1. 1
    Find the shared write

    Each task adds to the same total variable. That write is the hidden coupling the design must remove.

  2. 2
    Give each task its input

    Each task receives only its own list of items as an argument, so it never needs to look at anything outside itself.

  3. 3
    Return each count

    Each task computes a local count and returns it as its result, instead of writing into the total.

  4. 4
    Combine at the caller

    The caller collects the three returned counts and adds them in one place. The only exchanges are the three inputs going in and the three results coming out.

Result

No task writes to shared data. The total is computed by the caller from the returned counts, so the design can be checked by reading it.

MEDIUM

A program has four concurrent tasks that each record the number of errors they find in a shared log counter. Redesign the program so that each task receives what it needs as arguments and returns its error count, with the caller combining the results. Describe what each task receives and what it returns.

Hints
  • Ask what each task would need to read, and whether that value can be passed in as an argument.
  • Ask where the final combined count should be computed, and whether a single place can do it after the results arrive.

Key Takeaways

  • Sharing data between concurrent tasks is a common source of errors, and brief, read-only, or occasional sharing is not a safe substitute for avoiding it.
  • A concurrent task can be understood as a function that runs alongside its caller: arguments go in, and a result comes back.
  • The safe design keeps the data path simple, with the caller supplying arguments, the task working on its own input, and the result returned, with no third location both sides read and write.
  • Communication between tasks is expected; what must be avoided is hidden, tangled, or unsynchronized communication.
  • A design still depends on shared data when a task reads or writes anything other tasks can reach, and revising it means moving that data into arguments and returned results.

Related concepts

More concepts starting with S