Help understanding the difference between Task and GenServer

Hi. I recently started picking up Elixir, and have been reading code here and there. I came across the Clock module on the Bandit repo.

This module updates a value in an ets table every second, and it is implemented using the Task module. My question is, what is the difference between this implementation and implementing the same functionality using GenServer? (more or less like this, I guess):

defmodule Bandit.ClockGen do
  use GenServer

  def date_header do
    # exactly the same as in Bandit.Clock
  end

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, [])
  end

  @impl true
  def init(_) do
    __MODULE__ = :ets.new(__MODULE__, [:set, :protected, :named_table, {:read_concurrency, true}])
    update_header()

    {:ok, %{}}
  end

  @impl true
  def handle_info(:update_header, state) do
    update_header()
    {:noreply, state}
  end

  def handle_info(_, state), do: {:noreply, state}

  defp update_header do
    :ets.insert(__MODULE__, {:date_header, get_date_header()})
    Process.send_after(self(), :update_header, 1_000)
  end

  defp get_date_header, do: Calendar.strftime(DateTime.utc_now(), "%a, %d %b %Y %X GMT")
end

Are these two implementations equivalent? What am I missing?

Thank you.

The difference mostly manifests when you want to interact with the process by sending messages from other processes.

For instance, imagine you wanted to be able to tell the ClockGen to temporarily stop updating.

GenServer provides a straightforward extension point via handle_call/handle_cast/handle_info (depending on the specific shape of the messages), while a pure Task version will need to write an explicit receive somewhere.

5 Likes

Complementing @al2o3cr answer, while you can supervise a Task started with Task.Supervisor, by itself a Task is not an OTP behavior. It doesn’t have built-in callbacks for restarts or fault-tolerance logic. Tasks are often used for “fire and forget” jobs or for parallelizing parts of a computation.

In short, anything that can be done with a Task can also be done with a GenServer, but not the other way around. The author of Bandit chose to use a Task as a design decision. I believe they opted for a Task because it simplifies the implementation, as there was no need to control the lifecycle or handles fault-tolerance.

These two implementations are equivalent. In fact, if it were my design decision, I would prefer to use a GenServer to implement this Clock and eliminate the recursion.

3 Likes

Thank you. That clears up things a bit.

1 Like

That’s not completely correct. Yes Task is not really a behaviour. The input of a user of tasks just provides a single function. But tasks can be restarted by a supervisor, they just default to :temporary restart mode in their child_spec. They also implement a lot of the usual metadata you have in otp processes like ancestors and such.

2 Likes

I didn’t say that Task cannot be restarted. I said that it does not provide the more “fine-grained” callbacks to handle restarts and fault-tolerance logic, such as terminate/2. In fact, I mentioned in my answer that Task can be supervised, and the Task documentation explicitly mentions this as an option. Even the Bandit.Clock module referenced by @alce uses the restart: :permanent option.

The main purpose of my answer was to clarify the different use cases between using Task and implementing a GenServer. However, if you want to bring this discussion to a conceptual level: yes, Task is an OTP-compliant process, and I never stated otherwise. What I did say is that it is not an OTP behavior, as it does not implement GenServer or provide callbacks such as init/1, handle_call/3, and handle_cast/3.

1 Like