Skip to content

Intrusive AVL Tree

A tutorial on how etl::intrusive_avl_tree can give ordered, 𝐎(log N) access to objects that already exist elsewhere, without a second container to track them.

Overview

Imagine a set of driver/sensor tasks, each owned by its own subsystem. They are created and destroyed at unpredictable times as modules attach and detach, so there is no compile-time upper bound on how many exist at once. A scheduler needs to visit the tasks that are currently due, in ascending deadline order, on every tick.

A normal fixed-capacity ordered container (etl::set/etl::map) would need a second, separately-sized place to store pointers to these tasks. Instead, embedding an etl::intrusive_avl_tree link directly into each task object gives the scheduler ordered, 𝐎(log N) access to exactly the tasks that exist right now - nothing more is allocated, and nothing needs to be kept in sync by hand. That’s the payoff of “intrusive”: the tree links the objects that already exist, rather than storing copies of them.

The example below also demonstrates two things that fall out of the design for free:

  • Move support. When the std::vector that owns the tasks reallocates, every task is moved into new storage - and each move takes over its task’s exact tree position, with no rebalancing and no code of its own.
  • Safe removal without an explicit erase() call. Removing a task’s owning object removes it from the schedule too, with no dangling entry left behind - whether that’s because the object is destroyed while still linked (link_type’s destructor unlinks it automatically), or, as in this example, because std::vector::erase move-assigns another task over it (link_type’s move assignment unlinks this before taking over the moved-from tree position).

Example

#include "etl/intrusive_avl_tree.h"

#include <cstdint>
#include <iostream>
#include <vector>
#include <algorithm>

// A driver/sensor task that already exists as a long-lived object owned by
// its own subsystem. Inheriting from the tree's link_type is all it takes
// to make it schedulable - no separate fixed-capacity container is needed
// to track which tasks exist.
class SensorTask : public etl::intrusive_avl_tree_base<0>::link_type
{
public:
  SensorTask(uint32_t id_, uint32_t deadline_, uint32_t period_)
    : id(id_), deadline(deadline_), period(period_)
  {
  }

  // Orders primarily by deadline; ties are broken by id so two tasks due
  // on the same tick still occupy distinct tree positions.
  struct Locate
  {
    uint32_t target_deadline;
    uint32_t target_id;

    int operator()(const SensorTask& node) const
    {
      if (target_deadline != node.deadline)
      {
        return (target_deadline < node.deadline) ? -1 : 1;
      }
      if (target_id != node.id)
      {
        return (target_id < node.id) ? -1 : 1;
      }
      return 0;
    }
  };

  uint32_t id;
  uint32_t deadline; // next tick at which this task must be serviced
  uint32_t period;
};

typedef etl::intrusive_avl_tree<SensorTask> Schedule;

// The only place a task gets linked - called whenever a module attaches,
// or whenever a task is rescheduled after being serviced.
void schedule_task(Schedule& schedule, SensorTask& task)
{
  schedule.find_or_insert(SensorTask::Locate{ task.deadline, task.id },
                           [&task] { return &task; });
}

// Visits every currently-due task in ascending deadline order, services it,
// then reschedules it at its next deadline.
void service_due_tasks(Schedule& schedule, uint32_t now)
{
  for (auto it = schedule.begin(); (it != schedule.end()) && (it->deadline <= now); )
  {
    SensorTask& task = *it;
    it = schedule.erase(it); // O(log N), rebalances

    std::cout << "servicing task " << task.id << " (was due at " << task.deadline << ")\n";

    task.deadline = now + task.period;
    schedule_task(schedule, task); // reschedule at the new deadline
  }
}

void print_schedule(const Schedule& schedule)
{
  for (auto it = schedule.begin(); it != schedule.end(); ++it)
  {
    std::cout << "  task " << it->id << " due at " << it->deadline << "\n";
  }
}

int main()
{
  Schedule schedule;

  // Driver registry: a plain std::vector that a subsystem uses to own its
  // tasks for whatever reason it already needs to - the tree does not
  // require this vector to exist, it's just a stand-in for "wherever the
  // real owner happens to keep the object".
  std::vector<SensorTask> driver_registry;
  driver_registry.reserve(2);

  driver_registry.emplace_back(1, 100, 1000);
  schedule_task(schedule, driver_registry.back());

  driver_registry.emplace_back(2, 300, 1000);
  schedule_task(schedule, driver_registry.back());

  std::cout << "Initial schedule (ascending deadline):\n";
  print_schedule(schedule);

  std::cout << "\nschedule.size() before growth: " << schedule.size() << "\n";

  // driver_registry.reserve()'d capacity was 2; this emplace_back forces a
  // reallocation. std::vector move-constructs every existing SensorTask
  // into the new storage. Each move steals its task's exact tree position
  // (O(1), no rebalancing) - the tree needs no code of its own to survive
  // the underlying storage moving.
  driver_registry.emplace_back(3, 200, 1100);
  schedule_task(schedule, driver_registry.back());

  std::cout << "schedule.size() after growth:  " << schedule.size() << "\n\n";

  std::cout << "Schedule after growth (still correctly ordered):\n";
  print_schedule(schedule);

  std::cout << "\nServicing due tasks at tick 250:\n";
  service_due_tasks(schedule, 250);

  std::cout << "\nSchedule after servicing:\n";
  print_schedule(schedule);

  // Unplugging a module removes its SensorTask from driver_registry. Task 2
  // isn't the last element, so std::vector::erase() doesn't just destroy it
  // in place: it move-assigns the following element (task 3) into task 2's
  // slot, then destroys the now-duplicate last slot. That move-assignment
  // is SensorTask's (implicitly-generated) one, which calls
  // link_type::operator=(link_type&&): it first unlinks *this* - task 2's
  // slot, still linked at deadline 300 - from the tree, then takes over
  // task 3's tree position. The destructor that runs afterwards, on the
  // vacated last slot, fires on an already-unlinked, moved-from object, so
  // it has nothing left to do - no explicit schedule.erase() call is made
  // here, and no dangling entry is left behind.
  std::cout << "\nschedule.size() before unplugging task 2: " << schedule.size() << "\n";

  auto pos = std::find_if(driver_registry.begin(), driver_registry.end(),
                           [](const SensorTask& t) { return t.id == 2; });
  driver_registry.erase(pos); // move-assigns task 3 over task 2, unlinking task 2

  std::cout << "schedule.size() after unplugging task 2:  " << schedule.size() << "\n";

  return 0;
}

Program output

Initial schedule (ascending deadline):
  task 1 due at 100
  task 2 due at 300

schedule.size() before growth: 2
schedule.size() after growth:  3

Schedule after growth (still correctly ordered):
  task 1 due at 100
  task 3 due at 200
  task 2 due at 300

Servicing due tasks at tick 250:
servicing task 1 (was due at 100)
servicing task 3 (was due at 200)

Schedule after servicing:
  task 2 due at 300
  task 1 due at 1250
  task 3 due at 1350

schedule.size() before unplugging task 2: 3
schedule.size() after unplugging task 2:  2