Getting straight to the point - it takes the functions, adds them to queue and executes them after their timer expires. when a task is added using add() function, it returns a cancellation token to the caller which can be used later to cancel the task execution before it is started
https://github.com/msk1039/task-scheduler
learncpp chapter 20.1 - https://www.learncpp.com/cpp-tutorial/function-pointers/ learncpp chapter 20.6 - https://www.learncpp.com/#:~:text=(anonymous%20functions)
You give it a lambda (or any callable), a delay in milliseconds, and it runs that function after the delay expires. When you call add(), you get back a CancellationToken. Hold onto that token — if you change your mind before the task fires, call .cancel() on it and the scheduler skips it.
Single header file. No dependencies beyond the standard library. Drop scheduler.hpp into your project and you are done.
The whole thing lives in the scheduler namespace and is made of three classes:
| Class | Job |
|---|---|
CancellationToken | Returned to the caller. Holds a shared atomic flag and the task id. Calling .cancel() flips the flag. |
Task | Stored inside the queue. Holds the callable, the scheduled fire time, and a pointer to the same shared flag. |
Scheduler | Owns the priority queue. Exposes add() and run(). |
scheduler::add() worksWhen you call add(job, delay):
id is grabbed by atomically incrementing next_id.shared_ptr<atomic<bool>> cancellation flag is created, initially false.steady_clock::now() + delay.Task is constructed with the id, the callable, the fire time, and a copy of the flag pointer, then pushed into the min-heap.CancellationToken is constructed with the same id and the same flag pointer and returned to the caller.Both the Task inside the queue and the CancellationToken in the caller's hands point at the same atomic flag. That shared pointer is the entire cancellation mechanism.

Flowchart 1 — a linear flow:
add(job, delay)→ fetch-add id → create shared atomic flag → computenow() + delay→ pushTaskto min-heap → construct and returnCancellationToken. Highlight the shared pointer arrow going to both the Task node and the CancellationToken node.
Scheduler keeps a std::priority_queue with std::greater<Task> as the comparator:
std::priority_queue<Task, std::vector<Task>, std::greater<Task>> queue;Task defines operator> to compare by scheduled_time:
bool operator>(const Task& other) const {
return scheduled_time > other.scheduled_time;
}std::greater<Task> internally calls a > b, so the task with the earliest fire time always sits at the top. Every time a new task is pushed, the heap re-balances in O(log n).

scheduler::run() worksrun() is a blocking loop that drains the queue:
void run() {
while (!queue.empty()) {
std::this_thread::sleep_until(queue.top().getScheduledTime());
while (!queue.empty() && queue.top().isExpired()) {
Task task = queue.top();
queue.pop();
if (!task.isCancelled()) {
task.execute();
}
}
}
}Step by step:
sleep_until — the thread sleeps precisely until the next task's fire time. No busy-waiting, no polling.execute(), it checks isCancelled(). If the caller flipped the flag via their token, the task is silently skipped.execute() wraps the callable in a try/catch. A throwing task prints to stderr but does not crash the scheduler or skip the remaining queue.
Flowchart 2 — Start → queue empty? (yes → end) →
sleep_untiltop task time → inner loop: queue empty or top not expired? (yes → back to outer loop) → pop task →isCancelled()? (yes → skip, back to inner loop) →execute()→ catch exceptions → back to inner loop.
class CancellationToken {
uint64_t id;
std::shared_ptr<std::atomic<bool>> canceled_flag;
public:
void cancel() { canceled_flag->store(true); }
bool isCanceled() const { return canceled_flag->load(); }
};cancel() does a single atomic store. isCancelled() inside Task does a single atomic load. No mutex, no lock. Safe to call from any thread at any time — even while run() is sleeping.
#include "scheduler.hpp"
#include <iostream>
#include <chrono>
int main() {
scheduler::Scheduler s;
s.add([]{ std::cout << "fires after 1 second\n"; },
std::chrono::milliseconds(1000));
auto token = s.add([]{ std::cout << "this will never print\n"; },
std::chrono::milliseconds(500));
token.cancel(); // cancel before run() even starts
s.add([]{ std::cout << "fires after 200ms\n"; },
std::chrono::milliseconds(200));
s.run();
// output:
// fires after 200ms
// fires after 1 second
}The 200 ms task fires first because the heap always surfaces the earliest task, even though it was added last. The 500 ms task was cancelled, so it is silently dropped.
run() blocks the calling thread. Tasks execute sequentially, not concurrently. Long-running tasks will delay later ones.sleep_until precision — actual wake time depends on the OS scheduler. On most systems this is within a few milliseconds of the requested time.add() should not be called from another thread while run() is executing without adding a mutex around the queue.