A semantics comparison workbench for a concurrent, asynchronous, distributed programming language

Supplementary website

Chris Poskitt, Alexander Heussner, Claudio Corrodi

Table of Contents

Throughout the paper, we use the example of the dining philosophers to illustrate the workbench and toolchain. Due to space constraints, the presentation is limited to a few select features of both. Here, we aim to cover the example in more detail by providing additional code snippets, graphs from the Scoop-GTS.

Note that some of the text here is copied verbatim from the paper, however, we will not further indicate those sections here.

SCOOP Implementation

The following SCOOP code implements the dining philosophers as presented in the paper.

The main application that initializes problem instance is implemented in the class APPLICATION:

class
    APPLICATION

create
    make

feature -- Initialisation

    i: INTEGER
    first_fork, left_fork, right_fork: separate FORK
    a_philosopher: separate PHILOSOPHER

    make
            -- Create philosophers and forks
            -- and initiate the dinner.
        do
            philosopher_count := 2
            round_count := 1

            -- Dining Philosophers with `philosopher_count' philosophers and `round_count' rounds.

            from
                i := 1
                create first_fork.make
                left_fork := first_fork
            until
                i > philosopher_count
            loop
                if i < philosopher_count then
                    create right_fork.make
                else
                    right_fork := first_fork
                end
                create a_philosopher.make (i, left_fork, right_fork, round_count)
                launch_philosopher (a_philosopher)
                left_fork := right_fork
                i := i + 1
            end
        end

feature {NONE} -- Implementation

    philosopher_count: INTEGER
            -- Number of philosophers.

    round_count: INTEGER
            -- Number of times each philosopher should eat.

    launch_philosopher (philosopher: separate PHILOSOPHER)
            -- Launch a_philosopher.
        do
            philosopher.live
        end

end

The philosophers’ behaviour is implemented in the class PHILOSOPHER:

class
    PHILOSOPHER

create
    make

feature -- Initialisation

    make (philosopher: INTEGER; left, right: separate FORK; round_count: INTEGER)
            -- Initialise with ID of `philosopher', forks `left' and `right', and for `round_count' times to eat.
        require
            valid_id: philosopher > 0
            valid_times_to_eat: round_count > 0
        do
            id := philosopher
            left_fork := left
            right_fork := right
            times_to_eat := round_count
        ensure
            id_set: id = philosopher
            left_fork_set: left_fork = left
            right_fork_set: right_fork = right
            times_to_eat_set: times_to_eat = round_count
        end

feature -- Access

    id: INTEGER
            -- Philosopher's id.

feature -- Measurement

    times_to_eat: INTEGER
            -- How many times does it remain for the philosopher to eat?

feature -- Basic operations

    eat (left, right: separate FORK)
            -- Eat, having acquired `left' and `right' forks.
        do
            -- Eating takes place.
        ensure
            has_eaten: true
        end

    live
        do
            from
            until
                times_to_eat < 1
            loop
                -- Philosopher `Current.id' waiting for forks.
                eat (left_fork, right_fork)
                --bad_eat
                -- Philosopher `Current.id' has eaten.
                times_to_eat := times_to_eat - 1
            end
        end

    bad_eat
            -- Eat, by first picking up `left_fork' (and picking up `right_fork'
            -- in the `pickup_left' call.
        do
            pickup_left (left_fork)
        end

    pickup_left (left: separate FORK)
            -- After having picked up `left', proceed to pick up `right_fork'.
        do
            pickup_right (right_fork)
        end

    pickup_right (right: separate FORK)
            -- Both forks have been acquired at this point.
        do
            -- eating takes place
        end

feature {NONE} -- Access

    left_fork: separate FORK
            -- Left fork used for eating.   

    right_fork: separate FORK
            -- Right fork used for eating.

invariant
    valid_id: id >= 1

end

Finally, the forks are represented as instances of the trivial class FORK; they do not have any logic associated, but are used as separate objects in philosopher arguments. Consequently, the SCOOP runtime will ensure that a fork can be held by a single philosopher at a time. The implementation is as follows:

class
    FORK

create
    make

feature -- Initialisation

    make
        do
        end

end

A SCOOP-Graph program represenatation

In a first step, we translate the source code to start graph, which mainly consists of a collection of control-flow graphs, each representing a method in the source code. For example, the generated control-flow subgraph can be seen in the following figure.

Figure: Control-flow-graph for pickup_left
Figure: Control-flow-graph for pickup_left

In this example, there are two State nodes (one InitialState and one FinalState node), as well as a single Command node. The ActionCommand corresponds to the source code line Current.pickup_right (right_fork); likewise, other actions such as ActionAssignment or ActionPreconditionTest correspond to the source code abstractions. With this close mapping to the source language, the control-flow-graphs remain easy to understand and intuitive even for SCOOP programmers unfamiliar with graph transformation systems.

Representing runtime state

In SCOOP-GTS, there is initally a single node indication the root procedure, i.e., the method that is executed at the beginning of program execution (unlike other languages like C, there is no convention for a program entry point; instead, any method can be selected by instructing the compiler accordingly). Together with Object Templates, i.e., graphs containing class name and attribute names, the runtime state can be initialized. The following figure shows the root configuration node and the template for the APPLICATION class.

In the QoQ semantics, the rule named rule_scoop_qoq_initialize_root is used
to initialize the runtime state representation, shown below.

Here, the rule matches the Initialization node which is then deleted upon rule application (indicated by coloring node and edges blue). By matching the root_class edge to the ObjectTemplate with the same name, the rule can create a new Object node (nodes and edges added during application are colored in green). Using a universal quantifier (∀), the rule matches all attributes connected to the template and thus we can create object-local copies. Since this rule is used in the QoQ semantics, the rule also creates the first Processor, as well as a representation of Memory state and an empty queue of queues (QoQ) node. Finally, the processor’s current state is set to the InitialState node of the root procedure.

After this setup, the system is initialized with a root object and a root processor that is about to execute the root procedure. Similar rules exist for the RQ and DSCOOP semantics.

Controlling execution through control programs

The Scoop-Gts consists of a large number of rules (in the source code, each .gpr file corresponds to one rule) in various categories like evaluation of references, cleaning up nodes and edges that are not used anymore, or moving a processor’s execution along the control flow graph. If one simply non-deterministically applies a rule that matches, one has to introduce helper nodes and edges in the graph and rules in order to force a certain execution. For example, a Scheduling node could be introduced to indicate that one of the scheduling rules (such as scoop_qoq_scheduling_action_command_remote_empty, which creates a separate request for a command action) can now be applied. Then, each scheduling rule has a Scheduling node, meaning that only if this node is present the rule can be applied, and each other rule has a negative-application condition for Scheduling, meaning that when the state graph indicates that execution is in the scheduling process, no other rule is applied.

With more and more rules, however, controlling the execution order with (negative) application conditions can be hard to track and results in the introduction of many helper nodes and edges. Furthermore, optimizations that reduce the state space (e.g., by forcing a particular processor the execute local computations as long as possible before handing control to the next processor) are difficult to introduce.

Fortunately, Groove provides so-called control programs, which can be thought of as a set of functions (called recipes) that dictate the order in which rules are applied. A simple example (unrelated to the actual implementation) is as follows:

while (no_error) {
  alap execute_action;
}

recipe execute_action() {
  execute_command_local |
  execute_command_separate |
  ...;
}

Here, the main loop would execute as long as a rule no_error can be applied. In the body, we execute the recipe as long as possible (alap keyword), which in turn applies one of the stated rules nondeterministically (a | b is a nondeterministic choice).

Using control programs helps us to keep rules simple. For example, without control programs, the above rules execute_command_local and execute_command_separate would need to contain application conditions that test whether there is an error or not. With the control program, this can be avoided.

Another advantage is that control programs can be used to force certain interleavings in the state-space where exploring all possibilities is not relevant to the runtime. For example, suppose we have three garbage collection rules that leave the graph in the same state when applied as long as possible. Without a control program, we may end up exploring all different possibilities, just to arrive in the exact same state. In contrast, if we write a control program that applies the rules as long as possible in sequence (that is, alap { first; second; third; }), only a single execution trace is generated.

One major use of the above example in Scoop-Gts is that we can force processors to execute local computations (i.e., computations that do not involve data guarded by other processors) as long as possible before yielding control to the next one. This way, we considerably decrease the branching where it does not matter (in the sense that the asynchronous or distributed behaviour is influenced).

In the case of Scoop-Gts, the control programs are more complex. A simplified version is given in the paper in Listing 5; in the source code, they are stored as .gcp files, and they can be edited in the Groove UI.

Detecting a deadlock

After translating a SCOOP program to a Scoop-Gts graph and selecting the runtime, the Groove Simulator or our command-line wrapper can be used to explore the state space of the program under the selected runtime.

In order to detect discrepancies and errors, we can define rules that are periodically tested during state space exploration. For example, a rule that detects a deadlock involving two processors looks as follows.

Figure: Deadlock detection rule for 2 processors under the RQ semantics
Figure: Deadlock detection rule for 2 processors under the RQ semantics

Whenever such a rule matches during state space exploration, this means that under the given semantics, the error may occur. In this example, we then create a DeadlockError node, which is an Error node. Whenever one such node exists, the control program finishes, meaning that such a state is a final state in the generate state space. If there are no errors and execution finishes, final states are graphs where no processor is executing any method anymore, and all that remains are the processor, memory, and queue subgraphs, none of which is connected to a method control-flow graph.

Once the Groove exploration finishes, we can query the final states and inspect them. Since the graphs are saved as simple XML files, we can easily parse and look for Error nodes, such as ErrorDeadlock. If there is such a node, we report it accordingly. While currently not implemented in our tool, the closeness of a Scoop-Gts program representation and the source code, it is straightforward to map errors back to relevant locations in the source code.