Seastar's timer utility

4 May 2021

It’s common when building systems to periodically perform some action in the future. Send a heartbeat message, run a garbage collection task, blink a light. In threaded execution models a simple approach is to have a worker thread perform an action then sleep for some period. However, in a system like Seastar there are no threads to put to sleep (although technically you could create a looping fiber with a call to seastar::sleep). Instead, Seastar provides a timer utility which can be used to schedule an action to be executed in the future.

Here is an example of using Seastar’s timer utility which prints “hello” to standard output after ten milliseconds:

seastar::timer tm([] { std::cout << "hello" << std::endl; });
tm.arm(std::chrono::milliseconds(10));

Before continuing it’s worth mentioning that the timer isn’t a standalone utility. It depends on the underlying Seastar execution engine (the reactor) to track, expire, and execute timer callbacks. In the future we’ll discuss how all of this works internally, but for now we’ll restrict our discussion to the timer interface which hides all of the low-level details related to time keeping and scheduling.

In addition to setting the callback lambda when the timer is created, the callback can be changed at any time by passing a new lambda to set_callback which will be the active callback the next time the timer fires. This could be useful depending on how the timer object is created and managed. But I’ve never seen a need to change the callback once it has been set.

seastar::timer tm([] { std::cout << "hello" << std::endl; });
tm.set_callback([] { std::cout << "hello world" << std::endl; })

One important point about callbacks: they are synchronous and block the reactor. Therefore a timer callback should execute fast and if it needs to do any sort of expensive task it should be scheduled as a background fiber or added to a workqueue and handled by the application.

Creating a timer doesn’t do anything. It has to first be armed:

// now it is active
tm.arm(std::chrono::milliseconds(10));

Once the timer expires and the callback is invoked the timer will not automatically be re-armed. That is, it is a one-shot timer. However, the timer can be re-armed by calling arm again (even from within the callback) which provides the expected behavior.

seastar::timer tm([] { tm.arm(std::chrono::milliseconds(10)); });
tm.arm(std::chrono::milliseconds(10));

The API has some important restrictions on usage. First, once a timer is armed care must be taken to not arm the timer again until it has expired (this is a hard stop assertion failure). Use the armed method to inspect the state of the timer and cancel to stop a timer before it expires.

seastar::timer tm;
assert(!tm.armed());

tm.arm(std::chrono::milliseconds(10));
assert(tm.armed());

tm.cancel();
assert(!tm.armed());

Cancelling a timer and adjusting the expiration time by re-arming the timer is such a common pattern that the timer encapsulates this pattern in a single method called rearm.

void rearm(time_point until, compat::optional<duration> period = {}) {
    if (_armed) {
        cancel();
    }
    arm(until, period);
}

Instead of re-arming a timer in the timer’s callback, a periodic timer can be created which will re-arm itself automatically.

tm.arm_periodic(std::chrono::milliseconds(10));

Out of the box the Seastar timer is configured to use a high-resolution clock (i.e. timer expiration attempts to be precise). However, there are performance and efficiency advantages to using a low-resolution clock. If precision is not of particular concern a low-resolution clock (10ms at the time of writing) can be used:

seastar::timer<seastar::lowres_clock> tm;

And that is the Seastar timer. There are a few other interfaces worth exploring in the interface itself, but they are all variations on the interfaces we have discussed here. For instance, expiration times can be expressed as relative times from now or at specific points in the future.