Wait, Bang vs. Bang, Wait: Subtleties in Elixir Cron Scripts
This post is just a quick recap of how to do the cron-like task of executing code on a periodic basis in Elixir. In other languages, you might implement this using a sleep function, but behold this warning from ye olde Elixir docs:
For almost all situations where you would use
sleep/1
in Elixir, there is likely a more correct, faster and precise way of achieving the same with message passing.
Like vampires, Elixir never sleeps! Remember what Joe Armstrong said of Erlang (Elixir’s engine): “write once, run forever.” In this case, we can do our message passing via Process.send_after/4
.
Because this is Medium, we pause for a gratuitous photo — it helps the clickbait or something. So here’s CJ Cron wearing a Rockies uniform (because I was born in Colorado and by the time Cron was playing, our tax dollars were funding more than the Zephyrs. Sportsball!!).
Back to our article. Here’s our little cron-like module:
defmodule Cronlike do use GenServer @allowed_units [:second, :minute, :hour, :day] def start_link(state) do
GenServer.start_link(__MODULE__, state)
end @impl true
def init(%{interval: interval, unit: unit} = state) when is_integer(interval) and interval > 0 and unit in @allowed_units do # wait, bang:
Process.send_after(self(), :tick, to_ms(interval, unit)) # bang, wait:
# send(self(), :tick) {:ok, state}
end @impl true
def handle_info(:tick, %{interval: interval, unit: unit, mod: mod,
fun: fun, args: args} = state) do # Do Something
apply(mod, fun, args) Process.send_after(self(), :tick, to_ms(interval, unit)) {:noreply, state}
end defp to_ms(interval, :second), do: interval * 1000
defp to_ms(interval, :minute), do: to_ms(interval, :second) * 60
defp to_ms(interval, :hour), do: to_ms(interval, :minute) * 60
defp to_ms(interval, :day), do: to_ms(interval, :hour) * 24end
You would start it in your application.ex
something like so:
def start(_type, _args) do children = [
Supervisor.child_spec(
{Cronlike, %{interval: 1, unit: :second, mod: IO, fun: :puts, args: ["Hi"]}}, id: :thing1),
Supervisor.child_spec(
{Cronlike, %{interval: 2, unit: :second, mod: IO, fun: :puts, args: ["Bye"]}}, id: :thing2)
] Supervisor.start_link(children, [strategy: :one_for_one, name: MyApp.Supervisor])end
Our contractual obligations with sysadmins force us to point out that this is not technically a cron
, but those of us who are not so puritanically chastised can see that the idea is similar: we simply want to execute something at periodic intervals.
Because this is only a quick flyby (are you still reading, or just scrolling by now?) we will make only brief mention of some idiomatic Elixir niceties in our sample module:
- We are using a module attribute
@allowed_units
to store “constant”-like values in a conspicuous place at the top of our module. - Our function relies on guard clauses to restrict the input.
- We are taking advantage of pattern matching when defining multiple function clauses to do our conversion to milliseconds via our
to_ms/2
function. - We can spin up multiple instances (am I allowed to use that word in reference to functional programming?) of our
Cronlike
module by giving each one a unique:id
when the app is started. - This module can execute any function by defining its module (via
:mod
), function (:fun
), and arguments (:args
) as arguments toapply/3
Now that we’ve got that out of the way, we can finally talk about the weirdness that is sending messages to a GenServer
and the difference between Process.send_after/4
and send/2
. That’s what this article was supposed to be about, it just takes 9 innings of back-story.
When we init
the module, we make an indirect call to the handle_info/2
callback. This is not an obvious connection. Look closely. The “message” we are sending to the current process is a simple atom: :tick
— one important thing to note is that when we send the message, we do not get an immediate response (there is a reason why that function returns :noreply
). It’s more like we queued up the message for delivery, but we aren’t waiting around to see if it got properly handled. Think postcard instead of registered letter: there is no tracking number.
In Elixir-speak, we have sent the message via “cast” instead of via “call”, so we don’t expect any useful response from send/2
or Process.send_after/4
It’s not obvious that send(self(), :tick)
would end up being routed to handle_info/2
, so you might need an article like this to demonstrate the concept. The first input to handle_info
is simply the message (:tick
in our case). The second argument is the state of the GenServer
(which defines the function we wish to periodically execute). You could put more into the message payload and rely less on the state of the server — dealer’s choice.
You can see that the handle_info/2
implementation sends another message to itself via Process.send_after/4
— this is what gives us that periodic cron-like behavior: the thing just keeps calling itself.
The only difference between having a periodic task that executes first and then repeats (i.e. “bang, wait”) vs. one that waits first and then executes (“wait, bang”), is the flavor of message passing used when we kick things off inside of the init/1
function. If we send the message immediately via send/2
we end up with a “bang, wait” type of execution. If we instead use Process.send_after/4
we end up with a “wait, bang” setup.
Hopefully this article helps us avoid losing sleep over how message passing works in Elixir.