ztask-cpp is a C++14 header-only cooperative task scheduler designed for resource-constrained embedded systems. It provides deterministic, zero-allocation task scheduling with precise timing control.
- Zero heap allocation: All memory allocated at compile-time
- Deterministic performance: O(1) poll operation for ready task detection
- Low overhead: Minimal per-task memory footprint
- Type safety: C++ templates with compile-time capacity checking
- Platform agnostic: No OS dependencies, works on bare-metal or RTOS
- Low-power friendly: Precise sleep calculation via
TicksToNextTask()
┌─────────────────────────────────────────────────────────────┐
│ TaskScheduler<MaxTasks> │
├─────────────────────────────────────────────────────────────┤
│ - current_ticks_: TickType │
│ - active_count_: uint32_t │
│ - head_index_: uint32_t │
│ - slots_[MaxTasks]: TaskSlot[] │
├─────────────────────────────────────────────────────────────┤
│ + Tick() │
│ + Poll() -> uint32_t │
│ + Bind(...) -> TaskId │
│ + Unbind(TaskId) │
│ + TicksToNextTask() -> TickType │
└─────────────────────────────────────────────────────────────┘
Why not a heap/priority queue?
- Heap operations require O(log n) time for insert/extract
- Dynamic allocation incompatible with embedded constraints
- Cache-unfriendly pointer chasing
Why not a simple array scan?
- O(n) poll operation unacceptable for real-time systems
- Wastes CPU cycles checking unready tasks
Chosen approach: Sorted intrusive linked list
- Tasks stored in fixed array (cache-friendly, no allocation)
- Intrusive
next_indexfield links tasks bynext_schedule(ascending) - Head always points to earliest task
- Poll checks only head: O(1) operation
- Insert: O(n) worst-case, but infrequent (task binding)
- Remove: O(n) worst-case, but infrequent (task unbinding)
TaskSlot structure (32 bytes on 32-bit platform):
┌──────────────────────────────────────────────────────────┐
│ fn: TaskFn (4/8 bytes) │
│ ctx: void* (4/8 bytes) │
│ repeat_ticks: TickType (4/8 bytes) │
│ next_schedule: TickType (4/8 bytes) │
│ next_index: uint32_t (4 bytes) │
│ generation: uint8_t (1 byte) │
│ active: bool (1 byte) │
│ [padding: 2 bytes] │
└──────────────────────────────────────────────────────────┘
TaskScheduler<16> total size: ~544 bytes (32-bit platform)
- slots_[16]: 512 bytes
- current_ticks_: 4 bytes
- active_count_: 4 bytes
- head_index_: 4 bytes
- [padding/alignment]: ~20 bytes
Problem: Prevent use-after-free when task is unbound and slot reused.
Solution: Encode generation counter in TaskId.
TaskId (16-bit):
┌────────────────┬────────────────┐
│ generation (8) │ slot_index (8) │
└────────────────┴────────────────┘
- slot_index: 0-255 (supports up to 256 tasks)
- generation: 0-255 (wraps around, provides ABA protection)
Validation:
void Unbind(TaskId id) {
uint32_t slot_idx = id & 0xFF;
uint8_t generation = id >> 8;
if (!slots_[slot_idx].active ||
slots_[slot_idx].generation != generation) {
return; // Stale ID, ignore
}
// ... proceed with unbind
}The scheduler is agnostic to tick source. Common patterns:
-
Hardware timer ISR:
void TIM2_IRQHandler() { scheduler.Tick(); }
-
Main loop with timestamp:
uint32_t last_tick = HAL_GetTick(); while (1) { uint32_t now = HAL_GetTick(); if (now != last_tick) { scheduler.Tick(); last_tick = now; } scheduler.Poll(); }
-
RTOS task:
void scheduler_task(void* arg) { while (1) { vTaskDelay(pdMS_TO_TICKS(1)); scheduler.Tick(); scheduler.Poll(); } }
Choose tick period based on application requirements:
- 1 ms: General-purpose (most common)
- 100 µs: High-frequency control loops
- 10 ms: Low-power applications
uint32_t Poll() {
uint32_t executed = 0;
while (head_index_ != kNoTask) {
TaskSlot& head = slots_[head_index_];
// Early exit: head is earliest task, if not ready, none are
if (head.next_schedule > current_ticks_) {
break;
}
// Remove from list before execution (allows Unbind in task fn)
uint32_t exec_idx = head_index_;
head_index_ = head.next_index;
// Execute
slots_[exec_idx].fn(slots_[exec_idx].ctx);
++executed;
// Reschedule or unbind
if (slots_[exec_idx].active) {
if (slots_[exec_idx].repeat_ticks > 0) {
// Periodic: reinsert into sorted list
slots_[exec_idx].next_schedule =
current_ticks_ + slots_[exec_idx].repeat_ticks;
InsertSorted(exec_idx);
} else {
// One-shot: mark inactive
slots_[exec_idx].active = false;
slots_[exec_idx].generation++;
--active_count_;
}
}
}
return executed;
}Key properties:
- O(1) ready check (head comparison)
- Executes all ready tasks in one call
- Safe for task to call
Unbind()on itself - Periodic tasks automatically rescheduled
Low-power applications need to sleep CPU between tasks:
while (1) {
scheduler.Tick();
scheduler.Poll();
auto remaining = scheduler.TicksToNextTask();
if (remaining > 0 && remaining != UINT32_MAX) {
// Enter low-power mode for 'remaining' ticks
HAL_PWR_EnterSLEEPMode(remaining);
}
}TickType TicksToNextTask() const {
if (head_index_ == kNoTask) {
return static_cast<TickType>(-1); // No tasks
}
const TaskSlot& head = slots_[head_index_];
if (head.next_schedule <= current_ticks_) {
return 0; // Task ready now
}
return head.next_schedule - current_ticks_;
}Complexity: O(1) (head access only)
| Aspect | C ztask | ztask-cpp |
|---|---|---|
| Language | C99 | C++14 |
| Capacity | Runtime (malloc) | Compile-time (template) |
| Type safety | void* casts | Template parameters |
| Inlining | Limited | Aggressive (template instantiation) |
| Code size | Smaller (single implementation) | Larger (per-instantiation) |
| Performance | Baseline | 5-7% faster (inlining) |
| Memory safety | Manual | RAII-friendly |
| Exceptions | N/A | Compatible with -fno-exceptions |
When to use ztask-cpp:
- C++ codebase
- Known task count at compile-time
- Need type safety and RAII integration
- Performance-critical applications
When to use C ztask:
- C-only codebase
- Dynamic task count requirements
- Minimal code size priority
| Operation | Best Case | Average Case | Worst Case |
|---|---|---|---|
| Tick() | O(1) | O(1) | O(1) |
| Poll() | O(1) | O(k) | O(n) |
| Bind() | O(1) | O(n/2) | O(n) |
| Unbind() | O(1) | O(n/2) | O(n) |
| TicksToNextTask() | O(1) | O(1) | O(1) |
k = number of ready tasks, n = active task count
- Per-scheduler overhead: 12 bytes (current_ticks, active_count, head_index)
- Per-task overhead: 32 bytes (TaskSlot)
- Total:
12 + MaxTasks * 32bytes
Example:
TaskScheduler<8>: 268 bytesTaskScheduler<16>: 524 bytesTaskScheduler<32>: 1036 bytes
ztask-cpp is NOT thread-safe by design.
Rationale:
- Target platforms often single-threaded (bare-metal MCU)
- Locking overhead unacceptable for real-time systems
- User can add external synchronization if needed
Safe usage patterns:
- Single-threaded: All calls from main loop (most common)
- ISR + main loop:
Tick()in ISR,Poll()in main loop (safe if ISR doesn't call other methods) - RTOS: Wrap scheduler in mutex if accessed from multiple tasks
Problem: current_ticks_ wraps around after 2^32 - 1 (49 days at 1ms tick).
Mitigation: Use unsigned arithmetic, which handles wraparound correctly:
// Works correctly even with wraparound
if (head.next_schedule <= current_ticks_) { ... }Limitation: Tasks scheduled more than 2^31 ticks in future may misbehave.
Scenario: Task function calls Unbind() on its own TaskId.
Handling: Poll() removes task from list before execution, checks active flag after execution. Safe by design.
Behavior: Bind() returns kInvalidId. Caller must handle gracefully.
Recommendation: Size MaxTasks with headroom (e.g., 2x expected peak).
Potential improvements (not yet implemented):
- Priority levels: Multiple sorted lists per priority
- Task groups: Bulk enable/disable related tasks
- Statistics: Track execution time, missed deadlines
- Deadline monitoring: Warn if task execution exceeds period
- Multi-core support: Per-core schedulers with work stealing
- Original C ztask: github.com/zephyrproject-rtos/zephyr (inspiration)
- Intrusive containers: Boost.Intrusive design patterns
- Embedded scheduling: "Real-Time Systems" by Jane W. S. Liu