Threading Components of the Flowduino ESPressio Development Platform.
Light-weight and easy-to-use Threading for your Microcontroller development work.
The latest Stable Version is 1.3.0.
The ESPressio Development Platform is a collection of discrete (sometimes intra-connected) Component Libraries developed with a particular development ethos in mind.
The key objectives of the ESPressio Development Platform are:
- Light-weight - The Components should always strive to optimize memory consumption and operational overhead as much as possible, but not to the detriment of...
- Ease of Use - Many of our components serve as Developer-Friendly Abstractions of existing procedural code libraries.
- Object-Oriented - A
typefor everything, and everything in atype! - SOLID:
-
-
Single Responsibility Principle (SRP) Break your code into smaller, focused components.
-
-
-
Open/Closed Principle (OCP) Be open for extension but closed for modification.
-
-
-
Liskov Substitution Principle (LSP) Be substitutable for the base type without altering correctness.
-
-
-
Interface Segregation Principle (ISP) Break interfaces into specific, client-focused ones.
-
-
-
Dependency Inversion Principle (DIP) Be dependent on abstractions, not concretions.
-
To the maximum extent possible within the limitations/restrictons/constraints of the C++ langauge, the Arduino platform, and Microcontroller Programming itself, all Component Libraries of the ESPressio Development Platform must strive to honour the SOLID principles.
ESPressio (and its component libraries, including this one) are subject to the Apache License 2.0
Please see the accompanying this library for full details.
Every type/variable/constant/etc. related to ESPressio Threads are located within the Threads sub-namespace of the ESPressio parent namespace.
The namespace provides the following (click on any declaration to navigate to more info):
ESPressio::Threads::IThreadESPressio::Threads::ThreadESPressio::Threads::ManagerESPressio::Threads::GarbageCollectorESPressio::Threads::IThreadSafeESPressio::Threads::MutexESPressio::Threads::ReadWriteMutex
You can quickly and easily add this library to your project in PlatformIO by simply including the following in your platformio.ini file:
lib_deps =
flowduino/ESPressio-Threads@^1.3.0Alternatively, if you want to use the bleeding-edge (effectively "Developer Integration Testing" or "DIT") sources, you can instead use:
lib_deps =
https://github.com/Flowduino/ESPressio-Threads.gitPlease note that this will use the very latest commits pushed into the repository, so volatility is possible.
Threads enable us to perform concurrent and/or parallel processing on our microcontroller devices. In the case of multi-core microcontrollers, such as the ESP32, we can achieve true concurrent execution by using the components provided here in the ESPressio Thread Library.
By default, when an instance of a Thread descendant is created, presuming that you do not modify by calling SetCoreID() prior to initializing the instance, the Thread Manager will automatically allocate the Thread to the next available CPU Core. Single-core targets always use CPU 0.
For example, by default, your first Thread Instance will occupy CPU 0, your second will occupy CPU 1, your third will co-occupy CPU 0.
However, as hinted previously (and as you'll see later in this document) you can very easily define explicitly which CPU Core you want your Thread to run on.
Core ID, stack size, and priority are creation-time settings. Their setters are ignored while a FreeRTOS task exists, ensuring the corresponding getters cannot report settings different from the running task. Terminate or shut down the Thread before changing these settings for its next initialization.
Now, when your Microcontroller doesn't have multiple CPU Cores, or when you have multiple threads co-tenanting the same CPU Cores, Threads will operate on the princpals of Time Slicing. This is where Threads are executed in Parallel (not the same as Concurrent), and they each get slices of time within which to continue execution.
In this way, multiple distinct contexts can be progressed without having to wait for each of them to complete in turn.
Those of you familiar with multi-threading will already be aware of the need to enforce careful thread-safety when working with multiple Threads.
ESPressio Threads makes it easy, providing multiple choices of Thread-Safe Locks for you to easily use.
You'll see an example later in this document.
ESPressio Threads have been designed with ease of use in mind.
Ultimately, they are a carefully Managed Encapsulation of Tasks, abstracted to operate and interface more alike a true Thread in modern desktop and mobile development.
Let's take a look at a really simple implementation:
Before we define our Thread, we need to include the required header:
#include <ESPressio_Thread.hpp>Given that ESPressio Threads uses multi-tier Namespacing throughout, let's declare our Namespace so that we can reference the necessary type identifiers with less code:
using namespace ESPressio::Threads;With the required header linked, and the namespace defined, we can now define a simple Thread type, which we shall call MyFirstThread:
class MyFirstThread : public Thread {
protected:
void OnInitialization() override {
// Anything we need to do here prior to the Thread's Loop sstarting
}
void OnLoop() override {
// Whatever we want to do within the Loop
}
};NOTE: It is not necessary to override
OnInitializationunless you have a reason. It is virtual, not abstract.
We shall be building from this basic example class throughout the rest of this documentation!
So, the above class declaration doesn't really do anything... let's build upon it to illustrate how multiple Threads work:
class MyFirstThread : public Thread {
private:
int _counter = 0;
protected:
void OnInitialization() override {
// Anything we need to do here prior to the Thread's Loop sstarting
}
void OnLoop() override {
_counter++; // Increment the counter
// Let's display some information about our Thread...
Serial.printf("MyFirstThread::OnLoop() - Thread #%d - On CPU %d, Counter = %d", GetThreadID(), xPortGetCoreID(), _counter);
delay(1000); // Let's let this Thread wait for 1 second before it loops around again
}
};With the above changes, any instance of MyFirstThread will execute its OnLoop() method every one second, and each time it does, it'll increment a counter, then print out the following information in the Serial console:
- The Thread ID
- Which CPU the Thread is running on
- The value of the Counter
Admittedly, this isn't the most practical use of a Thread, however, it is an illustrative one.
Let's quickly assemble a program to use MyFirstThread:
MyFirstThread thread1;
void setup() {
Serial.begin(115200);
delay(500); // Small delay just so that the thread doesn't start before the Serial Monitor is ready
thread1.Initialize();
}That's all there is to it! If you push this program to your (compatible) microcontroller, it will immediately start printing the following into your Serial console (once per second):
MyFirstThread::OnLoop() - Thread 1 - On CPU 0, Counter = 0
MyFirstThread::OnLoop() - Thread 1 - On CPU 0, Counter = 1
MyFirstThread::OnLoop() - Thread 1 - On CPU 0, Counter = 2
MyFirstThread::OnLoop() - Thread 1 - On CPU 0, Counter = 3
Your existing loop() method will continue to operate exactly as it always has. On the ESP32, the default loop() method executes on CPU 1, while you will notice that your instance of MyFirstThread (thread1 in the above sample code) is running on CPU 0.
To make it easier to refer up and down, let's combine all of the code together now:
#include <ESPressio_Thread.hpp>
using namespace ESPressio::Threads;
class MyFirstThread : public Thread {
private:
int _counter = 0;
protected:
void OnInitialization() override {
// Anything we need to do here prior to the Thread's Loop sstarting
}
void OnLoop() override {
_counter++; // Increment the counter
// Let's display some information about our Thread...
Serial.printf("MyFirstThread::OnLoop() - Thread #%d - On CPU %d, Counter = %d", GetThreadID(), xPortGetCoreID(), _counter);
delay(1000); // Let's let this Thread wait for 1 second before it loops around again
}
};
MyFirstThread thread1;
void setup() {
Serial.begin(115200);
delay(500); // Small delay just so that the thread doesn't start before the Serial Monitor is ready
thread1.Initialize();
}So we've created one separate thread (ideally to execute on a separate CPU Core from the default application thread)... but what if we want more threads?
That's really not a problem.
Let's modify the previous example to create multiple Threads:
MyFirstThread thread1;
MyFirstThread thread2;
MyFirstThread thread3;
void setup() {
Serial.begin(115200);
delay(500); // Small delay just so that the thread doesn't start before the Serial Monitor is ready
thread1.Initialize();
thread2.Initialize();
thread3.Initialize();
}We've now added two additional threads, so our output will look something like this:
MyFirstThread::OnLoop() - Thread 1 - On CPU 0, Counter = 1
MyFirstThread::OnLoop() - Thread 2 - On CPU 1, Counter = 1
MyFirstThread::OnLoop() - Thread 3 - On CPU 0, Counter = 1
MyFirstThread::OnLoop() - Thread 1 - On CPU 0, Counter = 2
MyFirstThread::OnLoop() - Thread 3 - On CPU 0, Counter = 2
MyFirstThread::OnLoop() - Thread 2 - On CPU 1, Counter = 2
The explicit maximum number of ESPressio Threads supported by the library is 256, however the practical limit depends entirely on the specifications of your microcontroller. It's almost certainly going to be considerably lower than 256!
Attempting to construct another registered Thread after all 256 IDs are occupied throws ThreadLimitExceededException. This library configuration therefore requires C++ exception support to be enabled.
Library exceptions use a common type hierarchy. ThreadException is the root type. ThreadRegistrationException represents registration failures; ThreadLimitExceededException, ThreadDuplicateIDException, and ThreadInvalidRegistrationException derive from it. ThreadDuplicateIDException::GetThreadID() returns the conflicting custom ID, while ThreadInvalidRegistrationException reports an attempt to register a null pointer. ThreadExecutionException derives directly from ThreadException and reports an exception escaping OnLoop().
Initialize() returns ThreadInitializationStatus. Success means a task was created and initialization completed. The remaining values describe why initialization did not start or complete: AlreadyInitialized, InvalidState, ExitSignalUnavailable, TerminationDispatcherUnavailable, TerminationDispatchPending, TaskCreationFailed, ConcurrentInitializationLost, TerminatedDuringInitialization, or InitializationException.
InitializationException means that OnInitialization() or an initialization lifecycle callback threw an exception. The library catches the exception, terminates and deletes the still-gated FreeRTOS task, and leaves the Thread in the Terminated state so that no orphaned task remains.
Each Thread can optionally handle its own unsuccessful initialization outcomes by registering SetOnInitializationFailed() before calling Initialize(). The callback receives the Thread and the exact ThreadInitializationStatus:
thread1.SetOnInitializationFailed(
[](IThread* sender, ThreadInitializationStatus status) {
// Handle or report this Thread's initialization outcome.
}
);The callback runs synchronously on the caller of Initialize(), after internal initialization locks have been released, for every status other than Success (including AlreadyInitialized). Exceptions thrown by this callback are contained and do not replace the status returned by Initialize().
Start() also returns ThreadInitializationStatus. When it must create a task, it returns the exact result from Initialize() and does not attempt to start the Thread after a failed initialization. For an initialized, paused, or already-running Thread it performs the applicable start/resume operation and returns AlreadyInitialized; for a terminating or destroyed Thread it returns InvalidState. Existing calls may ignore the returned value, but custom classes implementing IThread directly must update their Start() override to use this return type.
Exceptions thrown by lifecycle callbacks are contained by the library. OnStateChange, OnInitialize, OnStart, OnPause, OnTerminate, OnTerminated, OnInitializationFailed, and OnDestroy cannot escape the public lifecycle operation that invoked them. OnStateChange and the state-specific callback are isolated from one another, so an exception in either handler does not suppress the other. During Initialize(), a lifecycle callback exception produces InitializationException after safe task cleanup; during other lifecycle operations it is contained without being rethrown. Applications should still handle and report errors inside callbacks.
State transitions and their callbacks are serialized across tasks. A callback may re-enter lifecycle methods; if OnStateChange moves the Thread into another state, the obsolete state-specific callback for the earlier transition is suppressed. Initialization exceptions are attributed only to callbacks executing on the task that invoked Initialize().
ThreadInitializationStatus status = thread1.Initialize();
if (status != ThreadInitializationStatus::Success) {
// Handle or report the initialization outcome.
}ThreadManager::Initialize() retains its original void interface and ignores individual outcomes. Use InitializeWithResults() when the application needs to inspect every registered Thread's result:
for (const ThreadInitializationResult& result :
ThreadManager::GetInstance()->InitializeWithResults()) {
if (result.status != ThreadInitializationStatus::Success) {
// Report result.threadID and result.status.
}
}The returned collection contains the Thread ID and initialization status in manager iteration order. It intentionally contains no Thread pointers whose lifetime could end after manager initialization. If a custom IThread implementation unexpectedly throws from Initialize(), its result is reported as InitializationException and the manager continues initializing the remaining Threads.
IThread and its derived objects are intentionally non-copyable and non-movable. Always pass them by reference or pointer. Two C++ objects must never represent or manage the same underlying FreeRTOS task, task handle, manager registration, synchronization state, or cleanup ownership. Prefer IThread& when a value is required to exist and a suitably owned IThread* or smart pointer when optional or transferred ownership is required.
In the previous example, you'll see that we manually called Initialize() on each instance of MyFirstThread.
Well, ESPressio Threads provides a central Thread Manager, and all of your Thread instances automatically register themselves with this Manager.
This means we can Initialize() all of our Thread Instances in a single command!
First we need to make sure we include the ThreadManager's header in our program:
#include <ESPressio_ThreadManager.hpp>Now we can modify the previous code example accordingly:
MyFirstThread thread1;
MyFirstThread thread2;
MyFirstThread thread3;
void setup() {
Serial.begin(115200);
delay(500); // Small delay just so that the thread doesn't start before the Serial Monitor is ready
Manager::Initialize();
}Now, all three of our MyFirstThread instances will start exactly as they did before, but we didn't have to explicitly Initialize() each of them separately.
ThreadManager::ForEachThread() and ThreadManager::Initialize() invoke Thread code without holding the manager's thread-list lock, so callbacks may safely re-enter the manager. The manager pins these operations while they run and defers automatic garbage-collection deletion until the final active iteration completes.
The manager stores an immutable registration record containing the assigned ID, non-owning Thread pointer, and assigned core. Lookups and initialization results use that stored ID rather than invoking GetThreadID() while locked. Registering the same pointer again returns its original ID and core without advancing round-robin core assignment. New registration is transactional: the core counter advances only after the record is inserted successfully, and Thread construction removes its record if a later constructor operation throws. Cleanup is performed in two phases: state and cleanup-claim virtual methods run without the thread-list lock, then the manager reacquires the lock and removes only records whose ID and pointer still exactly match. Custom IThread implementations may therefore re-enter ThreadManager from these virtual methods without deadlocking the list lock.
Thread instances request unique IDs from the manager automatically. A custom IThread registered through AddThread(thread) supplies its own GetThreadID() value; registration throws ThreadDuplicateIDException if that ID is already present, preventing ambiguous lookup or removal.
Calling AddThread(nullptr) throws ThreadInvalidRegistrationException rather than silently returning a core assignment.
The pin protects against deletion performed by ThreadManager::CleanUp(). Application code must not directly delete a Thread concurrently with manager iteration; unmanaged concurrent destruction remains unsupported because the manager stores non-owning pointers in order to support both stack-allocated and dynamically allocated Threads.
Use ThreadManager::WithThread() when accessing one Thread by ID while garbage collection may be active:
ThreadManager::GetInstance()->WithThread(threadID, [](IThread* thread) {
// The manager will not garbage-collect this Thread during the callback.
thread->Pause();
});GetThread() remains available for source compatibility, but returns a non-owning pointer whose lifetime is not pinned after the method returns. Use it only when the application independently guarantees that the Thread cannot be destroyed; prefer WithThread() otherwise.
It is quite common to have Threads with non-permanent lifetimes, such as Worker Threads (less common with microcontrollers, but not unheard of).
ESPressio Threads provides a means of leveraging fully-automatic Garbage Collection for your Threads once they've Terminated.
Let's modify our previous example to take advantage of it, and let's add some finality to MyFirstThread so that it will automatically Terminate when it has done its "work":
class MyFirstThread : public Thread {
private:
int _counter = 0;
protected:
void OnInitialization() override {
// Anything we need to do here prior to the Thread's Loop sstarting
}
void OnLoop() override {
_counter++; // Increment the counter
// Let's display some information about our Thread...
Serial.printf("MyFirstThread::OnLoop() - Thread #%d - On CPU %d, Counter = %d", GetThreadID(), xPortGetCoreID(), _counter);
if (_counter == 10) {
Terminate(); // This will Terminate the Thread
}
delay(1000); // Let's let this Thread wait for 1 second before it loops around again
}
public:
MyFirstThread(bool freeOnTerminate) : Thread(freeOnTerminate) {}
};Okay, so our MyFirstThread class has been updated so that it will automatically Terminate when the _counter reaches 10.
I've also added a public Constructor to expose the overloaded constructor on Thread, which provides the optional freeOnTerminate parameter we shall be using in a moment.
Let's modify the way we define the Instances of MyFirstThread so that we can leverage Automatic Garbage Collection:
MyFirstThread* thread1;
MyFirstThread* thread2;
MyFirstThread* thread3;
void setup() {
Serial.begin(115200);
// Create our Threads (passing `true` to the constructor for "FreeOnTerminate")
thread1 = new MyFirstThread(true);
thread2 = new MyFirstThread(true);
thread3 = new MyFirstThread(true);
delay(500); // Small delay just so that the thread doesn't start before the Serial Monitor is ready
Manager::Initialize();
}Now, when you run this program, each of the three instances of MyFirstThread will loop precisely 10 times, output their entries to the Serial console, then each of them will automatically Terminate().
At that moment, the Automatic Garbage Collector will be awoken, and will take responsibility for purging the unwanted instances from our device's active memory.
It is important to understand when it's appropriate to take advantage of Automatic Garbage Collection, and when you should manually manage the memory of your
Threads.
It's also good to know that the Automatic Garbage Collector is a "good citizen" and doesn't take up undue memory or clock cycles when it doesn't have any garbage to collect.
Thread provides a Shutdown() method for derived types. It requests termination and waits until the underlying FreeRTOS task has stopped accessing the object.
If a derived class has members that are used by OnLoop(), its destructor must call Shutdown() before those members are destroyed:
class MyWorkerThread : public Thread {
private:
SomeResource _resource;
protected:
void OnLoop() override {
_resource.DoWork();
}
public:
~MyWorkerThread() override {
Shutdown();
}
};This is source-compatible with existing derived classes: no new virtual method needs to be implemented. Existing classes that are always terminated before destruction do not require a change. Derived classes that may be destroyed while running should add the destructor pattern above.
Do not call Shutdown() from OnLoop() or from code executing on the Thread's own FreeRTOS task. Call Terminate() there instead; the worker will finish its current loop iteration and exit normally.
Shutdown() may be called during OnInitialization(). In that context it requests termination without waiting, allowing OnInitialization() to return so initialization can delete the still-gated worker safely.
Directly destroying a running derived object without first calling Shutdown() is unsupported. C++ destroys derived members before it invokes the base Thread destructor, so the base destructor cannot protect members that OnLoop() may still be using.
Calling Shutdown() claims manual ownership of destruction by disabling FreeOnTerminate. Consequently, code that explicitly shuts down a dynamically allocated Thread remains responsible for deleting it.
Manual and automatic cleanup now use an atomic ownership claim. If Shutdown() begins before manager cleanup claims the object, manual ownership wins and ThreadManager::CleanUp() will not delete it. Conversely, once manager cleanup has atomically claimed an object, application code must no longer access that FreeOnTerminate instance.
Shutdown() also waits for queued termination-dispatch work after the worker task has exited. When it returns, neither the worker nor the termination dispatcher will access the object, so derived members may be destroyed safely. Calling Shutdown() from OnTerminated itself does not wait on the dispatcher task and therefore does not deadlock; the callback must still not delete its sender.
Termination has two callback milestones. SetOnTerminate() registers a callback for the moment the Thread loop enters the Terminated state. SetOnTerminated() runs later on the dedicated termination-dispatcher task, after FreeRTOS task execution has ended. Use SetOnTerminated() when cleanup depends on the worker no longer executing OnLoop(). The dispatcher keeps TLS cleanup short and permits ordinary callback work without blocking the FreeRTOS cleanup context.
The termination dispatcher is initialized once, when first required. If its queue or task cannot be created, it remains unavailable for the lifetime of the application and Initialize() reports TerminationDispatcherUnavailable. Unlike the garbage collector, the dispatcher does not retry initialization; applications should treat this status as a startup resource/configuration failure.
Enqueueing termination work from FreeRTOS task-deletion cleanup is non-blocking. The default dispatcher queue can hold one pending event for every supported Thread. If a smaller configured queue is exhausted, OnTerminated is not invoked for that termination, shutdown waiters are still released, and a FreeOnTerminate object remains registered until a later explicit ThreadManager::CleanUp() call.
An OnTerminated callback must not directly delete its sender. It may change FreeOnTerminate; the dispatcher evaluates that setting after the callback and manager cleanup must subsequently win the atomic automatic-cleanup claim before deletion can occur.
Automatic garbage collection runs on a private infrastructure task rather than an IThread. It therefore consumes neither a public Thread ID nor one of the 256 registration slots. Its stack size and priority can be configured with ESPRESSIO_THREAD_GARBAGE_COLLECTOR_STACK_SIZE and ESPRESSIO_THREAD_GARBAGE_COLLECTOR_PRIORITY.
If garbage-collector task creation fails because resources are temporarily unavailable, later cleanup requests retry initialization. If retry still fails, cleanup runs synchronously on the requesting ordinary task so FreeOnTerminate objects are not leaked permanently. ThreadGarbageCollector::IsAvailable() reports whether its background task is currently available.
Exceptions escaping OnLoop() are contained at the FreeRTOS task boundary and converted into ordinary Thread termination. Register SetOnExecutionFailed() before starting the Thread to inspect the failure. The callback receives a std::exception_ptr containing ThreadExecutionException, which can be caught through the exception hierarchy; RethrowCause() exposes the original application exception:
thread1.SetOnExecutionFailed(
[](IThread*, std::exception_ptr failure) {
try {
std::rethrow_exception(failure);
} catch (const ThreadExecutionException& exception) {
try {
exception.RethrowCause();
} catch (const std::exception& cause) {
// Report cause.what().
} catch (...) {
// Report a non-standard application exception.
}
} catch (const ThreadException& exception) {
// Handle any other ESPressio Threads exception.
}
}
);Exceptions thrown by the failure callback itself are contained. The callback runs on the failing worker task immediately after OnLoop() exits and before the Thread enters its normal termination sequence.
An OnExecutionFailed callback must never delete its sender because the worker task continues the termination sequence after the callback returns. It may call Terminate(). If destruction is required, allow termination to complete and perform deletion later from an owning task, or rely on FreeOnTerminate and manager cleanup.
When working with multiple Threads (especially on multi-core hardware such as the ESP32 microcontrollers) it is absolutely critical that we identify any and all members (properties) within our Objects that may be simultainously accessed (be that read or write) by multiple Threads at any given moment.
Single Byte Types (such as bool, byte, and uint8_t... just a few examples) are generally considered Atomic, meaning that modifying their value occurs in a single cycle, and therefore they are considered to be inherently Thread-Safe Types.
However, most Types are more than a single Byte, and these are never inherently Thread-Safe.
For that reason, ESPressio Threads provides a neat "decorator" which can be used for Object Members (properties) whose values may be read and modified by multiple threads at any given moment.
Let's provide a simple illustrative example of an unsafe member in a Thread:
class NotThreadSafeThread : public Thread {
private:
int _counter = 0;
protected:
void OnInitialization() override {
// Anything we need to do here prior to the Thread's Loop sstarting
}
void OnLoop() override {
_counter++; // Increment the counter
// Let's display some information about our Thread...
Serial.printf("MyFirstThread::OnLoop() - Thread #%d - On CPU %d, Counter = %d", GetThreadID(), xPortGetCoreID(), _counter);
if (_counter == 10) {
Terminate(); // This will Terminate the Thread
}
delay(1000); // Let's let this Thread wait for 1 second before it loops around again
}
public:
NotThreadSafeThread(bool freeOnTerminate) : Thread(freeOnTerminate) {}
int GetCounter ( return _counter; )
void SetCounter(int counter) { _counter = counter; }
};The above example is NOT Thread-Safe, because the member _counter is publicly exposed through the methods GetCounter and SetCounter, which may be invoked by other Threads any number of times, potentially concurrently.
This means that, should one Thread invoke SetCounter at the same moment another thread invokes either SetCounter or GetCounter, unpredictable and undefined behaviour can occur (which can in fact crash your program entirely).
At the same time, the OnLoop method above is also incrementing _counter, and if this occurs at the same instant that another Thread invokes GetCounter or SetCounter, we can end up in an undefined state where the program is likely to crash.
So, how can we quickly and easily make NotThreadSafeThread into ThreadSafeThread?
Well, beyond just changing the name, let's take a look:
#include <ESPressio_ThreadSafe.hpp> // < This provides access to our Thread Safe Types
class ThreadSafeThread : public Thread {
private:
IThreadSafe<int>* _counter = new ReadWriteMutex<int>(0);
protected:
void OnInitialization() override {
// Anything we need to do here prior to the Thread's Loop sstarting
}
void OnLoop() override {
int counter = 0;
_counter->WithWriteLock([&](int& value) {
value++;
counter = value; // Set the local copy so we can use it without locking the member again
});
// Let's display some information about our Thread...
Serial.printf("MyFirstThread::OnLoop() - Thread #%d - On CPU %d, Counter = %d", GetThreadID(), xPortGetCoreID(), counter);
if (_counter == 10) {
Terminate(); // This will Terminate the Thread
}
delay(1000); // Let's let this Thread wait for 1 second before it loops around again
}
public:
ThreadSafeThread(bool freeOnTerminate) : Thread(freeOnTerminate) {}
~ThreadSafeThread() {
delete _counter; // We need to clean up the memory here
}
int GetCounter ( return _counter->Get(); )
void SetCounter(int counter) { _counter->Set(counter); }
};The above code shows how we can leverage the ESPressio Threads ReadWriteMutex type to encapsulate a member value (in this case, _counter of the int type) so that we can access it for both Read and Write in a Thread-Safe way.
Note that
ReadWriteMutexoperates on the principle of Multi-Read, Exclusive-Write, which makes the most sense in this example context. You can identally use theMutextype (provided inside theESPressio_ThreadSafe.hppheader file also) if you want Exclusive-Read, Exclusive-Write behaviour.
Let's unpick this code to see what each piece is doing.
We'll start with the member (property) declaration of _counter itself.
IThreadSafe<int>* _counter = new ReadWriteMutex<int>(0);This declares _counter to be of the type ReadWriteMutex<int> (our ReadWriteMutex type-specialized for int).
Additionally, it creates a new instance of this ReadWriteMutex, and the constructor takes the intiial value (0 in this case) for our member.
Please take special note that _counter is a pointer to a ReadWriteMutex<int>. This is necessary due to linguistic behaviour in C++.
Indeed, because we declare the member to be a pointer, we must use the -> accessor for its members and methods, rather than a ..
Additionally, because it is a pointer, we require the destructor...
~ThreadSafeThread() {
delete _counter; // We need to clean up the memory here
}... which ensures that the instance of the ReadWriteMutex<int> is destroyed when its owning ThreadSafeThread is destroyed. Failure to implement the destructor will result in Memory Leaks, so please remember to manage your memory like this properly.
Next, let's take a look at the Loop() implementation's use of _counter:
int counter = 0;
_counter->WithWriteLock([&](int& value) {
value++;
counter = value; // Set the local copy so we can use it without locking the member again
});The first line initializes a "local copy" variable, initially set with a value of 0, that will be updated to contain the current (incremented) value of the counter.
WithWriteLock then defines a Lambda Function (the remainder of the code in the above snip) that is excuted only after the Thread-Safe Lock (the ReadWriteMutex) has been safely acquired.
Everything occuring inside the Lambda Function occurs with the Exclusive Write lock engaged, meaning it is 100% thread-safe for the duration of execution.
We then increment the value (which is a Reference to the actual int value itself).
Finally, we update our local variable counter to contain the current value (which was just incremented).
The moment that Lambda Function returns, the Exclusive Write lock is released, meaning that any other Thread can now acquire it as desired.
Now let's take a look at the Getter and Setter for Counter:
int GetCounter ( return _counter->Get(); )
void SetCounter(int counter) { _counter->Set(counter); }You will notice that each of these respective methods invokes Get() or Set() respectively.
These methods (which belong to ReadWriteMutex and are concretions of the common IThreadSafe interface) take responsibility for acquiring and releasing the appropriate Thread-Safe Lock, making these methods entirely Thread-Safe.
This means that all public, protected, and private methods having access to _counter are performing every operation (reads and writes) within the protection of the Thread-Safe Lock.
As well as those we have seen above (Get(), Set(), and WithWriteLock), the IThreadSafe interface (and all concrete implementations thereof, such as ReadWriteMutex and Mutex) provide a number of other Methods that will be useful to you depending on your use-cases.
Let's take a look at them now in brief:
TryGet(T defaultValue)will attempt to obtain the Thread-Safe (Read) Lock, and if it is available in that instant, will return the actual value. If the Thread-Safe (Read) Lock is not available in that instant, the value given for parameterdefaultValuewill instead be returned. This may be useful if it is not absolutely critical to get the precise (actual) value of a member at the point of request.TrySet(T value)will attempt to obtain the Thread-Safe (Write) Lock, and if it is available in that instant, will set the value to that given forvalue. This method returns abool, wheretrueindicates that the value was updated, andfalseindicates that it was not updated.IsLockedRead()returns a bool wheretrueindicates that the Thread-Safe Read Lock is currently unavailable, whilefalseindicates that the invoking Thread acquired it. InvokeReleaseReadLock()when finished.IsLockedWrite()returns a bool wheretrueindicates that the Thread-Safe Write Lock is currently unavailable, whilefalseindicates that the invoking Thread acquired it. InvokeReleaseWriteLock()when finished.WithReadLock()invokes the given Lambda Function with a mutable reference toT. Because the callback can modify the value, this method uses an exclusive lock.WithWriteLock()invokes the given Lambda Function (which takes a reference toT- the specialized value type - as its parameter) and executes that Lambda Function within the protection of the acquired Thread-Safe (Write) lock.TryWithReadLock()operates almost identically toWithReadLock(), however it will only execute the Lambda Function if the exclusive Lock is immediately available at the instant of request. It returns aboolwheretrueindicates that the Lock was acquired (therefore the Lambda Function executed) andfalseindicates that the Lock was unavailable (therefore the Lamdba Function was not executed).TryWithWriteLock()operates almost identically toWithWriteLock(), however it will only execute the Lambda Function if the Thread-Safe (Write) Lock is immediately available at the instant of request. It returns aboolwheretrueindicates that the Lock was acquired (therefore the Lambda Function executed) andfalseindicates that the Lock was unavailable (therefore the Lamdba Function was not executed).WithSharedReadLock()invokes a callback takingconst T&.ReadWriteMutexuses a shared lock for this method, allowing concurrent immutable readers.TryWithSharedReadLock()is the non-blocking counterpart toWithSharedReadLock()and returns whether the shared lock was acquired and the callback executed.ReleaseReadLock()releases a Lock acquired byIsLockedRead().ReleaseWriteLock()releases a Lock acquired byIsLockedWrite().ReleaseLock()remains available as a backward-compatible alias forReleaseReadLock().