Synchronizing Access to Shared Data
Brief or infrequent access is not the test.
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?
Reveal 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.
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
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.
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.
total = 0
def count_shared(items):
global total
for item in items:
total = total + 1The function reads and writes a variable that other tasks can also reach, so its result depends on timing.
def count_items(items):
count = 0
for item in items:
count = count + 1
return countThe 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 likeTwo tasks each read a shared list only once, and one task appends to it near the end.
Why it failsThe 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 insteadRemove 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 likeEach task receives its items as an argument but still adds to one global total.
Why it failsThe task still writes to a location other tasks can reach, so the hidden coupling remains.
Do this insteadHave 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 likeA developer removes all communication, even a single clear hand-off of a result.
Why it failsCommunication is expected. The principle targets hidden, tangled, or unsynchronized contact.
Do this insteadKeep 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.
- 1Check 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.
- 2Check 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.
- 3Check whether the task's output is communicated by changing shared state. If it is, have the task return the output as its result instead.
- 4Check 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.
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.
- 1Find the shared write
Each task adds to the same total variable. That write is the hidden coupling the design must remove.
- 2Give 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.
- 3Return each count
Each task computes a local count and returns it as its result, instead of writing into the total.
- 4Combine 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.
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.
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.