Elixir Task, Task.Supervisor - Another way to work with Elixir process
Author: Manh Vu
Published: 2024-07-06

Intro

If you just jump to Elixir from other languages, process & supervisor is one of many things you need to understand to take advantage of Elixir but it’s hard for people they don’t come from concurrent programming languages.

For newbie can work easy with process, Elixir provides Task & Task.Supervisor to help us work with Elixir process.

Task

Task is high level abstract to work with process. We can create a process to execute a task and get result without boilerplate code (for spawn & get result).

Not same as GenServer (much more simpler than GenServer) Task provides a simple way to execute a function (anonymous function or define in a module). We must care our state & loop function if need a long-run task. For state we have Agent module, you can check in this topic

Task is simple for using both cases, using directly or define a module with Task.

For start directly we can use like:

# create a task in other process.
task = Task.async(fn ->
  # execute a task.
end)

# do something.

# get result
result = Task.await(task)

We can use await_many for case we need to wait two or more tasks.

We can see a flow to create & wait a task:

Task flow

We can see to execute a function as a process and get result is only in two functions call.

Remember our task create by Task.async will linked with current process (parent) then if task is crashed or current process is crashed other process will die follow (if we don’t want a link, can use async_nolink in Task.Supervisor). The link in this case help us follow “fail fast/let it crash”, we don’t need to clean rest processes if one of linked process was crashed.

We can use task as a child in supervisor by simple use Task in our module like:

defmodule PermanentTask do
  use Task, restart: :permanent

  def start_link(arg) do
    Task.start_link(__MODULE__, :run, [arg])
  end

  def run(arg) do
    # ...
  end
end

Then we can add to our supervisor like:

Supervisor.start_link([
  # other children in supervisor.
  {PermanentTask, arg}
], strategy: :one_for_one)

As we see, if we need add a simple task then Task can help us reduce complex things like self define a process or implement a GenServer for supervisor (I have other posts for explaining GenServer and Supervisor).

Deep dive to Task module we see Task.async function will return a reference for Task.await can get result from message. Nothing magic in here, Task module just wrap function need to run and spawn process with link + monitor. If we run a short task then get message from queue (if run iex in terminal, we can use flush function to get all messages in queue), we will see two messages like:

{#Reference<0.0.13315.722909021.800391170.124665>, 6}
{:DOWN, #Reference<0.0.13315.722909021.800391170.124665>, :process,
 #PID<0.105.0>, :normal}

A message with {#Ref... is a message contain result (where Task.await will get result out), one other is message with format {:DOWN, #Ref..., :process, #PID<0.105.0>, :normal} is created by monitor process feature :normal that mean our task run & completed (no error). It’s simple right?

Task can return a stream by using async_stream for case we need to work with stream.

For case we want to ignore we can call ignore/1 to unlink a running task.

For case we need check status of unlinked task we can use yield/2 & yield_many/2.

Task.Supervisor

Elixir provides a supervisor for Task to work & manage dynamically tasks (support to create remote tasks).

children = [
  {Task.Supervisor, name: OurApp.TaskSupervisor}
]

Supervisor.start_link(children, strategy: :one_for_one)

And at runtime we can add async task like:

task = Task.Supervisor.async(OurApp.TaskSupervisor, fn ->
  # execute a task.
end)

For case we need to create a lot of tasks we can use Task.Supervisor with partition to avoid bottleneck.

For case we need spawn long-run processes and need to communicate between these, we can use Agent module or Ets table.

Use cases

If you work with LiveView process, Task is so easy to create another process to do task and these’re a couple, if LiveView or Task process is crash, other one will die. We don’t need care to clean :).


# In LiveView process, wait to receive a task from user.
handle_event("run_task", params, socket) do
Task.async(fn ->
  # run task in other process, result format {:task, result}
  do_task(params)
end)

{:noreply, assign(socket, task_ref: ref}
end

# receive result from task process
handle_info({ref, {:task, result}}, socket) do
Process.demonitor(ref, [:flush])

# do somethings with result.

{:noreply, socket}
end

Conclusion

Task is good for case run one process or group of processes in period of time and get result from that. It’s convenient for us code with flow in our mind, don’t need to stop and back to Application or Supervisor module to add child and handle result.