3.1 LF by Example
Figure
2 shows an LF implementation of the deposit/withdrawal example in Figure
1(a). The diagram shown on the right uses a graphical syntax to visualize the LF program. It is automatically synthesized from the source code by the LF IDEs [
94]. The first line of the program is a
target declaration, which specifies the target language (C++ in this case). The program further specifies three
reactor classes:
Account,
User, and an anonymous
main reactor. The main reactor serves as an entry point for LF programs and is instantiated automatically at runtime. Reactor classes in LF are in many ways analogous to classes in object-oriented languages. In particular, reactor classes encapsulate state, methods, and other components; offer a form of inheritance; can be generic; and are parameterized at instantiation.
The User reactor class (lines 2 to 6) is parameterized by an offset and a value of types time and float. time is the only built-in type of LF and represents a time value. All other types are given in the target language. The timer declared on line 3 will trigger once after the given offset. Note that timers may additionally specify a period to trigger the timer repeatedly. The output port req declared on line 4 is used to send events with an associated float value to other reactors, indicating a deposit or withdrawal request.
In LF, all computation is performed in reactive code segments called reactions that are implemented in the target language. In the diagram, reactions are represented by dark gray chevrons. All reactions must explicitly declare their triggers, other dependencies, and potential effects. In line 5, the User reactor declares a reaction that is triggered by the timer t and that may produce an event on the output port req. The reaction body is given in C++ code and sets the req output based on the value that the class is parameterized with at instantiation.
The Account reactor is defined on line 7. It has a state variable balance of type float, two input ports reqA and reqB, one reaction for each input, and a method named apply. State variables and methods in LF are equivalent to protected member variables and methods in object-oriented languages. Methods are useful for sharing code within a reactor, but they cannot be invoked by other reactors. Triggering functionality in other reactors is only possible by emitting events via ports on connections, which can subsequently trigger a reaction. In addition to methods, LF also provides preambles that can be used to define shared functions and types and to insert target language imports. Preambles live in a global scope and cannot access reactor members.
The reactions on lines 11 and 12 are triggered by the
reqA or
reqB ports and attempt to apply the requested change to the balance. Reactions can access any methods, parameters, or state variables declared by the local reactor. Both reactions retrieve the value associated with the triggering event on the respective port and call the method
apply. In the C++ target, the additional dereference operator (
*) is required as all values are wrapped by a smart pointer for fine-grained access control and safe memory management. The
apply method defined on lines 13 to 17 implements the account’s business logic. If the resulting balance is non-negative, it modifies the balance accordingly and prints “Accepted.” Otherwise, it prints “Denied.”
1Note that we implemented
Account using two separate ports and reactions for the sake of simplicity. The reader might notice that the separated reactions duplicate logic and are not a practical solution, in particular if there are many users. We choose this representation to keep our exposition simple. In Section
4.2, we will introduce a syntax that enables a more compact implementation of
Account.
The main reactor assembles the program. It creates a single instance of Account (line 21) and two instances of User (lines 22, 23) and connects the outputs of the user instances to the inputs of the account instance (lines 24, 25) using the -> operator. The userA is parameterized with an offset of 1 second and a value of 20 and userB is parameterized with an offset of 2 seconds and a value of –10. When executed, the program will wait for 1 second before triggering the timer of the userA reactor and invoking the reaction on line 5. The event produced by this reaction will trigger the reaction on line 11, which is invoked immediately after the first reaction completes. Two seconds after program startup, userB will react and subsequently trigger the reaction on line 12.
In this example, the deposit event (+20.0) occurs earlier than the withdrawal event (–10.0), and hence our execution semantics ensures that the account processes the deposit event before the withdrawal event, meaning the balance will not become negative. In a more realistic implementation, the two users would generate events sporadically and have their reactions triggered not by a timer but by a physical action (see Section
3.3). However, using a timer greatly simplifies our exposition as we only have to consider a single
logical timeline along which events are ordered. Moreover, such timers can be used to create regression tests that validate program execution with specific input timings.
Note that even when the two events occur logically simultaneously, meaning that both reactions in the
Account reactor are triggered at the same logical time, the resulting program will be deterministic. All reactions at the same logical time are executed according to a well-defined precedence relation. In particular, any reactions within the same reactor are mutually exclusive and executed following the lexical declaration order of the reactions in LF code. This order is also reflected by the numbers displayed on the reactions in the diagram in Figure
2. More details on the precedence relation of reactions are given in Section
4.1. Since reactions and connections are logically instantaneous, the execution order is also preserved if any proxies are inserted, as is shown in Figure
3(a).
To deliberately change the order in which events occur, a logical delay can be introduced in the program using a
logical action, as shown in Figure
3(b) and the corresponding code in Figure
3(c). In the diagram, actions are denoted by small white triangles. In contrast to ports, which allow relaying events logically instantaneously from one reactor to another, logical actions provide a mechanism for scheduling new events at a later (logical) time. Upon receiving an input, reaction 2 of the
ProxyDelay reactor is triggered, which schedules its logical action with a configurable delay. This creates a new event, which, when processed, triggers reaction 1 of the
ProxyDelay reactor, which retrieves the original value and forwards it to its output port.
The reactor class
ProxyDelay (line 4) has a type parameter
T that denotes the type of the values of its input, output, and logical action. Using a runtime API function called
schedule, the reaction on line 9 schedules a future event (on logical action
a) with the value of the triggering input event and a given delay.
2 The reaction on line 8 is triggered by
a and simply forwards the value of the triggering event to the output port.
On line 15, the delay reactor is instantiated with a delay of 2 seconds and using the type
float. The other reactors are instantiated from the definitions given in Figure
2, which are imported on line 2. Due to the additional delay of 2 seconds, the deposit message from
userA will only be processed after the withdrawal message from
userB, causing B’s request to be denied. Since delaying messages is a common problem, LF provides a dedicated syntax for it. Instead of manually inserting a delay reactor, we can use an
after delay. For this, we remove line 15 and replace lines 16 and 17 with
userA.req -> account.reqA after 2 sec.
It is important to note that all of the discussed examples are deterministic, regardless of the physical execution times of reactions, as all events are unambiguously ordered along a single logical timeline. The physical timing of the events, on the other hand, will be approximate. The contribution of this article is to show that such determinism does not necessarily reduce performance and is also useful for applications that have no need for explicit timing.
3.2 Logical and Physical Time
All events have an associated tag. Tags are ordered along a logical timeline and can be thought of as a timestamp. Timers automatically schedule events at regular intervals relative to a start tag that is determined at startup. Reactions may use logical actions to schedule future events with a given delay relative to the current tag.
In time-sensitive applications, tags are not purely used for logical ordering but also relate to physical time. By default, the runtime only processes the events associated with a certain tag once the current physical time
T is greater than the time value of the tag
t (
\(T\gt t\) ). We say that logical time “chases” physical time. The relationship between physical and logical time in the reactor model gives logical delays a useful semantics and also permits the formulation of deadlines. This timed semantics is particularly useful for software that operates in cyber-physical systems. For a more in-depth discussion of LF’s timed semantics, the interested reader may refer to [
61].
If an application has no need for any physical time properties, the concurrence of physical and logical time can be turned off; in this case, the tags are used only to preserve determinism, not to control timing. Moreover, LF programmers are not required to explicitly control the timing aspects of their programs. Delays can simply be omitted, for instance, when scheduling an action, in which case the runtime will use the next available tag. In consequence, also untimed general-purpose programs can benefit from the deterministic concurrency enabled by LF’s timed semantics.
3.3 Asynchrony and Deliberate Nondeterminism
In the examples discussed in Section
3.1, we have hard-coded the order in which the users send requests by using timers and, thus, assigned fixed tags to the request events. While using a predefined order is useful for testing and for demonstration, reactor programs that are deployed in practice need to be able to handle sporadic asynchronous inputs in order to be useful. Concretely, in our account example we do need to handle asynchronous events that are created when users initiate withdrawal or deposit requests.
The reactor model distinguishes logical actions and physical ones. While a logical action is always scheduled synchronously with a delay relative to the current tag, a physical action may be scheduled from asynchronous contexts. Its event is assigned a tag based on the current reading of physical time. Physical actions are the key mechanism for handling sporadic inputs originating from physical processes and for introducing deliberate nondeterminism.
The assignment of tags to physical actions is nondeterministic in the sense that it is not defined by the program. However, once those tags are assigned, for example, to deposit or withdrawal requests by a user, the processing of the events is deterministic and occurs in tag order. Hence, the tags assigned to externally initiated events are considered as part of the input, and given this input, the program remains deterministic. This approach draws a clear perimeter around the deterministic and therefore testable program logic while allowing it to interact with sporadic external inputs.
Figures
4(a) and
4(c) show an implementation of our account example that uses physical actions to handle sporadic user requests. The physical action on line 5 may be scheduled from asynchronous processes outside of the LF program. It has type
float, which allows it to carry the value of the deposit or withdrawal request initiated by the user. The reaction on line 5 is triggered by the physical action and it forwards the value of the action to the output port. The
Account and
Proxy reactors remain unchanged. Consequently, they implement the same testable behavior as in our earlier examples. We only exchanged the event sources from predefined timers to physical actions to allow sporadic input. Concretely, if
userA sends a deposit message at tag
\(g_A\) and
userB sends a withdrawal message at tag
\(g_B\) with
\(g_B \gt g_A\) , then the semantics of LF guarantees that the response of the account is identical to the response in a test case that uses the same tag ordering (i.e., the behavior is identical to the program in Figure
3(a)).
Physical actions can also be used within the program itself, for example, to nondeterministically assign a new tag to a message received from another reactor. In this usage, physical actions provide a means for deliberately introducing actor-like nondeterminism into a program. For example, the program shown in Figure
4(b) reproduces the nondeterministic behavior of the actor program shown in Figure
1(c). It is created by replacing the logical connection operator
-> with the physical connection operator
∼> on lines 15 to 17. Physical connections behave similar to after delays, but instead of inserting a delay, they insert a physical action to create events with tags based on the current physical time. Thus, in Figure
4(b), the deposit and withdrawal messages are tagged nondeterministically in the order in which they arrive at the account. Consequently, the account processes the messages in the order of arrival.