Tasks

Learn about tasks and their supervision in Elixir.

We'll cover the following

This part of the course is about processes and process distribution. So far, we’ve covered two extremes. In the initial chapters, we looked at the spawn primitive, along with message sending and receiving and multimode operations. Later, we looked at OTP, the juggernaut of process architecture.

Sometimes, though, we want something in the middle. We want to be able to run simple processes, either for background processing or for maintaining state. But we don’t want to be bothered with the low-level details of spawn, send, and receive, and we really don’t need the extra control that writing our own GenServer gives us.

Enter tasks and agents, which are two simple-to-use Elixir abstractions. These use OTP’s features but insulate us from these details.

Tasks

An Elixir task is a function that runs in the background.

defmodule Fib do
  def of(0), do: 0
  def of(1), do: 1
  def of(n), do: Fib.of(n-1) + Fib.of(n-2)
end

IO.puts "Start the task"
worker = Task.async(fn -> Fib.of(20) end)
IO.puts "Do something else"
## ...
IO.puts "Wait for the task"
result = Task.await(worker)

IO.puts "The result is #{result}"

The call to Task.async creates a separate process that runs the given function. The return value of async is a task descriptor (actually a PID and a reference) that we’ll use to identify the task later.

Once the task is running, the code continues with other work. When it wants to get the function’s value, it calls Task.await, passing in the task descriptor. This call waits for our background task to finish and returns its value.

We can also pass Task.async the name of a module and function, along with any arguments. Here are the changes:

worker = Task.async(Fib, :of, [20]) 
result = Task.await(worker)
IO.puts "The result is #{result}"

Tasks and supervision

Tasks are implemented as OTP servers, which means we can add them to our application’s supervision tree. We can do this in a number of ways.

First, we can link a task to a currently supervised process by calling start_link instead of async. This has less impact than we might think. If the function running in the task crashes and we use start_link, our process will be terminated immediately. We instead use async, our process will be terminated only when we subsequently call await on the crashed task.

The second way to supervise tasks is to run them directly from a supervisor. Here, we specify the Task module itself as the module to run, and we pass it the function to be run in the background as a parameter.

  children = [
   { Task, fn -> 
 do_something_extraordinary() end }
 ]
 Supervisor.start_link(children, strategy: 
 :one_for_one)

We can take this approach a step further by moving the task’s code out of the supervisor and into its own module.

defmodule MyApp.MyTask do
  use Task

  def start_link(param) do
    Task.start_link(__MODULE__, :thing_to_run, [ param ])
  end

  def thing_to_run(param) do
    IO.puts "running task with #{param}"
  end
end

The key thing here is use Task. This defines a child_spec function, allowing this module to be supervised:

children = [
  { MyApp.MyTask, 123 }
]

The problem with this approach is that we can’t use Task.await because our code isn’t directly calling Task.async.

The solution to this is to supervise the tasks dynamically. This is similar in concept to using a :simple_one_for_one supervisor strategy for regular servers. See the task documentation for details.

However, before we get too carried away, remember that a simple start_link in an already-supervised process may well be all we need.

Run the c tasks1.exs and c tasks2.exs commands to execute the code below:

Get hands-on with 1400+ tech skills courses.