Lesson 5: Increasing Concurrency¶
In the previous lesson, we modified ValveControllerImpl
in an attempt to prevent it blocking its client while the valve is
moving in order to comply with requirement R3. Our solution only partly meets this goal. To illustrate where our
current solution fails, consider the following Coco snippet of a component implementation of a client using
our ValveControllerImpl
component:
state Empty { client.startFillingTank() = { valve.open(); setNextState(Filling); } } state Filling { client.isValveMoving() = !(valve.isOpen() || valve.isClosed()) }
In state Filling
, the client could be blocked when executing the transition for isValveMoving()
, depending on
whether or not the valve finished moving when isValveMoving()
was called. This is because while the valve is moving,
the ValveControllerImpl
is in an execution state
. If the client attempts a call on ValveControllerImpl
while
it is in an execution state
, the client will become blocked until the ValveControllerImpl
enters an
event state
.
The current implementation of the ValveControllerImpl
therefore does not guarantee that isOpen()
and
isClosed()
are always non-blocking, and so requirement R3 is not met.
In this lesson, we will build a solution in which the required non-blocking property of the valve controller is always preserved, and the client can always remain responsive.
We will be introducing the following new language features:
- Nested states.
- Component queues and handling incoming signals from required ports.
- The built-in variable
nextState
.
Extending the Architecture¶
We could make the ValveHALBase
component more complex to solve the asynchronies there. However, this goes against
our objective of keeping ValveHALBase
as a simple, handwritten component that makes the hardware/software interface
accessible to the Coco components. Instead, we are going to define a new level of abstraction called the ValveDriverImpl
with the goal of making a uniform interface defined by the ValveDriver
port, which hides as many differences between
different hardware valve types as possible.
The revised architecture is as follows:
In addition to the existing ValveControllerImpl
component, we have now introduced the following two new components:
ValveDriverImpl
is a driver module which abstracts away valve-specific characteristics behind a common interface. The existence of this component, and its provided port are invisible to the client application.ValveGroup
is an encapsulating component which consists of aValveControllerImpl
and aValveDriverImpl
, and implementsValveController
as its provided port. It appears as a single opaque component to other components using it, which it interacts with via its provided port client.
We will abstract away from the details of whether or not the physical valve movement is synchronous or asynchronous, and
the way in which the end of the valve movement is determined. This satisfies the requirement R4, and allows a
single implementation of the ValveControllerImpl
to work with more valve types, each of which potentially has its own,
type-specific driver.
Step 1: Creating the ValveDriver
Port¶
We start by creating a new module called ValveDriver
as follows:
import unqualified ValveHAL
port ValveDriver {
enum Status {
case Unknown
case Opened
case Closed
case Moving
case Error
}
function setup(settings : ValveParameters) : Bool
function open() : Nil
function close() : Nil
outgoing signal endOfMovement(result : Status)
machine {
// Initial position unknown
var status : Status = .Unknown
state Initial {
setup(_) = nondet {
ValidParameters:
{
setNextState(Ready);
true
},
InvalidParameters: false,
}
}
state Ready {
open() = setNextState(Moving(.Opened))
close() = setNextState(Moving(.Closed))
}
state Moving(target : Status) {
spontaneous = {
status = nondet {
MoveSucceeded: target,
MoveFailed: .Error,
};
endOfMovement(status);
setNextState(Ready);
}
}
}
}
We are not introducing any new Coco constructs in this port, so it should be clear to anyone who has worked through the previous lessons.
The complete Coco for this port is in Example_05B/src/ValveDriver.coco
.
Step 2: Creating the ValveDriverImpl
Component¶
Our next step is to create a component to implement a virtual valve. It will behave asynchronously, and report error
conditions, irrespective of whether or not the underlying hardware has these properties. We have designed this example
to implement asynchronous movement on hardware that behaves synchronously, while modifying the ValveControllerImpl
so that
it remains responsive during the valve movement.
Our complete component for ValveDriverImpl
is as follows:
import unqualified ValveDriver
import unqualified ValveHAL
@runtime(.MultiThreaded)
component ValveDriverImpl {
val client : Provided<ValveDriver>
val hal : Required<ValveHAL>
machine {
state Initial {
client.setup(settings : ValveParameters) = if (hal.setParameters(settings)) {
setNextState(Ready);
return true;
} else {
return false;
}
}
state Ready {
client.open() = setNextState(Moving(.Open))
client.close() = setNextState(Moving(.Close))
}
// Asynchronously execute the synchronous valve movement
execution state Moving(target : ValveHAL.Moves) {
hal.move(target);
// Signal end of movement - this valve has no errors
client.endOfMovement(match (target) {
.Open => .Opened,
.Close => .Closed,
});
// Ready for next move
setNextState(Ready);
}
}
}
We have not used any new constructs in this component, so it should be clear to anyone who has worked through the previous lessons.
Similar to the previous implementation of the ValveControllerImpl
, this component cannot respond to its client, the
ValveControllerImpl
, until the execution state has finished, and the state machine has changed to an event state. This
means that for ValveControllerImpl
to remain responsive to its client, it must not interact with ValveDriverImpl
while the valve is moving.
The complete Coco for this component is in Example_05B/src/ValveDriver.coco
.
Step 3: Modifying ValveControllerImpl
to use ValveDriver
¶
We now need to modify the ValveControllerImpl
component to use the ValveDriverImpl
component, instead of directly
accessing the ValveHALBase
. We will:
- Replace the current required port instance
hal
with a new instance ofValveDriver
, calleddriver
, and change all references ofhal
todriver
throughout the state machine. - Replace the execution state with an event state, which will handle the
endOfMovement(…)
signal fromValveDriver
.
While making these changes, we will also take the opportunity to refactor ValveControllerImpl
. The modified version looks
like the following:
import unqualified ValveController
import unqualified ValveDriver
import unqualified ValveHAL
@runtime(.MultiThreaded)
component ValveControllerImpl {
val client : Provided<ValveController>
val driver : Required<ValveDriver>
machine {
client.isOpen() = false
client.isClosed() = false
state Initial {
client.setup(settings : ValveParameters) = if (driver.setup(settings)) {
setNextState(Ready.Unknown);
return true;
} else {
return false;
}
}
state Ready {
client.open() = {
driver.open();
setNextState(Moving)
}
client.close() = {
driver.close();
setNextState(Moving)
}
// Valve position unknown until the first move request is received
state Unknown {}
state Opened {
client.isOpen() = true
client.open() = client.moveEnded(true)
}
state Closed {
client.isClosed() = true
client.close() = client.moveEnded(true)
}
}
state Moving {
driver.endOfMovement(result : ValveDriver.Status) = {
setNextState(match (result) {
.Opened => Ready.Opened,
.Closed => Ready.Closed,
_ => abort(),
});
client.moveEnded(true);
}
}
}
}
In the refactored state machine, a new state called Ready
is introduced to encapsulate all the
stationary states that are able to receive open()
and close()
requests, namely Unknown
, Opened
, and Closed
.
These states are known as nested or child states of
Ready
, which in turn is referred to as their parent state. Ready
has transition declarations for
open()
and close()
at the top-level, which means that if either of them is called when the state
machine is in one of the nested states with no matching transition enabled, then the corresponding top-level transition
(i.e. in Ready
) will be executed. For example, the nested state Unknown
does not have any transitions, and therefore
the corresponding transitions in the parent state Ready
will be executed in the event that open()
or close()
is called.
Note
Coco supports hierarchical state machines. This means that every event state
,
except for Terminated
, can have nested states to any depth, forming a rooted tree of parent-child relationships with
Main
as the root. Event transitions
for the same event can be declared in
multiple states across the hierarchy.
When a component state machine receives an incoming function call or signal in a state S, it will execute the
corresponding enabled event transition
declared in S. If no such match exists, then the state
machine will execute the corresponding match transition declared in the nearest ancestor of S. If no matching transitions
exist in any of the ancestors of S up to and including Main
, then it will execute the illegal
handler for that
incoming event. See Dispatching Events in a Component State Machine for further details.
Verifying your Changes (Example_05B)¶
The verification fails because the ValveControllerImpl
is no longer well-formed and produces the following counterexample:
Analysis¶
The counterexample shows the required port driver
is sending a signal to ValveControllerImpl
, but
its queue is full. This is because we have not declared a queue for the ValveControllerImpl
component yet, and therefore it cannot receive any of the signals sent to it by its required port.
Step 4: Adding a Queue to ValveControllerImpl
¶
We need to extend the ValveControllerImpl
component with a queue that is big enough to store all of the signals that
can be sent by its required ports before they have to be processed. In this case, a queue length of size 1 suffices.
We add a queue by labelling the ValveControllerImpl
component declaration with the following @queue
attribute:
@queue(1)
The modified Coco for this component is in Example_05C/src/ValveControllerImpl.coco
.
Verifying the Models (Example_05C)¶
During verification, the Coco Platform finds an error where ValveControllerImpl
calls abort()
,
which is not allowed:
Analysis¶
This counterexample illustrates how the last event performed by ValveControllerImpl
before the abort()
call is
to dequeue an endOfMovement(Status.Error)
in state Moving
. Examining the corresponding transition in Moving
,
we see:
driver.endOfMovement(result : ValveDriver.Status) = {
setNextState(match (result) {
.Opened => Ready.Opened,
.Closed => Ready.Closed,
_ => abort(),
});
client.moveEnded(true);
}
A match
expression, matching on the result
parameter in the endOfMovement(…)
signal, is used to
determine the next state based on the result of the movement. The type of result
is Status
, which is an enum with five
values, but the match
expression only provides logic for two of them, and an abort()
for all the others.
Step 5: Adding Missing Match Cases to ValveControllerImpl
¶
The solution to this error is to amend the match
statement by adding the missing cases as follows:
driver.endOfMovement(result : ValveDriver.Status) = {
setNextState(match (result) {
.Open => Ready.Opened,
.Closed => Ready.Closed,
.Error => Ready.Unknown,
.Unknown => Ready.Unknown,
.Moving => abort(),
});
client.moveEnded(true);
}
In the case of an error, we do not know the position of the valve. For completeness, we will also add
the missing cases for .Unknown
and .Moving
.
The complete Coco for this component is in Example_05D/src/ValveControllerImpl.coco
.
Verifying your Changes (Example_05D)¶
The verification fails because the ValveControllerImpl
does not implement its provided port correctly:
Analysis¶
The counterexample shows the client calling open()
on the ValveControllerImpl
to close the valve, and in turn,
ValveControllerImpl
calls open()
on its required port driver
. driver
sends an endOfMovement(.Error)
signal to the ValveControllerImpl
indicating that the move has failed, which in turn should send a moveEnded(false)
signal to its client. Instead, it erroneously sends a moveEnded(true)
specifying that the move completed without
errors. As a result, the provided port, ValveController
, expects ValveControllerImpl
to be in state Opened
when in
fact, it is in state Unknown
. Consequently, the isOpen()
returns false
when the provided port was expecting it to
return true
.
Note
When the verification finds an error, the shortest counterexample that results in the error is displayed. In this
example, the same error can occur when the client calls open()
or close()
, and since their respective
counterexamples are the same length, the one displayed may vary between releases.
Step 6: Modifying ValveControllerImpl
¶
To resolve the error shown in the counterexample above, we will modify the Moving
state in ValveControllerImpl
to send the
correct signals:
state Moving {
driver.endOfMovement(result : ValveDriver.Status) = {
setNextState(match (result) {
.Opened => Ready.Opened,
.Closed => Ready.Closed,
.Error => Ready.Unknown,
.Unknown => Ready.Unknown,
.Moving => abort(),
});
client.moveEnded(nextState != Ready.Unknown);
}
}
Note
The builtin variable nextState : State
is an immutable copy of the parameter in the most recent call to one of the
state transition functions. Its value can only be changed indirectly by calling
one of these state transition functions, such as setNextState
.
This nextState
variable is used in the following statement:
client.moveEnded(nextState != Ready.Unknown);
ValveControllerImpl
sends the moveEnded()
signal with an expression as its argument that refers to the value of
nextState
. If the value of nextState
is Ready.Unknown
, then this means that ValveControllerImpl
has
received one of the error conditions from the ValveDriverImpl
, and sends the signal moveEnded(false)
. In all other
cases, it sends the signal moveEnded(true)
.
The complete Coco for this component is in Example_05E/src/ValveControllerImpl.coco
.
Verifying your Changes (Example_05E)¶
The project now verifies correctly.
Step 7: Extending ValveControllerImpl
to Preserve Signal Data¶
When the valve completes a movement, the ValveDriverImpl
component sends an endOfMovement(…)
signal that is declared
with the following signature:
outgoing signal endOfMovement(result : Status)
In contrast, when the ValveControllerImpl
component sends a signal to its client to indicate the completion
of the valve movement, it does so with a signal declared as follows:
outgoing signal moveEnded(result : Bool)
This loses information that would probably be useful to its client. We will therefore change the moveEnded()
signature
to match the corresponding signal sent by ValveDriverImpl
by:
- Modifying the
ValveController
port to change the signal signature. - Modifying the
ValveControllerImpl
component to solve any static type errors caused by changing its provided port. - Reverifying the project, and fixing any other issues resulting from the changes.
Modifying ValveController
¶
We will take the following steps to modify ValveController
:
- Import the declarations from
ValveDriver
. - Modify the signature in the signal declaration.
- Adapt the transition handlers that send this signal to handle the new signature.
- Modify the transition that sends this signal in state
Moving
with the new arguments. - Delete the
enum
Position
.
Your modified ValveController
port should look like the following:
import unqualified ValveDriver
import unqualified ValveHAL
port ValveController {
function setup(settings : ValveParameters) : Bool
function open() : Nil
function close() : Nil
function isOpen() : Bool
function isClosed() : Bool
outgoing signal moveEnded(result : ValveDriver.Status)
machine {
var position : ValveDriver.Status = .Unknown
isOpen() = position == .Opened
isClosed() = position == .Closed
state Initial {
setup(_) = nondet {
InvalidParameters:
{
return false;
},
ValidParameters:
{
setNextState(Ready);
return true;
},
}
}
state Ready {
open() = nondet {
{
position = .Opened;
moveEnded(position);
},
{
setNextState(Moving(.Opened))
},
}
close() = nondet {
{
position = .Closed;
moveEnded(position);
},
{
setNextState(Moving(.Closed))
},
}
}
state Moving(target : ValveDriver.Status) {
entry() = {
position = .Unknown;
}
spontaneous = {
position = nondet {
target,
.Unknown,
.Error,
};
setNextState(Ready);
}
exit() = {
moveEnded(position);
}
}
}
}
Let’s step through each change in turn.
The following line imports ValveDriver
:
import unqualified ValveDriver
This brings the declaration of Status
into scope. The following line is the amended signal
declaration:
outgoing signal moveEnded(result : ValveDriver.Status)
The following lines are changed to use the values of Status
:
var position : ValveDriver.Status = .Unknown
isOpen() = position == .Opened
isClosed() = position == .Closed
The following lines are also changed to use Status
defined in ValveDriver
:
open() = nondet {
{
position = .Opened;
moveEnded(position);
},
{
setNextState(Moving(.Opened))
},
}
close() = nondet {
{
position = .Closed;
moveEnded(position);
},
{
setNextState(Moving(.Closed))
},
}
The parameter of state Moving
is modified as follows:
state Moving(target : ValveDriver.Status) {
Finally, the spontaneous transition
handler is changed as follows:
position = nondet {
target,
.Unknown,
.Error,
};
The complete Coco for this port is in Example_06A/src/ValveController.coco
.
Modifying ValveControllerImpl
¶
Your ValveControllerImpl
component will now have type errors caused by the changes we have made to the signal declaration
in its provided port ValveController
.
To fix this, edit your component so that the state machine looks like the following:
machine {
client.isOpen() = false
client.isClosed() = false
state Initial {
client.setup(settings : ValveParameters) = if (driver.setup(settings)) {
setNextState(Ready.Unknown);
return true;
} else {
return false;
}
}
state Ready {
client.open() = {
driver.open();
setNextState(Moving)
}
client.close() = {
driver.close();
setNextState(Moving)
}
// Valve position unknown until the first move request is received
state Unknown {}
state Opened {
client.isOpen() = true
client.open() = client.moveEnded(.Opened)
}
state Closed {
client.isClosed() = true
client.close() = client.moveEnded(.Closed)
}
}
state Moving {
driver.endOfMovement(result : ValveDriver.Status) = {
setNextState(match (result) {
.Opened => Ready.Opened,
.Closed => Ready.Closed,
.Error => Ready.Unknown,
.Unknown => Ready.Unknown,
.Moving => abort(),
});
client.moveEnded(result);
}
}
}
The complete Coco for this component is in Example_06A/src/ValveControllerImpl.coco
.
Step 8: Building an Encapsulating Component ValveGroup
¶
As defined in the new architecture at the start of this lesson, some of the functionality of the ValveControllerImpl
has
been moved into a new component called ValveDriverImpl
. The fact that we have divided the ValveControllerImpl
into two
components and how they are connected together at runtime, is private knowledge that should be hidden from the client.
We should be free to refactor the ValveControllerImpl
into as many components as we like in order to implement the
specified behaviour, without having any impact on the client.
We will create a new encapsulating component called ValveGroup
, which consists of
an instance of ValveControllerImpl
and an instance of ValveDriverImpl
, and connects them together:
Once we have done this, we will modify the TestValveGroup
to instantiate the new ValveGroup
component
instead of ValveControllerImpl
.
Creating ValveGroup
¶
We start by declaring a new component called ValveGroup
as follows:
import unqualified ValveController
import unqualified ValveControllerImpl
import unqualified ValveDriverImpl
import unqualified ValveHAL
@runtime(.MultiThreaded)
component ValveGroup {
val client : Provided<ValveController>
val hal : Required<ValveHAL>
val valve : ValveControllerImpl
val driver : ValveDriverImpl
init() = {
connect(client, valve.client);
connect(valve.driver, driver.client);
connect(driver.hal, hal);
}
}
The following lines declare instances of its provided and required ports:
The following lines declare the instances valve
and driver
of the components ValveControllerImpl
and ValveDriverImpl
respectively:
val valve : ValveControllerImpl
val driver : ValveDriverImpl
The following constructor specifies how the component instances are connected together:
Modifying TestValveGroup
to use ValveGroup
¶
Our next step is to make the following changes to TestValveGroup
:
- Import the declaration from
ValveGroup
instead ofValveControllerImpl
. - Instantiate
ValveGroup
instead ofValveControllerImpl
.
Our modified TestValveGroup
is as follows:
import unqualified SimulatorImpl
import unqualified ValveController
import unqualified ValveGroup
@runtime(.MultiThreaded)
component TestValveGroup {
val client : Provided<ValveController>
val valve : ValveGroup
val hal : SimulatorImpl
init() = {
connect(client, valve.client);
connect(valve.hal, hal.client);
}
}
The following architecture diagram is a graphical representation of our updated TestValveGroup
:
Verifying your Changes (Example_06A)¶
Your project should now pass the verification without any errors.