
This is the 3th post in this series on the 0.3 version of the Merg-E language spec. The previous posts are available here.
- part 1 : coding style, files, merging, scoping, name resolution and synchronisation
- part 2 : reverse markdown for documentation
In this post we look at a subject that is syntactically simple, but still is a bit involved in the Merg-E kanguage: Actors
In part one of this series we already discussed the lang.callable.function and the lang.callable.merge constructs. lets look at them once more:
mutable function report (count int)::{cout: cout, endl: endl}{
cout "counted " ok_count " prime numbers" endl;
};
and
reentrant mutable merge utils.is_prime as is_prime(x int)::{ok_count: ok_count}{max_prime: max_prime}@[range_error];
The "actor" function/merge modifier
Both of these will turn the named element (report and is_prime respectively in these examples) into callables.
We can add the modifier lang.callable.actor (name resolution should have it available as just actor) to these expressions, what is syntactically simple enough but has important concequences:
mutable actor function report (count int)::{cout: cout, endl: endl}{
cout "counted " ok_count " prime numbers" endl;
};
and
reentrant mutable actor merge utils.is_prime as is_prime(x int)::{ok_count: ok_count}{max_prime: max_prime}@[range_error];
One important thing to note is that actor should imediately precede function or merge. While other modifiers are position interchangeable, actor is not. That is because actor turns the resulting callable into an actor and any preceding modifier pertains to that actor, that is distinct from the non-actor callable.
Differences actors vs non-actor callables
So how does a non-actor callable differ from an actor callable? Well, in two different but repated ways: Scope lifetime and execution environment persistence.
| non-actor | actor | |
|---|---|---|
| scope/state | new scope/state created every call | scope/state persists across calls |
| execution | execution can run in any execution scope | execution is locked to a single execution scope |
| parallelism | consecutive calls may run in parallel | consecutive calls will run in sequence |
| function argument borrows | short lifetime, borrows encouraged | short lifetime, borrows encouraged |
| explicit closure borrows | short lifetime, borrows encouraged | close to application lifetime, don't use |
Because there is no syntactical difference between a def that is meant to be instantiated as an actor, a def that is meant to be instantiated as a non-actor callable, and a def that is meant to be able to do both, at least not untill you dive into the implementation, it is important to document intended def usage in a module and to use asserts taht will raise a lang.type.exception.assert_error:
reentrant mutable def is_prime = (x int)::{ok_count: int}{max_prime: int}@[range_error, assert_error] {
```devdoc
# is_prime
This **non-actor** callable determines if *x* is a prime.
The *x* parameter should not exceed *max_prime* or a range_error will be thrown.
Don't merge this callable into an actor os an assert_error will be thrown on invocation.
```
lang.assert.isfalse scope.is_actor;
...
}
The above asserts that the invocation took place in an non-actor callable context, and will raise an assert_error when used as an actor. The included reverse markdown documentation tells the user what to expect.
borrows
This is an importantly tricky part of Merg-E. To understand how borrows interact with callables we need to understand the lifetime of the scope that is available during invocation. In a non-actor callable, the scope lifetime ends either at the moment that the end of the execution is not awaited by anything, or at the moment that the await resolves within the execution contect where it is awaited. For an actor though, the callable maintains it's scope untill
the callable itself goes out of scope because the parent callable scope has it's lifetime end, what in most well designed Merg-E system shouldn't happen untill there is some kind of shutdown, partial restart, or re-orgestration. You should assume, when it comes to scope that an actor scope could live forever, and with it any borrowed mutable bound to that scope won't be given back.
Not all borrowed mutables are bound to the scope of the actor. If a mutable is borrowed through explicit capturing from the closure, it gets linked into the actor scope untill the end of the actor lifetime, what might never come. If however it is borrowed as invocation argument, then we are back to either the end of the invocation code if that ending wasn't made awaitable, or the moment the await resolves if it was.
Introducing pools
One drawback of actors is while they are long lived, they aren't as paralel as non-actor callables can be, at least not by default. To improve parelelism a bit though, we can create an actor pool. One thing though that we need to remember that Merg-E is a No assumptions about parallelism language. So we may try to define a pool of actors, the Merg-E runtime might need to decide otherwise.
mutable pool<16> function report (count int)::{cout: cout, endl: endl}{
cout "counted " ok_count " prime numbers" endl;
};
or if we are using merge:
reentrant mutable pool<8> merge utils.is_prime as is_prime(x int)::{ok_count: ok_count}{max_prime: max_prime}@[range_error];
Note the <> notation to indicate the pool size. A pool is basicly a collection of actors that share an internal message box. An invocation of a pool will add all invocation context to that message box, so invocations will get handled by one of the non-active actors in roughly a round robbing way.
Coming up
In this post we discussed non-actor callables versus actors, and we discussed actor pools. The differences are subtle and the concequences of these differences can make a big difference in wether or not the code will work as expected. Especialy the working of borrows might be surprising. I'll need a few more posts to talk about things like freezing, attenuation and decomposition, parallelism models, operators, capability patterns, and a few more.