Another code snippet that can be found in a few of my projects is the “time queue”, which is a simple ‘priority queue’ style data structure that I use to defer actions to a later time.
With this specific data structure, I have multiple implementations that clearly came from the same source. One indicator for that is a snarky comment in both about how std::list
is clearly not the best choice for the underlying data structure. They have diverged a bit since then though.
Requirements
In my use case not use time points, but only durations in standard-library nomenclature. This is a pretty restrictive requirement, because otherwise any priority queue (e.g. from boost or even from the standard library) can be used quite well. On the other hand, it allows me to use floating-point durations with predictable accuracy. The queue has two important functions:
insert
to insert a timeout duration and a payload.
tick
is called with a specific duration and then reports the payloads that have timed out since their insertions.
Typically tick
is called a lot more frequently than insert, and it should be fast. The payload is typically something like a std::function
or an id for a state-machine that needs to be pulsed.
The basic idea is to only keep the duration difference to the previous item in the list. Only the first item keeps its total timeout. This way, when tick is called, usually only the first item needs to be updated. tick only has to touch more items when they time out.
Simple Implementation
One of the implementations for void insert(TimeType timeout, PayloadType payload)
looks like this:
if (tick_active_)
{
deferred_.push_back({ .remaining = after, .payload = std::move(payload) });
return;
}
auto i = queue_.begin();
for (; i != queue_.end() && timeout > i->remaining; ++i)
timeout -= i->remaining;
if (i != queue_.end())
i->remaining -= timeout;
queue_.insert(i, { .remaining = after, .payload = std::move(payload) });
There is a special case there that guards against inserting into queue_
(which is still a very bad std::list
) by instead inserting into deferred_
(which is a std::vector
, phew). We will see why this is useful in the implementation for template void tick(TimeType delta, Executor execute)
:
tick_active_ = true;
auto i = queue_.begin();
for (; i != queue_.end() && delta >= i->remaining; ++i)
{
delta -= i->remaining;
execute(i->payload);
}
if (i != queue_.end())
i->remaining -= delta;
queue_.erase(queue_.begin(), i);
tick_active_ = false;
while (!deferred_.empty())
{
auto& entry = deferred_.back();
insert(entry.remaining, std::move(entry.payload));
deferred_.pop_back();
}
The timed out items are reported via a callback that is supplied as Executor execute
. Of course, these can do anything, including insert
ing new items, which can invalidate the iterator. This is a common use case, in fact, as many deferred actions will naturally want follow ups (let’s ignore for the moment that the implementation is nowhere near exception safe…). The items that were deferred to deferred_
in insert get added to queue_
after the iteration is complete.
This worked well enough to ship, but the other implementation had another good idea. Instead of reporting the timed-out items to a callback, it just returned them in a vector. The whole tick_active_
guard becomes unnecessary, as any processing on the returned items is naturally deferred until after the iteration:
std::vector<PayloadType> tick(TimeType delta)
{
std::vector<PayloadType> result;
auto i = queue_.begin();
for (; i != queue_.end() && delta >= i->remaining; ++i)
{
delta -= i->remaining;
result.push_back(i->payload);
}
if (i != queue_.end())
i->remaining -= delta;
queue_.erase(queue_.begin(), i);
return result;
}
This solves the insert
-while-tick
problem, and lets us use the result neatly in a range-based for-loop like this: for (auto const& payload : queue.tick(delta)) {}
. Which I personally always find a little bit nicer than inversion-of-control. However, the cost is at least one extra allocation for timed-out items. This might be acceptable, but maybe we can do better for very little extra complexity.
Return of the second list
Edit: The previous version of this article tried to keep the timed-out items at the beginning of the vector before returning them as a std::span
. As commenter Steffen pointed out, this again prevents us from inserting while iterating on the result, as any insert might invalidate the backing-vector.
We can get rid of the allocation for most of the tick calls, even if they return a non-empty list. Remember that a std::vector
does not deallocate its capacity even when it’s clear
ed unless that is explicitly requested, e.g. via shrink_to_fit
. So instead of returning a new vector each time, we’re keeping one around for the timed out items and return a const-ref to it from tick
:
std::vector<PayloadType> const& tick(TimeType delta)
{
timed_out_.clear();
auto i = queue_.begin();
for (; i != queue_.end() && delta >= i->remaining; ++i)
{
delta -= i->remaining;
timed_out_.push_back(std::move(i->payload));
}
if (i != queue_.end())
i->remaining -= delta;
queue_.erase(queue_.begin(), i);
return timed_out_;
}
This solution is pretty similar to the deferred list from the first version, but instead of ‘locking’ the main list while iterating, we’re now separating the items we’re iterating on.