Concepts / Returning Results from Tasks

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.

  • Programming

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?

  • It cannot; the result is lost

  • The caller passes in an object, and the function places the result inside that object

  • The function prints the result and the caller reads the printout

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

CallerTaskContainer object1. creates empty container2. gives container to task3. writes result inside4. reads result afterwards
In what order do the caller's create, the task's write, and the caller's read happen, and when is the result actually ready to read?

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.

Code walkthrough
def square_into(number, container):
    container.append(number * number)

results = []
square_into(4, results)
print(results)
  1. 1
    The function square_into takes a number and a container. It computes the square and adds it to the container.
  2. 2
    The caller creates an empty list named results. This is the container the caller supplies.
  3. 3
    The call square_into(4, results) gives the list to the function. The function writes 16 into it.
  4. 4
    The 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
Output
[16]
Worked example

Tracing the container's contents

Starting from an empty list, what does the list contain after each step of the code?

  1. 1
    Create the container

    results = [] gives an empty list. Its contents are nothing.

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

  3. 3
    Task writes

    Inside the function, container.append(16) adds 16 to that list.

  4. 4
    Caller reads

    print(results) reads the list the caller still holds, which now contains 16.

Result

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.

AspectResult through a return valueResult through an argument
Where the result arrivesAs the value the function returnsInside an object the caller passed in
What the call site showsThe result is visibly assigned or usedThe call may look like it produces nothing
Who creates the containerThe function produces the result itselfThe caller creates the container before the call
When the result can be readAfter the call returnsAfter the task has written into the container
Overall judgmentThe conventional and more elegant routeA 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 like

    The caller creates the list, starts the task, and immediately prints the list without waiting for the task to finish.

    Why it fails

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

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

    A call such as fill_report(data, output) appears to do nothing, yet it quietly fills output.

    Why it fails

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

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

    Two tasks both add to the same counter with no guard around the update.

    Why it fails

    Their operations can interleave, so the final value may not reflect every update.

    Do this instead

    Synchronize 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

MEDIUM

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

  1. 1
    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.
  2. 2
    The 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.
  3. 3
    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.
  4. 4
    Many 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.

Related concepts

More concepts starting with R