Accessing an Actor with Arbitrary Transactions

Problem

With asynchronous APIs on actors, we have the problem of not being able to combine multiple asynchronous calls to form an atomic operation, as messages to an actor may be interleaved. This means that any operation that isn't exposed as a single behaviour on the actor cannot be done in an atomic way, which puts a pressure on our API to provide "ancillary" behaviours that are just combinations and compositions of the "fundamental" behaviours. We'd prefer to solve this problem in a way that alleviated that pressure without creating a larger API surface to maintain, and without having to do the constant guesswork of imagining which combinations and compositions will be needed.

Let's say we want to create a simple set of named registers which can each hold an integer value, and that we want to be able to share read/write access to these registers among multiple actors in our application. So, we declare an actor type named SharedRegisters which holds a map of string register names to integer values and provides access to this register data via read and write behaviours.

use collections = "collections"

actor SharedRegisters
  let _data: collections.Map[String, I64] = _data.create()

  be write(name: String, value: I64) =>
    """
    Write to the named register, setting its value to the given value.
    """
    _data(name) = value

  be read(name: String, fn: {(I64)} val) =>
    """
    Read the value of the named register and pass it to the given function.
    A register which has never been written to will have a value of zero.
    """
    fn(try _data(name)? else 0 end)

We can then write a basic program that writes to a few registers and reads from them.

actor Main
  new create(env: Env) =>
    let reg = SharedRegisters
    let out = env.out

    reg.write("x", 99)
    reg.write("y", 100)

    reg.read("x", {(value: I64)(out) =>
      out.print("The value of x is " + value.string())
    } val)

    reg.read("y", {(value: I64)(out) =>
      out.print("The value of y is " + value.string())
    } val)

Because of Pony's causal messaging, we can expect this program to perform those operations in the written order, printing the following:

The value of x is 99
The value of y is 100

Now, let's say we want to do some higher-level operations on these registers, and we want to do them from multiple independent actors. To demonstrate a basic example, we'll declare a Mathematician actor with an increment behaviour that does a read operation followed by a write operation.

actor Mathematician
  let _reg: SharedRegisters
  let _out: StdStream
  new create(reg: SharedRegisters, out: StdStream) =>
    _reg = reg
    _out = out

  be increment(name: String) =>
    """
    Read the value of the named register, then write the incremented value.
    """
    let reg = _reg
    let out = _out

    reg.read(name, {(value: I64)(reg, out, name) =>
      let new_value = value + 1
      reg.write(name, new_value)
      out.print("Incremented " + name + " to " + new_value.string())
    } val)

However, there's a problem with this in the context of concurrent access to the same SharedRegisters. What if some other write operation changed the value of the register between the read operation and write operation that make up our increment? We can try to experiment with this situation by creating many Mathematicians and having them concurrently increment the same register.

use collections = "collections"

actor Main
  new create(env: Env) =>
    let reg = SharedRegisters
    let out = env.out

    reg.write("x", 99)

    for i in collections.Range(0, 10) do
      Mathematician(reg, out).increment("x")
    end

Sure enough, when we run this program, we see that our increment will only sometimes increase the observed value, with the results varying based on how the concurrent operations happen to line up in any given execution.

Incremented x to 100
Incremented x to 101
Incremented x to 101
Incremented x to 102
Incremented x to 103
Incremented x to 103
Incremented x to 104
Incremented x to 104
Incremented x to 104
Incremented x to 104

In Pony, an actor's behaviour acts like a single atomic transaction over the actor's internal state - it cannot be interrupted with other behaviours. However, when a conceptual operation spans multiple behaviours (like a read followed by a write, we can get into trouble because those transactions can be interleaved with others.

We could try defining increment as a behaviour of the SharedRegisters actor, such that it would be one atomic transaction. However, we'd soon find ourselves wanting to do other multi-operation transactions, and we might find our SharedRegisters actor to be getting a bit bloated if we added all of them as behaviours.

Furthermore, if the SharedRegisters were part of a library package maintained separately from the applications using it, the maintainer would be hard-pressed to imagine all of the multi-operation transactions that might be useful to the application developers. A more general solution that doesn't require the library developer to worry about this kind of guesswork would be desirable.

Solution

Essentially, we want to provide a way for the caller to define a custom transaction, then execute it atomically within a single behaviour of the actor. This part is simple enough - the caller can pass a lambda, and the actor can execute it directly, just as we did before with the read behaviour when we passed the value to the caller's function.

However, unlike in the read behaviour, we also need the transaction lambda passed to the access behaviour to have exclusive synchronous access to perform arbitrary combinations of operations on the actor's state. In other words, we need the lambda to see the actor as the actor sees itself, instead of how it is seen from the outside.

Luckily, Pony has exactly the concepts we need to implement this idea. An actor is always seen from the outside as a tag (an opaque reference that you can only send messages to), but an actor is seen from the inside as a ref (by default, behaviours have read and write access to the actor's state). Because the transaction lambda will be executed inside the actor, we can pass the non-sendable ref reference to the actor itself as the argument, giving the transaction exclusive synchronous read/write access through that reference.

Let's take a look at a reworked implementation of SharedRegisters that includes an access behaviour as well as some synchronous versions of the read and write behaviours for use in the custom transactions.

actor SharedRegisters
  let _data: collections.Map[String, I64] = _data.create()

  be access(fn: {(SharedRegisters ref)} val) =>
    fn(this)

  be write(name: String, value: I64) =>
    write_now(name, value)

  be read(name: String, fn: {(I64)} val) =>
    fn(read_now(name))

  fun ref write_now(name: String, value: I64) =>
    _data(name) = value

  fun ref read_now(name: String): I64 =>
    try _data(name)? else 0 end

Note that the safety of access to the synchronous methods is guaranteed and protected by the Pony type system - any non-exclusive use of the synchronous methods is prevented, simply by keeping the ref from leaking outside of the actor. Note also that in this paradigm, the read and write behaviours are just asynchronous wrappers for their synchronous counterparts, read_now and write_now.

Now let's take a look at a revised implementation of Mathematician that uses the access behaviour to combine these synchronous operations into one atomic increment transaction:

actor Mathematician
  let _reg: SharedRegisters
  let _out: StdStream
  new create(reg: SharedRegisters, out: StdStream) =>
    _reg = reg
    _out = out

  be increment(name: String) =>
    """
    Read the value of the named register, then write the incremented value.
    """
    let reg = _reg
    let out = _out

    reg.access({(reg: SharedRegisters ref)(out, name) =>
      let new_value = reg.read_now(name) + 1
      reg.write_now(name, new_value)
      out.print("Incremented " + name + " to " + new_value.string())
    } val)

Sure enough, our example program using 10 concurrent Mathematicians to increment the same register will now give the expected output, with each increment transaction guaranteed to be atomic.

Incremented x to 100
Incremented x to 101
Incremented x to 102
Incremented x to 103
Incremented x to 104
Incremented x to 105
Incremented x to 106
Incremented x to 107
Incremented x to 108
Incremented x to 109

Discussion

Using the "access pattern" in Pony, we can create actors that provide not only asynchronous APIs but also synchronous APIs, which can be used together in transactions that are defined and passed in by the caller. This dramatically enhances the realm of possible interactions with a service actor, whose synchronous API can provide the fundamental building blocks from which the caller can build arbitrarily complex atomic transactions.

The accessed actor can control the scope of what is possible in a transaction by controlling the reference capability of the reference that is passed to the transaction lambda. For example, we could choose to pass box instead of a ref if we wanted to provide read-only access to the transaction.

Even though we said that the transaction lambda is seeing the actor "as it sees itself", the accessed actor can still hide implementation details in the same way as any other class - by making those fields and methods private. The private fields and methods will not be accessible from within the transaction, so the implementation details can remain protected and hidden from the API surface.

results matching ""

    No results matching ""