Returning Results from Tasks
A result can be passed back through a function argument: the caller supplies an object, the task writes into it, and the caller reads it afterwards.
When a Task Has Something to Report
When a task finishes its work, it often has to report something back to the code that started it. The most familiar way to do this is to hand the result back as the value a function returns. This article looks at a different route: placing the result into an object that was passed in as an argument.
The technique is common and it works, but it raises a question that the rest of this article is built around: what has to be true about the shared object for this to be safe when tasks run at the same time? Keep that question in mind as you read. By the end you should be able to say when the technique is a reasonable choice, and when the shared object needs protection.
What do you think happens?
If a function never uses return to send anything back, how could the code that called it still get a result?
Reveal answer
The caller passes in an object, and the function places the result inside that object
This is the argument-based route described in this article. The object the caller supplied is the container, and the caller reads it after the task has finished.
Passing the Container In
A result can be passed back through a function argument. The caller supplies an object, the task writes into it, and the caller reads it afterwards. The result is never returned as a function's return value. Instead, the object that was handed over is the place where the outcome is left for the caller to find.
The flow has three moments that always occur in the same order. First, the caller creates the container and gives it to the task. Second, the task does its work and places the result inside the container. Third, the caller looks in the same container to find the outcome. The caller's read can only be meaningful after the task's write has happened, and this ordering is the heart of the technique.
Read the diagram from top to bottom. The read in step four is the moment the caller can rely on the outcome, and only because the write in step three is complete. The return-value approach hides this timing inside the function call. The argument-based approach makes the timing visible in the caller's own code, which is one reason the two approaches feel so different to read.
Tracing a Result Through a List
The example below uses a plain function call to stand in for a task. It is a generated illustration of the pattern, not a piece of the source material. The container is a list that the caller creates before the call. The function does not return anything; it only appends its result to the list it was given.
def square_into(number, container):
container.append(number * number)
results = []
square_into(4, results)
print(results)- 1The function
square_intotakes a number and a container. It computes the square and adds it to the container. - 2The caller creates an empty list named results. This is the container the caller supplies.
- 3The call
square_into(4, results)gives the list to the function. The function writes 16 into it. - 4The print on the last line reads the same list. The caller sees the outcome without any return value.
- The function signature has no return statement
- results is created by the caller before the call
- append changes the list object that was passed in
[16]Tracing the container's contents
Starting from an empty list, what does the list contain after each step of the code?
- 1Create the container
results = [] gives an empty list. Its contents are nothing.
- 2Hand it to the task
square_into(4, results)passes the same list object into the function. The function does not receive a copy of the empty list; it receives the list itself. - 3Task writes
Inside the function,
container.append(16)adds 16 to that list. - 4Caller reads
print(results)reads the list the caller still holds, which now contains 16.
After the call, results contains one element, 16.
Weighing the Argument Route Against Return Values
Returning results through arguments is widely used, and it does the job. However, it is not considered particularly elegant. Its main cost is that the reader of the calling code has to notice that a value is being filled in through a parameter, rather than seeing it arrive as a return value. A reader who scans the call site may not realize that anything is produced at all.
| Aspect | Result through a return value | Result through an argument |
|---|---|---|
| Where the result arrives | As the value the function returns | Inside an object the caller passed in |
| What the call site shows | The result is visibly assigned or used | The call may look like it produces nothing |
| Who creates the container | The function produces the result itself | The caller creates the container before the call |
| When the result can be read | After the call returns | After the task has written into the container |
| Overall judgment | The conventional and more elegant route | A workable option whose trade-offs should be weighed |
Two ways for a task to report back, compared against the points in this article
Why Shared Data Needs Synchronization
The container in the argument route is shared: the caller and the task both hold it. When tasks share data, access must be synchronized so that at most one task at a time has access to a given set of objects. Without that guarantee, two tasks can change the same object in ways that interfere with each other.
When No Synchronization Is Needed
Not every shared situation needs a guard. Many tasks reading immutable data at the same time is a simplification that causes no problem. If nothing can change the data, then no task can interfere with another by changing it, so the interleaving problem from the previous section cannot arise.
A useful way to decide is to ask two questions about the shared object. Can any task change it? If the answer is no, many tasks may read it at once. If the answer is yes, then access to it must be synchronized so that only one task at a time can touch it.
Mistakes Beginners Make With Shared Results
Common mistake Reading the container before the task has written into it
What it looks likeThe caller creates the list, starts the task, and immediately prints the list without waiting for the task to finish.
Why it failsThe caller's read depends on the task's write having already happened. Reading early sees the container before the result is in it.
Do this insteadMake sure the caller reads the container only after the task has completed its work.
Common mistake Assuming an argument-based result is as obvious as a return value
What it looks likeA call such as fill_report(data, output) appears to do nothing, yet it quietly fills output.
Why it failsThe reader of the calling code has to notice that a value is being filled in through a parameter. Without that notice, the result is easy to miss.
Do this insteadName the container clearly and keep the call site readable, or prefer a return value where it fits.
Common mistake Letting several tasks write to one shared object with no coordination
What it looks likeTwo tasks both add to the same counter with no guard around the update.
Why it failsTheir operations can interleave, so the final value may not reflect every update.
Do this insteadSynchronize access so that at most one task at a time has access to the shared object.
Common mistake Treating a shared object as safe because most tasks only read it, even though one task occasionally writes to it
Practice Deciding Whether to Synchronize
A configuration object is loaded once at startup and never changed afterwards. Several tasks read settings from it at the same time. Does this shared access need synchronization? Explain your reasoning using the idea of immutable data. Then describe a different case, in which one task updates a shared list while others read it, and say what has to be guaranteed in that case.
Hints
- Ask whether any task can change the object after it is shared.
- Consider whether the interleaving problem from the counter scenario could happen if no task ever writes.
- In the second case, think about who may touch the list at a given moment.
Key Takeaways From the Argument Route
- 1A result can be passed back through a function argument: the caller supplies an object, the task writes into it, and the caller reads it afterwards.
- 2The argument route works, but it is less elegant than a return value, because the reader must notice that a value is being filled in through a parameter.
- 3When tasks share data, access must be synchronized so that at most one task at a time has access to a given set of objects.
- 4Many tasks reading immutable data at the same time causes no problem, but this does not cover data that tasks change.
At a glance
- A result can be passed back through a function argument: the caller creates an object, the task writes into it, and the caller reads it afterwards.
- The argument route is workable but less elegant than returning a value, since the reader must notice the parameter being filled in.
- Shared data that tasks change must be synchronized so that at most one task at a time has access to it.
- Many tasks reading immutable data at once is safe, but this does not extend to data that tasks change.