In this short article I’m going to talk about how Pony gets away with not storing a stack for each of its actors. I was pretty surprised to find this out, given that in most actor model languages actors have their own stacks. For instance, in Go, Goroutines have a stack size of 2KB. Elixir/Erlang processes have a 1.2KB combined stack and heap. But surprisingly, Pony’s actors don’t have their own stacks! Instead, they use the stack of the OS thread that they’re running on.
Let’s take a step back. What even is Pony? For the purposes of this blog, all you have to know is that Pony is an Actor based programming language. That means that in Pony, you can define an actors that send messages to each other, like so: [1]
1actor Main
2 be say() => // say() is a function that can be called on the actor
3 env.out.print("Hello, world!")
4
5actor Caller
6 be callMain(main: Main) =>
7 main.say() // send the say() message to the main actor
If you’re not familiar with Actor based languages, I highly recommend you take a look into Elixir. For the rest of this post, I’m going assume you at least know what Actors and message passing are.
Moreover, although Go is not an actor based language, it is similar enough that we’re going to just pretend that Goroutines are like actors (they’re not, strictly speaking).
Example in Go and Elixir
So what do I mean when I say that Pony actors have no stacks? Let’s take an example in Go and Elixir to see where stacks are needed in those two languages.
For this post, let’s describe a very simple ping demo. We will have an main actor, that sums the numbers from 1 to 10. Then, we will pass it to actor 2, who squares that number, then returns the value to actor 1. Finally, actor 1 prints both the original and the squared numbers. A contrived example, but one that shows off what I mean to show.
In Go, here’s how we would write the code:
1func main() {
2 var x int
3 for i := 1; i <= 10; i++ {
4 x += i
5 }
6
7 // spawn a secondary Goroutine
8 resultChan := make(chan int)
9 go func() {
10 resultChan <- x * x
11 }()
12
13 // return the result to the main Goroutine
14 newVal := <-resultChan
15 fmt.Printf("Original: %d, New: %d\n", x, newVal)
16 // prints: Original: 55, New: 3025
17}
In Elixir, here’s how we would write the code: [2]
1x = Enum.sum(1..10)
2
3# Spawn a thread to compute the square
4parent = self()
5spawn fn -> send(parent, {:result, x * x}) end
6
7# Return the result to the main process
8receive do
9 {:result, newVal} ->
10 IO.puts("Original: #{x}, New: #{newVal}")
11end
12# prints: Original: 55, New: 3025
Both these languages solve this problem in quite the same way. First, the main actor does the summation. Then, the main actor spawns a worker actor to do the squaring. It then blocks to allow the second actor to do its work, and waits for the second actor to signal completion, either through a channel or through messages.
A diagram to show you what I mean
It’s the blocking operation that requires a stack. To demonstrate, let’s pretend that we’re running this application on a single CPU.
As you can see in the figure above, the main Goroutine has its own stack. At time 1, the secondary worker is spawned, which creates another stack. Then, the main Goroutine yields control to the secondary worker. The stack pointer moves to the secondary worker’s stack.
The secondary worker then does its work, and sends the newVal result over a channel back to the main goroutine. Having completed its work, the secondary worker yields control back to the main worker. The stack pointer moves to the main worker’s stack. Then, the output is printed to the screen. At this point, the secondary worker’s stack is garbage, and may be collected by the GC when it runs.
Example in Pony
However, in Pony there are no blocking operations, which is the key to not needing a stack!
In Pony, we have to write it in a different way. Here’s how we would solve the same problem in Pony.
First, we need to have a main actor, to compute the sum of 1 to 10 and spawn a worker. We do so in the constructor of the Main actor:
1actor Main
2 var x: I32 // x is a member variable
3
4 new create(env: Env) =>
5 x = 0
6 for i in Range(1, 11) do
7 x = x + i // sum 1 to 10 in x, a member variable
8 end
9
10 // spawn a squarer actor,
11 // and then call the square behavior on it
12 Squarer.square(x, this)
Notice that we don’t do anything after calling the square function on the Squarer actor! This is because there is no way to block. Instead, the squarer actor must perform the callback to print messages, which we will see in a second.
Let’s define the squarer Actor that will perform our squaring:
1actor Squarer
2 be square(x: I32, main: Main) =>
3 let newVal = x * x
4 main.printResult(newVal)
In the square function, we passed along a reference to the Main actor. To those familiar with Elixir, you might want to think of the reference as like the PID of the actor. With the PID, we can then send a message to the main Actor, to tell it to print the squared result.
Finally, let’s define printResult():
1actor Main
2 var x: I32 // x is a member variable
3 // ---- snipped ----
4
5 be printResult(newVal: I32) =>
6 _env.out.print("Original: " + x.string()
7 + ", New: " + newVal.string())
You’ll notice that we got around not having a stack by storing x in a Actor member variable. This is unlike Go and Elixir, where we can just refer to x since we have the stack frame lying around. In Pony, if you want to keep variable across asynchronous method calls, you have to explicitly store it in the Actor, which means that x is stored in the heap, and not the stack.
Let’s look at a time diagram.
- In the figure above, time 1 is just after computing the sum from 1 to 10. As you can see, there is an implicit this pointer stored on the stack, and a x member variable stored in the actor’s heap.
- Between time 1 and 2, the square behavior is called on the Squarer actor, and the constructor of the Main actor finishes. The Pony scheduler then schedules the Squarer actor to run the square behaviour. As the constructor’s stack frame is not needed, it can be popped off the thread stack (whether it actually does is an implementation detail).
- At time 2, the square behavior has finished.
- At time 3, the scheduler schedules the Main actor’s printResult() behavior. The previous stack frame can be popped off.
Not shown for clarity: the env variable, the this pointer of the Squarer actor. Also, this isn’t exactly how it works, thanks to optimizations, but it’s a good mental approximation to what happens.
Putting it together, we get:
1use "collections" // for the Range operator
2actor Main
3 let _env: Env
4 var x: I32
5
6 new create(env: Env) =>
7 _env = env // store env in a member variable
8 x = 0
9 for i in Range[I32](1, 11) do
10 x = x + i // sum 1 to 10 in x, a member variable
11 end
12
13 // spawn a squarer actor,
14 // and then call the square behavior on it
15 Squarer.square(x, this)
16
17 be printResult(newVal: I32) =>
18 _env.out.print("Original: " + x.string()
19 + ", New: " + newVal.string())
20
21actor Squarer
22 be square(x: I32, main: Main) =>
23 let newVal = x * x
24 main.printResult(newVal)
I’ve tried to keep this example as simple as possible for those new to Pony. Personally, I wouldn’t implement it this way, I would use Promises. In the footnotes I’ve also implemented this function in two other ways, to show you how it can be done. [3]
Conclusion
All in all, we learnt that Pony actors don’t have their own stack, instead leveraging the regular OS thread’s stack. This has a performance benefit because now, stack frames don’t have to be preserved when switching between actors. Also, memory doesn’t have to be wasted for actors not using their stack.
However, it does come with a few downsides. You still have to store variables somewhere, so now they go in the heap instead. This isn’t too bad, since in an Actor model system your “stack” is really on the heap.
More significantly, programming without blocking operations is really painful. It reminds me of early Javascript before async-await: You had to program everything with callbacks and promises. Functions that should have been one long block were split into multiple blocks.
Pony does have promises, which helps to prevent callback hell, but as JS programmers know, promises just aren’t as convenient as async await.
I wonder if it’s possible for the Pony compiler to implement Javascript-style async-await, since Pony already has closures and promises.
Notice anything wrong? Edit this page on Github
Footnotes
- Yes, this is not valid Pony code. For those watching closely, the main actor has no constructor, so env cannot be used. Moreover, these are not functions, but actually asynchronous behaviors. That said, I didn’t want to confuse anyone not familiar with Pony code.
- I’m no Elixir expert, so I would appreciate any feedback! I wanted to showcase a pure Actor model ping/pong, without any use of Task abstractions, which would probably be more useful for something like this.
- For fun, I’ve rewritten the Pony example using (a) callbacks, and (b) promises.
Using callbacks
1use "collections" // for the Range operator
2actor Main
3 let _env: Env
4 var x: I32
5
6 new create(env: Env) =>
7 _env = env // store env into a member variable
8 x = 0
9 for i in Range[I32](1, 11) do
10 x = x + i // sum 1 to 10 in x, a member variable
11 end
12
13 // spawn a squarer actor,
14 // and then call the square behavior on it.
15 // this~printResult() creates a closure which captures
16 // the local variables _env and x.
17 // "recover" is needed to tell the compiler to turn the
18 // closure into a sendable object (in this case, an iso)
19 Squarer.square(x, recover this~printResult() end)
20
21 be printResult(newVal: I32) =>
22 _env.out.print("Original: " + x.string()
23 + ", New: " + newVal.string())
24
25actor Squarer
26 be square(x: I32, callback: {(I32): None} val) =>
27 let newVal = x * x
28 callback(newVal)
Using promises and partial application
1use "collections" // for the Range operator
2use "promises"
3
4actor Main
5 new create(env: Env) =>
6 var x: I32 = 0 // x is now a local
7 for i in Range[I32](1, 11) do
8 x = x + i
9 end
10
11 // create a promise that takes as input the squared num
12 // then will print to stdout
13 let p = Promise[I32]
14 p.next[None](recover this~printResult(env, x) end)
15
16 // spawn a squarer actor,
17 // and then call the square behavior on it
18 Squarer.square(x, p)
19
20 be printResult(env: Env, x: I32, newVal: I32) =>
21 env.out.print("Original: " + x.string()
22 + ", New: " + newVal.string())
23
24actor Squarer
25 be square(x: I32, p: Promise[I32]) =>
26 let newVal = x * x
27 p(newVal) // fulfil the promise