Why Actors in Gleam?
Gleam is a language that does a lot, while still remaining very simple. It's combination of simple syntax with the ability to call into Erlang and JavaScript translates into a superpower of highly readable code that can build on top the features of Erlang and JavaScript. Gleam is rather popular for its JavaScript interop and the Lustre framework provides a very strong case for Gleam as a frontend framework language. However I was drawn to Gleam because of its ability to build on top of Erlang, the BEAM, and OTPThe BEAM is the virtural machine which runs the Erlang bytecode and OTP is a set of libraries which power Erlang's async behavior..
Gleam describes itself as "a friendly language for building type-safe systems that scale!" That last word, scale, is the primary driver behind my interest in Gleam, and the primary driver behind the scalability is OTP.
This series largely assumes you understand the basics of Gleam and the Actor modelIf you're coming from another language all you really need to know is that actors are each an Erlang Process which is more akin to green threads, or fibers, or goroutines and so on. They aren't 1 to 1 with OS processes; the BEAM VM manages them.. It's going to focus a bit more on OTP, Erlang, and how you can use these tools in your Gleam project.
Most of the information comes from learnings building a simple agile pointing web app. It's about as simple as a multiplayer web app can get and is something I'd wanted to build for some time. Gleam typesaftey and Erlang's asynchronous toolkit felt like a natural fit.
The Simplest Actor
At their core Actors in Gleam are functions which receive state and a message, manipulate the state based on the message, and finish by calling actor.continue(new_state)
. Gleam's OTP and Erlang packages take care of most of the complex work behind the scenes to tie into Erlang. To build a very simple actor all you need is:
- An initial state value, in this case 0
- A handle_message function which mutates the state on every message handled
- A message type which both contains the data to be sent, and information about what to do with it
A simple counter actor could look like:
import gleam/otp/actor
import gleam/erlang/process
pub fn main() {
let assert Ok(actor) =
actor.new(0) |> actor.on_message(handle_message) |> actor.start
// The data property contains the value we can send messages to
let counter = actor.data
process.send(counter, CountUp)
process.send(counter, CountDown)
process.send(counter, CountUp)
// At this point the actor's internal state would be equal to 1
}
// This message tells the actor what to do
type Message {
CountUp
CountDown
}
fn handle_message(state: Int, message: Message) {
let new_state = case message {
CountUp -> state + 1
CountDown -> state - 1
}
actor.continue(new_state)
}
Sending Messages
Processes in the BEAM VM are fully isolated from each other and as we saw above communicate by sending messages. Each process has a "mailbox" where the process recieves messages and can choose to act upon them. In Erlang sending a message is such a first class thing that the !
operator is used. process_id ! message
will send basically anything to any process. However Gleam doesn't have it as easy as all messages must be fully typed.
One of the the major selling points of Gleam is how strongly typed it is. While types are very helpful for catching errors at compile time and help aid development it does raise the bar for sending messages. The messages sent to a Gleam actor must respect the type expected by the actor's handle_message
function. In order to fully type check every message Gleam uses a type called Subject
. These subjects are what enforce the typing of messages.
Subjects
At their core Subjects are a pid coupled with the type of message they can be sent. A Subject is said to be "owned" by the process who's pid they contain. Creating a Subject will always give you a Subject which represents the process which called process.new_subject()
. From there you can send messages to that Subject.
The typesafety comes from the use a generic message
type which the Subject contains and all of the functions we've been using reference:
pub fn send(subject: Subject(message), message: message) -> Nil
pub fn call(subject: Subject(message), waiting timeout: Int, sending make_request: fn(Subject(reply)) -> message) -> reply
This does add a layer of complexity to working with actors. It can be frustrating to have to make sure that you've handled the edge cases, and that you've got a correctly typed Subject an actor in scope. However it does dramatically reduce the possibility of runtime errors. Gleam's excellent pattern matching functionality via case
statements and the typing of messages make it easy to exhaustively handle every possible message. This means your actors will almost never panic as a result of receiving a message. It's also just really nice to work with because it provides guide rails for your code.
Sending Data
Gleam's record types provide us with a simple way to send data to actors. If we want to add the ability to count up any amount we can add a CountX(Int)
record constructor to our Message
type:
type Message {
CountUp
CountDown
CountX(Int)
}
fn handle_message(state: Int, message: Message) {
let new_state = case message {
CountUp -> state + 1
CountDown -> state - 1
CountX(x) -> state + x
}
actor.continue(new_state)
}
Getting information back
The above actor isn't that useful, even as just a counter, because currently there's no way to get its count. This is where process.call
comes in Technically there's also get_state
in gleam_otp but this is just for debugging and testing purposes . This allows you to specify a Subject
to reply to. You can then call process.call
within your handle_message
to send any Gleam valueAs a note process.call
handles the creation of a reply Subject for you so you just provide it with a record constructor, including a record type back to the calling function. Adding a GetCount
message which responds with an Int
:
type Message {
CountUp
CountDown
CountX(Int)
GetCount(Subject(Int))
}
fn handle_message(state: Int, message: Message) {
let new_state = case message {
CountUp -> state + 1
CountDown -> state - 1
CountX(x) -> state + x
GetCount(reply) -> {
process.send(reply, state)
state
}
}
actor.continue(new_state)
}
pub fn main() {
let assert Ok(actor) =
actor.new(0) |> actor.on_message(handle_message) |> actor.start
let counter = actor.data
process.send(counter, CountUp)
process.send(counter, CountDown)
process.send(counter, CountUp)
let count = process.call(counter, 10, GetCount)
}
Names
Most Subjects point to a process' ID. This is useful if you're explicitly aware of the process and don't expect the ID to ever change or maybe if you're spinning up a process for a single task. However if you're expecting to be able to continually reference "the same Actor" regardless of what happens to it you'll need to use a Name.
OTP supports naming processes. These names are 1 to 1 with pids and provide a way to send messages to that process without having to know its id.
Note: it is important to never generate names dynamically at runtime as they are atoms which means there's a maximum possible number.
Named Subjects
Naming an actor is trivial with actor.named
. A name must be generated first, and then through the magic of type parameters you can create a named subject that's fully typesafe using the same name. We can then create as many subjects as we want which communicate with that same actor.
pub fn main() {
let counter_name = process.new_name("counter")
let assert Ok(_) = actor.new(0)
|> actor.on_message(handle_message)
|> actor.named(counter_name)
|> actor.start
process.send(process.named_subject(counter_name), CountUp)
process.send(process.named_subject(counter_name), CountDown)
process.send(process.named_subject(counter_name), CountUp)
}
Supervisors
If you've heard anything about Erlang or Elixir you've probably heard that one of their best features is that they are resilient to crashes. This resilience comes from its ability to restart processes which have crashed. Like Erlang, Gleam provides this power through the use of Supervisor trees. There's a ton of resources on how to design these and use them effectively. Most of what's been written is applicable to Gleam. I'd recommend starting with Learn You Some Erlang's chapter on Supervisors.
When working with supervisors in Gleam you need to do two things:
- Pick a restart
Strategy
- Provide
ChildSpecification
s
The Strategy
is one of: OneForOne
, OneForAll
, and RestForOne
and controls what the supervisor does when an actor crashes. The chapter linked above describes these. Most of the time you want OneForOne
but the others can be useful.
ChildSpecification
The ChildSpecification
type, and name, can look a bit intimidating but there's a helper function, supervisor.worker
, which solves for the average case. Basically all it does is return a ChildSpecification
with your actor's start and reasonable defaults. There's a lot of option for customization if you need it. In most cases though I'd recommend starting with supervisor.worker
and tinkering with the default if you find you need.
As a contrived example for how to use these let's create a supervisor and kill our counter.
fn start_counter(name: Name(Message)) {
fn() {
let assert Ok(_) = actor.new(0)
|> actor.on_message(handle_message)
|> actor.named(name)
|> actor.start
}
}
pub fn main() {
let counter_name = process.new_name("counter")
let assert Ok(_) = supervisor.new(supervisor.OneForOne)
|> supervisor.add(supervision.worker(start_counter(counter_name)))
|> supervisor.start
let counter_subject = process.named_subject(counter_name)
process.send(counter_subject, CountUp)
process.send(counter_subject, CountDown)
let assert Ok(current_pid) = process.subject_owner(counter_subject)
echo current_pid
process.kill(current_pid)
// It takes a moment for the supervisor to restart the process
process.sleep(500)
// Note we can use the same subject because we're referencing a name, not a pid
process.send(counter_subject, CountUp)
let assert Ok(current_pid) = process.subject_owner(counter_subject)
// This pid will be different from the above pid
echo current_pid
}