AceRoutine

repository·develop·Indexed 41 days ago

https://github.com/bxparks/aceroutine

A lightweight, cooperative multitasking library for Arduino that implements stackless coroutines. Designed for high performance and low memory usage on 8-bit and 32-bit microcontrollers, it allows multiple tasks to run concurrently using a ProtoThreads-based model. Features include a CoroutineScheduler for automatic task management, profiling tools for execution time, and experimental synchronized Channels for inter-coroutine communication.

Tokens
19.3K
Snippets
56
Records
81
Agent score
51%

What's inside AceRoutine

  1. Overview of AceRoutine

    develop

    AceRoutine is a low-memory, fast-switching, cooperative multitasking library for Arduino platforms. It implements stackless coroutines based on the ProtoThreads model, allowing execution to suspend via yield() or delay() and resume later. This allows multiple tasks to appear to run concurrently on microcontrollers with limited resources.

    Core Components

    • Coroutine: Provides context variables for individual coroutines.
    • CoroutineScheduler: Handles the scheduling of multiple coroutines (optional).

    Key Features

    • Low Memory Footprint: Optimized for both 8-bit (AVR) and 32-bit (ESP32, STM32, etc.) processors.
    • Fast Context Switching: Uses GCC/Clang 'computed goto' to avoid Duff's Device hacks, enabling switch statements within coroutines.
    • Profiling: Version 1.5+ includes tools to profile Coroutine::runCoroutine() execution time and render results as tables or JSON.
    • Experimental Channels: An unbuffered, synchronized Channel class allows one reader and one writer to communicate.
  2. Understand the ChannelBenchmark concept

    develop

    The ChannelBenchmark is a performance test designed to estimate the CPU overhead associated with reading from and writing to an AceRoutine Channel.

    It compares two modes:

    1. Baseline: A single Coroutine that increments a counter to a target NUM_COUNT. This measures the raw cost of coroutine execution/iteration.
    2. Channels: The baseline coroutine is augmented with a writer Coroutine (which writes to a Channel) and a reader Coroutine (which reads from that same Channel).

    The difference in microseconds per iteration (diff) between the 'Channels' mode and the 'Baseline' mode represents the approximate overhead introduced by the Channel's read and write operations.

  3. Chain Coroutines using runCoroutine()

    develop

    Coroutines can be chained by explicitly calling the runCoroutine() method of one coroutine from within another. This is useful for implementing the Decorator pattern or a chain of responsibility.

    When chaining, the outer coroutine can simply delegate to the inner one without needing its own COROUTINE_BEGIN()/COROUTINE_END() markers if it is purely a wrapper.

    class InnerCoroutine : public Coroutine {
      int runCoroutine() override {
        COROUTINE_BEGIN();
        // ...
        COROUTINE_END();
      }
    };
    
    class OuterCoroutine : public Coroutine {
      InnerCoroutine& mInner;
    public:
      OuterCoroutine(InnerCoroutine& inner) : mInner(inner) {}
    
      int runCoroutine() override {
        // Delegation: no BEGIN/END needed here
        return mInner.runCoroutine();
      }
    };
  4. Manage multiple coroutines with CoroutineScheduler

    develop

    The CoroutineScheduler automatically manages all coroutines defined via the COROUTINE() macro, so you don't have to manually call .runCoroutine() for each one in your loop().

    Usage:

    1. Call CoroutineScheduler::setup() in setup().
    2. Call CoroutineScheduler::loop() in loop().

    Resource Considerations:

    • Recommended for: 32-bit processors.
    • Overhead on 8-bit processors:
      • Increases flash memory by ~110 bytes for the singleton instance.
      • Each Coroutine instance consumes an additional ~70 bytes of flash.
      • CoroutineScheduler::loop() uses virtual dispatch, which is slower and uses more flash than direct calls.
    #include <AceRoutine.h>
    using namespace ace_routine;
    
    // ... COROUTINE definitions ...
    
    void setup() {
      // ...
      CoroutineScheduler::setup();
    }
    
    void loop() {
      CoroutineScheduler::loop();
    }
  5. How AceRoutine implements coroutines

    develop

    AceRoutine implements coroutines using the GCC language extension called computed goto (Labels-as-Values). This approach offers several advantages over traditional implementations like Duff's Device:

    1. Compatibility with switch statements: Unlike Duff's Device, computed gotos allow you to use switch statements inside your coroutines.
    2. C++ Class Integration: Instead of passing around a struct for context variables (as required in C), AceRoutine uses C++ classes. The coroutine's context is stored within the class member variables, making the state management transparent to the user.
    3. Boilerplate Reduction: The library provides macros like COROUTINE() and EXTERN_COROUTINE() to minimize the amount of code required to define and use coroutines.

    AceRoutine is designed as a standalone Arduino library and does not require a larger framework like Cosa.

  6. Handle stackless memory constraints and local variables

    develop

    AceRoutine coroutines are stackless. The stack is destroyed and recreated on every invocation. This has critical implications for memory:

    1. Local Variables: Standard local variables do not preserve their value across COROUTINE_YIELD(), COROUTINE_DELAY(), or COROUTINE_AWAIT().
    2. Local Objects: Using objects with non-trivial destructors (like String) across a yield point will cause the destructor to be called incorrectly upon resumption, likely crashing the program.

    Best Practices for State

    • Scope Limiting: Wrap local variables/objects in a { } block so they are fully destroyed before a yield point.
    • Static Variables: Use static variables inside the COROUTINE() to preserve state across yields. Note that static variables are shared across all instances of that coroutine.
    • Class Members: For manual coroutines (subclassing Coroutine), use private member variables to store state. This ensures each instance has its own unique state.
    // INCORRECT: String will crash on resumption
    COROUTINE(badExample) {
      COROUTINE_BEGIN();
      String s = "hello"; 
      COROUTINE_DELAY(1000);
      COROUTINE_END();
    }
    
    // CORRECT: Scope limiting
    COROUTINE(goodExample) {
      COROUTINE_BEGIN();
      {
        String s = "hello";
        Serial.println(s);
      } // s is destroyed here
      COROUTINE_DELAY(1000);
      COROUTINE_END();
    }
    
    // CORRECT: Using static for state
    COROUTINE(statefulExample) {
      COROUTINE_BEGIN();
      static int count = 0;
      count++;
      COROUTINE_DELAY(1000);
      COROUTINE_END();
    }
  7. How coroutine lifecycle and scheduling works via the Coroutine Linked List

    develop

    AceRoutine manages coroutines using a singly linked list. When you create a coroutine using the COROUTINE() macro, the instance is automatically inserted into a global linked list. This structure allows the CoroutineScheduler to perform round-robin execution by iterating through the list.

    Key Mechanics:

    • Automatic Registration: The COROUTINE() macro handles the constructor logic that inserts the new instance into the list.
    • The Root Pointer: The list starts at a static root pointer, accessible via the static method Coroutine::getRoot(). This method returns a pointer to the pointer (Coroutine**), which allows for easier manipulation of the list head.
    • Scheduler Traversal: The CoroutineScheduler tracks the active coroutine using an mCurrent pointer of type Coroutine**. Using a pointer-to-a-pointer allows the scheduler to:
      • Treat the root node identically to any other node in the list.
      • Insert new nodes immediately after the current node by modifying the mNext pointer of the previous node.
      • Facilitate list traversal and potential node deletion without needing a doubly linked list.
    // Concept: Coroutine Linked List Structure
    // CoroutineScheduler (mCurrent: Coroutine**) -> Coroutine (mNext: Coroutine*) -> Coroutine (mNext: Coroutine*) -> nullptr
  8. How Coroutine Profiling works

    develop

    AceRoutine provides a profiling mechanism to measure the execution time of coroutines. It uses a CoroutineProfiler interface to track how long Coroutine::runCoroutine() takes to execute.

    When profiling is enabled, you use Coroutine::runCoroutineWithProfiler() (or CoroutineScheduler::loopWithProfiler() when using a scheduler) to measure the elapsed time in microseconds and update the attached profiler.

    Resource Impact:

    • Static RAM: Increases by 2 bytes (8-bit) or 4 bytes (32-bit) per coroutine regardless of whether profiling is used, due to the internal profiler pointer.
    • Flash: Using profiling methods increases flash usage by approximately 50-80 bytes per coroutine due to virtual dispatch.
    • CPU Overhead: On 8-bit AVR, expect ~3 microseconds of latency per call. On 32-bit processors (ESP32, STM32), the overhead is negligible (0.1 - 0.4 micros).
    class CoroutineProfiler {
      public:
        virtual void updateElapsedMicros(uint32_t micros) = 0;
    };
  9. Understand the AutoBenchmark methodology

    develop

    The AutoBenchmark measures the overhead of context switching between coroutines in AceRoutine.

    It works by using two alternating coroutines that increment a global counter for a set duration. Each increment triggers a Coroutine context switch. The benchmark compares this against a baseline (a simple while-loop) to calculate the overhead.

    Key Metrics:

    • AceRoutine column: The time in microseconds it takes to increment the counter by 1 using coroutines.
    • base column: The time in microseconds for the baseline while-loop.
    • diff column: The calculated overhead caused by the Coroutine context switch.

    All time measurements are in microseconds.

  10. Suspend and Resume Coroutines

    develop

    The Coroutine::suspend() and Coroutine::resume() methods allow you to pause and restart coroutines.

    Critical Constraints:

    • Scheduler Required: These methods only work if you are using the CoroutineScheduler. They have no effect if coroutines are called directly in loop().
    • Call Location: You must not call suspend() or resume() from inside the coroutine itself. They must be called from outside the coroutine (e.g., from the main loop or another coroutine).
    • Behavior: When a coroutine is suspended, the CoroutineScheduler will skip it during its next loop iteration until it is resumed.
  11. Communicate between Coroutines using Channels (Experimental)

    develop

    The Channel<T> class provides an unbuffered, bidirectional point of synchronization between coroutines, inspired by Go Lang channels.

    Synchronization Behavior:

    • The writer blocks (yields) until the reader is ready.
    • The reader blocks (yields) until the writer is ready.
    • When a writer writes, the reader picks up the message and is allowed to continue execution before the writer is allowed to continue.
    • The writer continues execution only after the reader yields.

    Limitations:

    • Only a single Coroutine can write to a Channel.
    • Only a single Coroutine can read from a Channel.
    • No select statement (cannot wait for multiple channels simultaneously).
    • No buffered channels.
    • No way to close a channel.

    Usage:

    1. Define a message class (often containing a status field).
    2. Instantiate Channel<MessageType> channel;.
    3. Inject the channel into the constructors of the Writer and Reader coroutines.
    4. Use COROUTINE_CHANNEL_WRITE(channel, value) to send data.
    5. Use COROUTINE_CHANNEL_READ(channel, value) to receive data.
    class Message {
      static uint8_t const kStatusOk = 0;
      static uint8_t const kStatusError = 1;
      uint8_t status;
      int value;
    };
    
    Channel<Message> channel;
    
    // In Writer coroutine:
    COROUTINE_CHANNEL_WRITE(mChannel, message);
    
    // In Reader coroutine:
    COROUTINE_CHANNEL_READ(mChannel, message);