Seastar's timer utility
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.