Jack Marchant

Working with Tasks

July 26, 2018

While writing Understanding Concurrency in Elixir I started to grasp processes more than I have before. Working with them more closely has strengthened the concepts in my own mind. In Elixir’s standard library, there’s a few modules that abstract common code that without these modules you’d find youself repeating often. When you want to write asynchronous code, you may care about the result of the code, and sometimes you might not. The Task module makes it easy, either way. To better understand how tasks work, I thought I would create a simple (naive) module that would implement a similar API to that of the Task.

Re-implementing the Task module

Consider the following module, which I’m going to call Job.

defmodule Job do
  def async(fun) when is_function(fun) do
    parent = self()

    spawn_link(fn ->
      send(parent, {self(), fun.()})
    end)
  end

  def await(job, timeout \\ 5000) do
    receive do
      {^job, result} -> result
    after
      timeout -> {:error, "no result"}
    end
  end
end

Job.async/1 accepts a single function as a parameter, and this is the work that will be carried out asynchronously. You can either run the function, without caring about the result:

iex> Job.async(fn -> "Hi" end)
<#PID>

It returns a Process Identifier (PID), which is the result of calling spawn_link/1, passing in a function which in turn sends a message to the parent process. We’ve split up the implementation of async and await so that you can optionalally pass the PID to await if you care to wait for a result.

Let’s see what that would look like:

iex> Job.async(fn -> "Hi" end) |> Job.await()
"Hi"

When we pattern match on the job PID to identify the message being received, and the result of the job, the value of the result is the result of invoking the function passed to Job.async/1.

In this case the result was seen instantly, but if it the initial function was actually performing asynchronous work, then it would wait for a timeout period to elapse before giving up. This is the after section of the await function.

iex> Job.async(fn -> 
  :timer.sleep(6000)
  "Hi" 
end) 
|> Job.await(5000)

{:error, "no result"}

We got an error because the timeout had elapsed, given the timer in the function paused processing until 6 seconds had gone, whereas the Job.await/2 function gave up waiting after 5 seconds.

Conclusion

Hopefully the Job module helps your understanding of what the Task module is doing under the hood, to some degree, it is not the full implementation and there’s a whole lot more that come with using tasks, such as process supervision, streaming, and more. That being said, it can be useful to become familiar with passing messages between processes, in any case.


Jack Marchant

Written by Jack Marchant who builds apps with Elixir, GraphQL and React. You should follow him on Twitter or check out his code on GitHub