Beyond GenServer
Tasks
Task is a module used to run a job concurently, Task can be used in both awaited or none awaited, depending on if the starting process needs the results back or not.
long_job = fn ->
Process.sleep(2000)
:some_result
end
Calling Task.async/1 to start the lambda
task = Task.async(long_job)
The task can be awaited for by using Task.await/1 but this will fail after 5s timeout, this can be changed with Task.await/2
Task.async takes a zero arity function, the next example show how to use it with an arity function.
run_query =
fn query_def ->
Process.sleep(2000)
"#{query_def} result"
end
queries = 1..5
tasks =
Enum.map(
queries,
&Task.async(fn -> run_query.("query #{&1}") end)
)
Enum.map(tasks, &Task.await/1)
This is a short version with the pipe operator.
1..5
|> Enum.map(&Task.async(fn -> run_query.("query #{&1}") end))
|> Enum.map(&Task.await/1)
There are also non-awaited task, these task just performs the calculation and finish with reason :normal. the can be created with Task.start_link/1
Task.start_link(fn ->
Process.sleep(1000)
IO.puts("Hello from task")
end)
Task Supervisor
Elixir provides a dedicated Task Supervisor similar to the previous one we used.
Task.Supervisor.start_link(name: MyTaskSupervisor)
Task.Supervisor.start_child(
MyTaskSupervisor,
fn ->
IO.puts("Task started")
Process.sleep(2000)
IO.puts("Task stopping")
end
)
Agents
Agents are similar to GenServer but simpler, the don’t have handle_info/2 or terminate/1
Basics use of Agents
{:ok, pid} = Agent.start_link(fn -> %{name: "Bob", age: 30} end)
Agent.get(pid, fn state -> state.name end)
The get function takes a pid and a lambda to execute inside the agent, the lambda takes the state of the agent in it’s argument. the return value is sent to the caller process.
Agent.update(pid, fn state -> %{state | age: state.age + 1} end)
Agent.get(pid, fn state -> state end)
Agent.update/2 updates the state of the agent. this is a synchronous call, Agent.cast/2 can be used asynchronously.
Change in the state of an Agent can be noticed by other processes
{:ok, counter} = Agent.start_link(fn -> 0 end)
spawn(fn -> Agent.update(counter, fn count -> count + 1 end) end)
Agent.get(counter, fn count -> count end)
Agent module is implemented in plain Elixir on top of GenServer. Agent can’t be used to handle messages, or logic on termination, in those usecases GenServer is better suited.
ETS tables
Ets table are special in memory structure for storing erlang terms, they are really good for scalability compared to processes (which can be a bottleneck).
ETS tables don’t have garbage collection, memory is freed on delete, ETS table memory is only freed when spawning process is terminated.
Creating a ETS table is done by calling the Erlang :ets module, :ets.new/2
table = :ets.new(:my_table, [])
:ets.insert(table, {:key_1, 1})
:ets.insert(table, {:key_2, 2})
:ets.insert(table, {:key_1, 3})
:ets.lookup(table, :key_1)
:ets.lookup(table, :key_2)
ETS tables support other table types,
:set the default. One row per distinct key is allowed.
:ordered_set like :set but rows are in order via the < and > operators.
:bag Multiple rows with the same key are allowed, but two rows can’t be completely identical.
:duplicate_bag Multiple rows with the same key are allowed, even duplicated ones.
table1 = :ets.new(:some_table, [:set])
table2 = :ets.new(:some_table, [:ordered_set])
table3 = :ets.new(:some_table, [:bag])
table4 = :ets.new(:some_table, [:duplicate_bag])
An other important aspect is access permission.
:protected The default. The owner process can write/read, others can only read.
:public All processes can read/write.
:private Only the owner process can read/write
table5 = :ets.new(:some_table, [:protected, :duplicate_bag])
table6 = :ets.new(:some_table, [:public, :duplicate_bag])
table7 = :ets.new(:some_table, [:private, :duplicate_bag])
The table name is an atome, by default it serves no purpose and processes need the table pid to access it. You can create multiple tables with the same name and they will all be different.
It’s possible to use an atome as identifier for a table by passing :named_table as argument.
It’s not possible to create two identically named ETS table.
table8 = :ets.new(:my_table_named, [:private, :duplicate_bag, :named_table])
:ets.insert(:my_table_named, {:key_1, 3})
:ets.lookup(:my_table_named, :key_1)
:ets.lookup(:my_table_named, :key_2)
:ets.insert/2 to inserting new values.
:ets.delete/2 to delete values.
:ets.update_element/3 to update value.
:ets.update_counter/4 to update an integer in a row.
It’s good practive to start with GenServer and switch to an ETS table if it becomes a bottleneck.
in ets tables you can lookup for a key value by searching its key with :ets.lookup/2. sometimes you’d want to look specific values, to do that, there is :ets.match_object/2.
todo_list = :ets.new(:todo_list, [:bag])
:ets.insert(todo_list, {~D[2023-05-24], "Dentist"})
:ets.insert(todo_list, {~D[2023-05-24], "Shopping"})
:ets.insert(todo_list, {~D[2023-05-30], "Dentist"})
:ets.lookup(todo_list, ~D[2023-05-24]) #key lookup
:ets.match_object(todo_list, {:_, "Dentist"}) # value lookup with match pattern
This is not the same as patter matching, this iterates through the rows and return the matching one.
Notice the use of :_ atome instead of _, this is used when you don’t care to match the key value.
There is also :ets.match_delete/2 which deletes multiple elements at the same time.
It’s possible to make more complex filter with :ets.select/2
It’s worth noting that Erlang comes with two utilities related to ETS.
DETS disk-based Erlan Term Storage.
Mnesia database built on top of ETS and DETS.