© Hillel Wayne 2018
Hillel WaynePractical TLA+https://doi.org/10.1007/978-1-4842-3829-5_6

6. Temporal Logic

Hillel Wayne1 
(1)
Chicago, Illinois, USA
 
So far everything we’ve done has been testing invariants: statements that must be true for all states in a behavior. In this chapter, we introduce temporal properties: statements about the behavior itself. Temporal properties considerably expand the kinds of things we can check, providing a range of techniques that few other tools can match. Some examples of temporal properties:
  • Does the algorithm always terminate?

  • Will all messages in the queue get processed?

  • If disrupted, will the system return to a stable state over time?

  • Is the database eventually consistent?

Temporal properties are very powerful but also much harder to guarantee. Systems have a whole new set of failure modes that apply to temporal properties. As always, as a system gets harder to analyze, specifying and model checking it becomes more important.

Termination

The simplest temporal property is Termination. This is the requirement that the algorithm eventually ends. If the algorithm crashes or enters an infinite loop, then it violates termination.

To understand this better, imagine we have a car at a traffic light. We have two processes in the system. The traffic light alternates between red and green (yellow is an implementation detail). The car waits until the light is green before moving. Here’s a specification for this:
NextColor(c) == CASE c = "red" -> "green"
                  [] c = "green" -> "red"
(*--algorithm traffic
variables
  at_light = TRUE,
  light = "red";
process light = "light"
begin
  Cycle:
    while at_light do
      light := NextColor(light);
    end while;
end process;
process car = "car"
begin
  Drive:
    when light = "green";
    at_light := FALSE;
end process;
end algorithm;*)

Create a model and, under Model Overview > What to Check? > Properties, check Termination. Before you run it, try to predict what will happen.

Once you have a guess, run TLC. You should see that it fails. The first steps can be system dependent, but they all end the same way: the light is green, but the trace is “stuttering.” See Figure 6-1.
../images/462201_1_En_6_Chapter/462201_1_En_6_Fig1_HTML.jpg
Figure 6-1

Stuttering

What does that mean?

Stuttering

TLA+ is the “Temporal Logic of Actions.” Every step of the model represents a single action in time. TLC works by looking at all of the enabled labels at each step and picking one. However, it also has another option: it can take no action at all. We call this stuttering. In most cases, stuttering doesn’t change the spec: if no action happens, then everything’s the same as before and it didn’t matter. The one special case is if the spec keeps stuttering, over and over again, and never takes any other action. It’s as if the spec is frozen in time, never able to change.

Up until now, stuttering hasn’t mattered. All of our invariants are safety checks, which checks the model can’t reach an invalid state. Since stuttering on a valid state leaves you in a valid state, TLC had no reason to try stuttering. Most temporal properties, though, are what’s called liveness checks. A liveness check is one that verifies the system eventually does what you expect it to do. Here, TLC never finishes evaluating Cycle so the spec never terminates.

Stuttering can be useful to us. It can represent a server crashing, or a process timing out, or an awaited signal never coming. It’s better that TLA+ defaults to “everything can crash” than the converse: otherwise our models may only work because of an implicit assumption. If you want to explicitly rule out stuttering, you need to add fairness.

Fairness, Weak and Strong

There are two kinds of fairness: weak and strong. A weakly fair action will, if it stays enabled, eventually happen. We can declare every label in a process weakly fair by calling it a fair process. Here’s what the spec looks like when we add fairness:
fair process light = "light"
begin
  Cycle:
    while at_light do
      light := NextColor(light);
    end while;
end process;
fair process car = "car"
begin
  Drive:
    when light = "green";
    at_light := FALSE;
end process;

First, see what happens when only one process is fair. If only the car is fair, then the light might never cycle. If only the light is fair, it will eventually cycle to green, but the car might never move. Try this, and then see what happens when both processes are fair.

You should still see the spec fail! There’s one case we didn’t cover: What if the light keeps cycling between green and red? The Drive action is enabled, then disabled, then enabled again, ad infinitum. But weak fairness only guarantees the action will happen if it stays enabled. If the light is always green, the car will eventually drive through. But since it can keep cycling, the car is stuck.

This is where strong fairness comes in. A strongly fair action, if it’s repeatedly enabled, will eventually happen. There can be gaps in between, but as long as there’s some cycle where it keeps getting enabled again, it will happen. We can make a process strongly fair by calling it a fair+ process.
fair+ process car = "car"
begin
  Drive:
    when light = "green";
    at_light := FALSE;
end process;

This, finally, will always terminate. Even if the light keeps cycling, the Drive action is repeatedly enabled and so is guaranteed to happen. Note that this still requires the light to be weakly fair : if it’s unfair, it can simply cycle to red and stay there. In practice, people don’t often use strong fairness; it’s a much safer to assume the system is only weakly fair. However, it’s worth knowing about for the cases where it is useful.

Tip

You can also make individual actions in a process fair. For label A: in an unfair process, writing A:+ will make it weakly fair. In a weakly fair process, A:+ will make it strongly fair.

You can also make the spec globally fair by writing --fair algorithm instead of --algorithm.

The Temporal Operators

For all of these assume P and Q are Boolean statements.

[]

[] is always. []P means that for P is true for all states in all behaviors. This is useful enough that TLC is designed around it: saying P is an invariant is an optimized way of saying that []P is a temporal property, and in fact TLC uses a much faster algorithm to evaluate invariants. As such we rarely use it explicitly, except for specifying especially advanced properties.

You can also write ~[]P, which means that P will be false for at least one state.

<>

<> is eventually. <>P means that for every behavior, there is at least one state where P is true. It may be false before, and it may be false after, but what matters is that it was at some point true. In the traffic example, <>(light = "green") is a satisfied temporal property . But if we instead wrote
variables
  at_light = TRUE,
  light = "green";

then <>(light = "red") would be an unsatisfied temporal property: TLC can find a possible execution where the light is never red.

You can also write ~<>P, which means that P is never true. Note that this is the same as saying []~P, and in fact <>P is formally defined as ~[]~P.

Note

Termination is defined as “eventually all processes are done”: Termination == <>(A self in ProcSet: pc[self] = "Done").

The current version of TLC cannot check set membership of a variable set as part of a property with <>. So you can write <>(set = {}), but if you write <>(x in set), set must either be a constant or a parameterless operator.

~>

~> is leads-to. P ~> Q means that if there is some state where P is true, then either Q is true either now or in some future state. Once this is set, it’s irreversible: even if P is later false, Q still must happen. If we write
L == (light = "green") ~> ~at_light
then L is true if the light never becomes green or if the light turns green and at some point after the car is no longer at the light. Unlike <>, ~> is “triggered” every time P is true. In the base spec, (light = "red") ~> (light = "green") holds. But if we write
  Cycle:
    while at_light do
      light := NextColor(light);
    end while;
    light := "red";

then it would not hold. The first time the light turns red, it later turns green, which is fine. But the second time it turns red it doesn’t eventually turn green again, so the property is false.

~P ~> Q and P ~> ~Q have their expected meanings. ~(P ~> Q) makes TLC explode.

You can also do P ~> []Q. If P is true, then there is some state where Q becomes true and forever stays true.

[ ]<> and <>[ ]

[]<>P means that P is always eventually true, <>[]P means that P is eventually always true. For a finite spec, these mean the same thing: P is true at termination. For an infinite spec, <>[]P means that there is some point where P becomes true and forever stays true, while []<>P means that if P ever becomes false, it will eventually become true again. Another way to think about it is that []<>P <=> (~P ~> P): P being false leads to P being true later.

In our current version of the spec, both []<>(light = "green") and <>[](light = "green") are true, while []<>(light = "red") and <>[](light = "red") are false. If we change the light to
    while TRUE do
      light := NextColor(light);
    end while;

then <>[](light = "green") becomes false and []<>(light = "red") becomes true.

As with <>, TLC cannot check set membership of a variable set as part of a property with <>[] or []<>.

Limitations of Liveness

Hopefully by now you’re thinking two things:
  1. 1.

    Temporal properties can be incredibly powerful.

     
  2. 2.

    Temporal properties can be incredibly confusing.

     

Fact is, you don’t often need them. Most often what you want can be expressed as an invariant. The rest of the time you’re usually fine with [], <>, and simple uses of ~>. As long as you’re not writing something like <>~(P ~> []Q) you’re probably fine.

From a practical perspective, the main limitation of temporal properties is that checking liveness is slow. Very slow. Invariants are checked on individual states at a time, while temporal properties have to be checked over sequences of states. TLC uses a different algorithm for this, which is slower and is not parallelizable.

When checking temporal properties, place them in a separate model from your invariants. This way you can test the invariants much more quickly before checking the slower temporal properties. Also consider testing liveness over a smaller domain. If you can check invariants with MaxFoo <- 5, it might take the same time to check liveness for MaxFoo <- 3. You can, of course, simply leave TLC running for a longer time. Having a model take a day to check is unpleasant, but it’s better than having a mistake in your design.

There’s one other, extremely important limitation of temporal properties: do not combine temporal properties and symmetry sets. Regular sets of model constants are fine, but not symmetry sets. TLC optimizes symmetry sets by skipping redundant states, which may lead to it missing a liveness error. Almost all of the mistakes you can make using TLC are false positives: the checker will report spec errors that aren’t actually in the design. This is one of the extremely few false negatives: it could potentially tell you that a spec is valid when it really has errors. TLC will warn you if you accidentally do this.

Example

Now that you know how to use temporal properties, let’s apply it to a more interesting example than a traffic light. Dekker’s Algorithm was, historically, the first successful algorithm to allow two threads to share a single resource without having a race condition. It guarantees that both threads will eventually perform their update, but not at the same time, and without using any CPU-specific features. The only thing you need is some shared memory. We will specify it in TLA+ and show it works as expected.

Unlike all of the other specs we’ve written, the grain of atomicity here is a single CPU instruction. We can simulate this by using a new label for every single line, whether a conditional or an assignment. We represent the set of instructions where the thread is updating the resource as the critical section , or CS.
EXTENDS TLC, Integers
CONSTANT Threads
(*--algorithm dekker
variables flag = [t in Threads |-> FALSE];
fair process thread in Threads
begin
  P1: flag[self] := TRUE;
  * all threads except self are false
  P2: await A t in Threads {self}: ~flag[t];
  CS: skip;
  P3: flag[self] := FALSE;
  P4: goto P1;
end process;
end algorithm; *)
We can represent the invariant as “at most one thread is in the critical section at a time.” Since this is best represented by a check on pc, we need to place this after the PlusCal translation. There are a couple of ways we can write this, depending on your comfort level with the logic here.
AtMostOneCritical ==
  / A t in Threads: pc[t] /= "CS"
  / E t in Threads:
    / pc[t] = "CS"
    / A t2 in Threads {t}: pc[t2] /= "CS"
This is the naïve way. It says that none of the threads are in CS, or that one thread and no other is in CS. This is a little clunky: why split “there are at most one thread” into “there is no thread OR there is exactly one thread?” We can rewrite it more cleanly as this:
AtMostOneCritical ==
  A t1, t2 in Threads:
    t1 /= t2 => ~(pc[t1] = "CS" / pc[t2] = "CS")

For any two threads, they both can’t be in CS at the same time. We need the t1 /= t2 clause in there to make sure they’re different threads. Otherwise, TLC can pick the same thread as both t1 and t2.

In any case, let’s run the spec with Threads <- 1..2, INVARIANT AtMostOneCritical, Deadlock. The spec should fail with a deadlock after three steps. Both threads can turn on the flag at once. An early attempted solution was to have the flags enter a loop, constantly turning their own flag on and off until one of them gets into the critical section.
fair process thread in Threads
begin
  P1: flag[self] := TRUE;
  P2:
    while E t in Threads {self}: flag[t] do
      P2_1: flag[self] := FALSE;
      P2_2: flag[self] := TRUE;
    end while;
  CS: skip;
  P3: flag[self] := FALSE;
  P4: goto P1;
end process;
Confirm this fix works (91 states). We’re done, right? Well, not exactly. We’ve only shown that it doesn’t deadlock and it doesn’t have two threads in the critical section: safety properties. We also need to show that all of the threads successfully reach the critical section. We can represent the temporal property as:
Liveness ==
  A t in Threads:
    <>(pc[t] = "CS")

TLC can handle this property because the set, Threads, is a constant. This means that all threads eventually reach the cs step. If we add Liveness as a temporal property, the spec fails: both threads get endlessly stuck cycling in P2. This is called a livelock.

Warning

A common mistake is putting the temporal operator in the wrong place. If you write <>A t in Threads: pc[t] = "CS", you’re instead saying “there is a state where all the threads are simultaneously in CS”, which directly contradicts our AtMostOneCritical invariant.

Dekker’s algorithm fixes this:
(*--algorithm dekker
variables
  flag = [t in Threads |-> FALSE],
  next_thread in Threads;
fair process thread in Threads
begin
  P1: flag[self] := TRUE;
  P2:
    while E t in Threads {self}: flag[t] do
      P2_1:
        if next_thread /= self then
          P2_1_1: flag[self] := FALSE;
          P2_1_2: await next_thread = self;
          P2_1_3: flag[self] := TRUE;
        end if;
    end while;
  CS: skip;
  P3: with t in Threads {self} do
    next_thread := t;
  end with;
  P4: flag[self] := FALSE;
  P5: goto P1;
end process;
end algorithm; *)

This will pass (256 states). We’ve guaranteed our liveness properties for two threads.

While Dekker’s Algorithm is simple and satisfies all properties, it has a couple of problems. The first is that it only applies for two threads: if you extend it to Threads <- 1..3 it will fail. While two of the three threads will always reach CS, one thread won’t. This is called resource starvation . Try making some simple changes and seeing if you can generalize it. Remember to place every operation in a separate label, and don’t be surprised if you can’t manage it. The successful generalizations get very convoluted.

The other problem with Dekker’s Algorithm is that it’s not resilient. If either thread crashes, it will prevent the other from finishing. To show this, we can create two separate processes: one that’s fair and one that’s regular. Since TLC doesn’t have to evaluate the regular process, it can “simulate” a crashed process by never advancing it. For this version, since we know it has to have exactly two threads, I went ahead and hard-coded it.
EXTENDS TLC, Integers, Sequences
* CONSTANT Threads
Threads == 1..2
(*--algorithm dekker
variables
  flag = [t in Threads |-> FALSE],
  next_thread in Threads;
procedure thread()
begin
  P1: flag[self] := TRUE;
  P2:
    while E t in Threads {self}: flag[t] do
      P2_1:
        if next_thread /= self then
          P2_1_1: flag[self] := FALSE;
          P2_1_2: await next_thread = self;
          P2_1_3: flag[self] := TRUE;
        end if;
    end while;
  CS: skip;
  P3: next_thread := 3 - next_thread;
  P4: flag[self] := FALSE;
  P5: goto P1;
end procedure;
* self is only defined for sets
fair process fair_thread in {1}
begin
  Fair:
    call thread();
end process;
process crashable_thread in {2}
begin
  Crashable:
    call thread();
end process;
end algorithm; *)
I also pulled the thread logic into a procedure : the two threads have the exact same behaviour, and the only difference is whether they are fair or not. I also wrote in {1} because self is only defined for sets of processes, even if the set has only one element. Since we’re testing resilience, we want the spec to be valid even if the unfair process stops. So we adjust our liveness clause to only check the fair process:
Liveness ==
  A t in {1}:
    <>(pc[t] = "CS")

This fails. The crashing thread can reach P2_1_1 and never execute it, causing the fair thread to cycle in P2 forever. As with generalizing to three threads, fixing the resiliency bug requires major changes to the algorithm that are outside the scope of this book.

Summary

In this chapter we learned about fairness, liveness, termination, and stuttering. We also learned about temporal operators, how they are powerful, and how they can be tricky. We did an example of Dekker’s Algorithm.

With this, we have now covered all of the core material of the book. In the next chapters, we will not introduce any new syntax or rules. The rest of the book will teach you how to use TLA+ better and how to apply it to a wide variety of real-world problems.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.149.240.196