What do GenServers offer over Tasks?

I’m looking for clarity on the benefits of GenServers. At first I thought they could be used to process asynchronous work via handle_case, but it seems like the popular literature, some by Jose himself, advocates against using casts in favor of the synchronous call. If GenServers are meant to be used to handle synchronous code, what’s the point? I have trouble how I’d benefit more by using them over a Task.

Consider the following classic breakfast scenario. To have breakfast, I need to do the following things:

  1. Make toast
  2. Cook eggs
  3. Butter the toast
  4. Pour orange juice
  5. Plate the eggs and toast
  6. Eat

To do this, (1,3), 2, and 4 could be run as separate tasks. 5 could await 1, 2, and 3, and 6 could await 1-5:

oj_task = Task.async(fn -> pour_oj() end)
[
Task.async(fn -> make_toast() |> butter_toast end),
Task.async(fn -> make_eggs() end)
]
|> Enum.map(&Task.await/1)

plate_task = plate_eggs_and_toast()
[ oj_task, plate_task] |> Enum.map(&Task.await/1) |> eat()

How would I be able to do this via GenServers?

It’s possible that I’m missing some foundational understanding here. I just don’t see the utility of using call when a Task or cast would do the trick. Please help clarify. Thank you!

1 Like

A GenServer is typically going to run for a long time, versus tasks which are used for a brief time and then terminate.

For instance, you might use a GenServer to act as a cache in front of an expensive API; each request would arrive as a call to the GenServer, which would reply with the result and go back to waiting for requests.

Be wary with cast - there’s NO guarantee of message delivery. Casting to a process that has never existed won’t give any error.

2 Likes

GenSevers are about state. Each GenServer can maintain its own ‘mutable’ state that can be changed with either casts or calls.
I doubt they would be useful for the specific case you mention.
Although, in the same vein, you could have a toaster that keeps track of how many bread slices are left, one for the eggs…
Edit : on a side note, using call instead of cast doesn’t mean that you necessarily wait for the genserver to do the whole work, it may only be : acknowledge that you have received the order and I trust you’ll do something about it.

EVERY process is sequential. A Task is only processing the function given to it. A GenServer is taking one message at a time from its mailbox. call vs. cast does not change this. Call vs. cast is whether the caller is waiting for a response from the GenServer or not.

edit: Sorry I didn’t mean to reply to you directly @krstfk, just to the topic in general

3 Likes

Right… but with tasks you can atleast maintain control on the calling thread while the task work is getting done. With GenServers, the calling thread is blocked. Why not just do the work in the calling thread?

It sounds like you have a limited view of concurrency. Consider other use cases. How would you implement a connection pool with a set of Tasks? You need a GenServer to preserve the state of which connections are checked our or available. That’s the primary difference between Task and GenServer. Tasks are short-lived “run and done” processes. GenServers stick around, and remember state.

Also, be aware of your terminology. When I first learned Elixir I equated threads with processes too, but I’ve come to completely change my thinking. Threads allow shared memory, but processes do not. That simple change has profound implications.

2 Likes

Anything you can do with Task you can do with a GenServer and the reason is… Task is a GenServer!

As others have mentioned, tasks are all about performing some computation, it has some initial state, does the work and returns the result. It doesnt maintain state in the sense that you cannot really ask it what its state is until the Task finishes. Theres another abstraction worth mentioning and that is Agent. Agent is the opposite of Task, its all about state and hardly about compute. You can ask the Agent what it state is and ask it to update its state but there is not really any computation associated with it.

Given both Agent and Task are built on top of a GenServer, it needs to necessarily allow handling both the state and compute. And it sure does!

Oh, and since you have been using async/await, what async basically does is it spawns a process (spawning a process is always async) to do the work and when the work is done it will send the message to the calling process. All awat basically does it is just waiting on that message. Thats basically what GenServer.call does too, it sends the message and awaits response. The difference is after async but before await you can do more work in the caller which is different than GenServer.call which is just one invocation. Sometimes you need one and sometimes the other is most appropriate.

Hope it helps!

6 Likes

As @wojtekmach said there is more to GenServer than there is to tasks or agents .
With the breakfast example, if you make breakfast at home, then using tasks, just as a way to make things concurrently is fine.
But if you’re a waiter at restaurant, you want to be able to tell the next customer that an item is out, that cooking the eggs will be slow because ten persons ordered before. Then, you could have a GenServer for toasting the bread and one for the eggs, and maybe one to keep track of the orders that have been cooked.
Each time you go place an order, you’d call EggServer with a table number. EggServer would then handle the call by sending itself a message that it needs to cook the egg for the table number and then sending you a reply with the number of eggs left and the number of unprocessed orders, the new state would be number of eggs left and an added unprocessed order.
As the waiter, you can now ask ToastServer to do the same thing.
Meanwhile, in EggServer’s handle_info, Eggserver would do the actual cooking for table number and call the OrdersTracker with the table number when it’s ready and decrement the number of unprocessed order.
Again ToastServer would do the same thing concurrently.
And while both the eggs and the toasts are cooking, you could ask the OrderTracker if it has anything you could bring to a table.
And you could repeat this for each table.
You couldn’t track the number of unprocessed orders or the number of items left with Tasks, because you couldn’t manage state across orders. You couldn’t have the EggServer send itself a message with an agent because an agent can’t handle arbitrary message.

2 Likes

Unless I’m mistaken, Task is not built on top of GenServer:

It’s really a new addition to OTP that Elixir brings to the table.

A task is for any situation where you want to have OTP supervision for things that are simple, linear sets of actions. You should default to using Tasks over GenServers in the general case.

GenServers have re-entrant, nonlinear asynchronous, event flows driven by message-passing. They’re very complicated and actually rather disorganized, code-wise, in OTP. 99% of the time you probably shouldn’t use a GenServer. Important exceptions are:

  • You’re handling a stateful communications protocol (like TCP or something higher level like DHCP which is stateful over a stateless protocol).
  • You’re modeling something stateful in the real world (for example a VM, or a client’s web browser panel) and need a “smart caching layer”
  • You’re modeling something virtual that needs to be stateful and respond specifically to messages with a nonlinear control flow (like a poker game)
3 Likes

Unless I’m mistaken, Task is not built on top of GenServer:

You’re right. Tasks are OTP compatible and I was under impression they achieve that by using GenServer under the hood, but they use :proc_lib instead. Sorry for confusion.

2 Likes

GenServers serialise certain workfloads (i.e. only one job is being executed at a time through it). If f.ex. you are writing a web crawler you’d be much better served just using DynamicSupervisor and asynchronously spawn as many tasks as your heart desires (or use Task.async_stream which is a bit simpler API although with not the same features and guarantees).

GenServers are ideal for throttling access to external limited resources like 3rd party REST APIs, DB connections, filesystems etc. (okay, maybe pools was the better analogy here).

One thing I use a GenServer for is to enforce a read-write lock of sorts: reading goes directly to the source of the information I want but writing gets through a GenServer because that makes sure that I only ever will have one writer.

The scenario you described is IMO not well-served by GenServers.

1 Like