Features of logical time

Logo

We have seen in past articles how synchronous programming relies on a notion of logical time instead of exposing programmers only to actual, physical time. This abstract, idealized time is a discrete succession of instants that can be numbered with a monotonically increasing value t. Synchronous computations can be represented as instantaneous events that take place at a single instant and have zero duration. Events occurring at the same instant t can be given some priority so that a total ordering of events is always possible. By combining logical time with non-preemptive concurrency (i.e., by not having an independent scheduler decide when to preempt a thread based on its own, not always easily understood criteria, but instead having the programmer explicitly state when preemption is allowed to happen), it becomes possible to have a completely deterministic timing model that comes with significant benefits.

The first of these benefits applies to testing. Testing an application with a rich and complex graphical user interface or using non-local data is challenging, as it requires simulating processes over time, such as a mouse-driven drag-and-drop gesture, or fetching remote data. Moreover, a full test suite consists of many different tests covering many different scenarios, including the handling of errors and issues such as timeouts and retries. This makes the test runner itself an application that needs to consider these timing issues as it needs to run the test suite to completion.

A deterministic timing model for tests makes mocking user input or network activity simpler, as a trace of successive events happening at different instants can be generated for a given test: a mouse down event happens at time t, following by moving to some point p at t + 1, then to p’ at t + 2, and so on, until a mouse up event happens at t’. This in turn may trigger some asynchronous action ending at a later instant t”. Now the state of the system can be examined immediately after this time t” and the test passes if all expectations are met. The determinism of the model comes into play if several actions were triggered at time t’: the display is refreshed, a network request is made, &c. These events can be ordered exactly so that every run of the test is the same, and there should be no “flakiness” requiring to re-run the test several times to make sure it passes. It also becomes possible to verify that time does not advance past a given time, for instance when testing that a gesture should be ignored.

A second benefit of using logical time applies to debugging. Once a program uses some level of concurrency, debugging becomes increasingly difficult as the number of separate threads increases. But keeping track of logical time and discrete instants allows rendering a trace of events occurring in each thread visually on a timeline as shown below.

Visualization of several parallel threads and the time
        at which events occurred on each thread
Visualizing concurrent threads and the events that occurred at different times.

In this prototype visualization, five threads are shown as horizontal lines with time flowing from left to right. Three successive instants are shown, with the background color varying between instants (they are labelled with the time at which they occurred since the application started running). Each thread bears events which may have a duration (in which case they are represented as a rectangle) or not (they are then represented as a circle). Some of these events are spawning events, where a parent thread spawns a new child thread (which appears underneath it, connected to its parent by an arrow going down). Other events are end events, represented as double circles, where the execution of the thread ends and it joins its parent thread, as shown by an upward arrow. Finally, the crosses show threads that have been cancelled by their parent. Finally, currently active computations are shown in bright green, so we can see four threads waiting for some event to occur or an asynchronous computation to finish, while the middle thread has ended normally and joined its parent.

This visualization shows that many things can and do happen in a single instant, as synchronous events are handled instantaneously (therefore two successive synchronous computations take effect within the same instant), and threads are executed concurrently (and thus may receive or send events within the same instant), yet there is a definite order (with any event following events to its left and preceding all other events to to its right). The visualization shown here may not present the full picture (we don’t know what the actual computations and events are), but a debugger built on top of it can map these threads to the original source code for a full debugging experience. With some practice (and every debugger tool requires practice) the developer can at a glance verify whether the state of the application matches their expectations, or if something seems out place, such as a thread having been canceled or still running when it should not.

A third benefit of using logical time is time manipulation. Multiple mappings from logical instants to actual time exist for any given trace, and this can be taken advantage of to make time flow faster, slower or even backward. Again, this is extremely useful in development and testing, as tests that take some amount of time (such as waiting for a timeout to expire and verifying that that situation is handled as expected) can be run instantly, withouth having to manipulate and later restore a system clock, which is often, at best, an iffy proposition. Conversely, slowing time down is also useful to verify visually that an animation does run through every frame as expected, or to ensure the correct order of a series of events normally happening in quick succession.

Moving faster or slower through time just means applying some positive scaling factor to the function mapping instants to actual times; setting this factor below 1 to move slower and above 1 to move faster. That factor can also be zero or less: zero simply means that the program is paused and no event is handled (these can be ignored, or queued to be processed later). And a negative number now means that time is going backward: in the figure below, a transport bar control is added to the timeline visualization; this interface element comes from the world of audio equipment where a tape can be played, paused, stopped, fast-forwarded or rewound.

Visualization of several parallel threads and rewinding to a
        previous time in the trace
Rewinding to a previous time in the trace.

In this example, the program ran to completion in ten seconds as all threads show an end mark (a double circle or a cross), but the user has rewound time (at double speed) to the five-second mark, as shown by the time display of the transport bar (including some synchronous events happening at the six-second mark, but this is because these remain highlighted for a default duration in order to be seen by the user). In keeping with the tape deck analogy, the transport has both a play button (with a triangle ▶ icon) and a record button (with a red circle icon). This corresponds the two modes in which the application can run; in recording mode, new input events can be committed to tape, while in playback mode, only event that have occurred in the past are replayed, and new inputs are ignored. This means that the developer can replay the trace of the application back and forth to understand how all the input events that have occurred affect the state as time progresses; but pressing record at some arbitrary time means that every event that follows is erased from the trace, and a new future is created.

Of course, all of these benefits do not come for free, and they require some serious design and implementation effort. No computation is truly instantaneous in reality, so a synchronous system must be able to process events in a timely manner to achieve some sort of “soft real-time” behaviour, where exact timing may not be accurate to the millisecond, but order and correctness are still guaranteed, and latency is kept small enough to not be noticeable or impair usability.

A man holding a record with a desert island
    on the sleeve
“Now this is definitely one of my desert island records.”

As the ChucK paper highlighted, the cooperative preemption style of programming that is also adopted here requires explicit developer support, as threads must yield to other threads in order for time to move forward. Fortunately, both SMIL and Esterel provide blueprints for powerful tools for structured concurrency (such as par and seq containers/operators) that can turn this constraint into a more declarative way of architecturing an application.

Implementing time manipulations also comes with its challenges. Undoing and redoing an arbitrary computation is not generally feasible unless the developer actually provides hints about how to handle its side effects. Again, this can be alleviated by having means of separating pure functions from those that have effects. In many cases, undoing and redoing is just doing nothing: for example, in the case of a network request for remote content, the result of the request can be cached, and redoing that request is simply restoring this cached value at the instant where the request originally ended without new network interaction.

While this article focused on how logical time improved the developer experience, some or all of these benefits may also apply to the user experience, e.g., the transport bar can be included as an element of the application and the user then has the same power to manipulate time, rewind, and record again. ⚅⚅