22 AsyncPeriodicTimer

class AsyncPeriodicTimer is defined in AsyncPeriodicTimer.h in the cxThread library.

This is the public interface:

class AsyncPeriodicTimer
{
public:
    using ExecuteTaskFn = std::function<void(bool expired, std::atomic<bool>& abort)>;
    explicit AsyncPeriodicTimer(boost::asio::io_context& context);
    void Start(int timeoutMilliSecs, ExecuteTaskFn taskFn);
    void Reset(int timeoutMilliSecs = -1);
    void SynchronousStop();
};

std::shared_ptr<AsyncPeriodicTimer> MakeAsyncPeriodicTimer(boost::asio::io_context& context);

Instances of AsyncPeriodicTimer are reference counted using std::shared_ptr.

Start(timeoutMilliSecs, taskFn) causes the given task to be called asynchronously and repeatedly at the rate determined by timeoutMilliSecs. If the timer is not reset with calls to the Reset method then the task is called with expired equal to true.

A call to the Reset(timeoutMilliSecs) method does two things:

  • The task function will typically be called very soon afterwards (perhaps within about 10 microseconds) with expired equal to false, indicating that the timer was reset rather than given an opportunity to time out. Note that multiple calls to Reset at close to the same time may result in just a single call to the task function with expired equal to false.
  • The timer restarts, either with a new timeout period if timeoutMilliSecs >= 0 otherwise with the existing timeout period.

SynchronousStop stops further calls to the task that was passed to Start. It causes the atomic bool abort flag passed to the task to become set. A long running task should poll the abort flag to see whether it should abort what it's doing. This can for example help to allow applications to shutdown quickly.

Note well that SynchronousStop() synchronously ensures the io_context has stopped executing the task. The implementation waits on a ManualResetEvent until the timer has been stopped.

If Start is called on a timer which is already running then the time out and task to be executed is updated and the timer is restarted as though there was a call to Reset.

Calling SynchronousStop() on a timer that isn't running is permitted.

It is safe to call Start and Reset from the task being executed, but attempting to call SynchronousStop from the task produces a dead-lock.

AsyncPeriodicTimer has rather limited resolution. If a nonzero time period is passed to Start or Reset, the minimum timeout is typically about 15 milliseconds on Windows running on an x86 processor. This is the default interval between system clock ticks. See the article High-Resolution Timers for kernel drivers in the Microsoft Docs.

Example usage:

// #include "Ceda/cxThread/AsyncPeriodicTimer.h"

auto r = ceda::MakeAsyncPeriodicTimer(io_context);
int timeoutMilliSecs = 50;
r->Start(timeoutMilliSecs, [](bool expired, std::atomic<bool>& abort)
    {
        std::cout << "Executing task" << std::endl;
    });
std::this_thread::sleep_for(std::chrono::milliseconds(300));
r->SynchronousStop();

AsyncPeriodicTimer.h includes boost headers, so we are including boost in a public header of cxThread. However most ceda based applications have no need to use AsyncPeriodicTimer, so it's a more restricted dependency on boost.

Putting it another way, the rest of ceda-core tends to use this header internally, for example to help implement the LSS or a CSpace, so this header is typically only included from cpp files inside ceda-core libraries.

Purpose

There are some classes in ceda-core which have a concept of being started and stopped and which perform some task at a regular rate (e.g. once every 1000 milliseconds). For example:

As of July 2021 these classes use their own thread. In some cases this is through a TimeOutTask.

We want to instead use a boost asio io_context and deadline_timer. That means we have no counterpart to a Stop() method which calls join() on a std::thread to wait until the thread exits, ensuring it is no longer executing the task. Similarly we don't have a cxThread ITaskExecutor to wait on.

Nevertheless AsyncPeriodicTimer still provides a SynchronousStop() method that makes the same guarantee - i.e. SynchronousStop() synchronously ensures no thread is (or will be) executing the task.

AsyncPeriodicTimer is a final class. It is passed the task to be executed repeatedly at regular intervals in the Start() method using a std::function<bool expired, void(std::atomic<bool>& abort)>.

Implementation

boost::asio::deadline_timer has method async_wait() to start an asynchronous wait on the dead-line timer.

Instances of AsyncPeriodicTimer are heap allocated and reference counted using std::shared_ptr.

AsyncPeriodicTimer is a subclass of std::enable_shared_from_this<AsyncPeriodicTimer> so that shared_from_this() can be called within methods of AsyncPeriodicTimer to obtain a std::shared_ptr which points at itself.

The lamda passed to async_wait() captures a std::shared_ptr which points at the AsyncPeriodicTimer. This means the lambda prevents the AsyncPeriodicTimer from being deleted.

Only the AsyncPeriodicTimer needs to be reference counted - this requirement is not pushed back into applications. A lambda in turn passed to AsyncPeriodicTimer::Start() doesn't need to hold a strong reference to the objects which it accesses, because we have implemented a SynchronousStop() function which guarantees the lambda is synchronously no longer being called.

In order for an application to shut down quickly it needs to be able to abort async waits on boost asio deadline_timer objects. deadline_timer::cancel() can be called for this purpose.

The boost asio deadline_timer is not thread-safe. We ensure all access is by the context, and we assume the context is only running a single thread.

AsyncPeriodicTimer.h

// AsyncPeriodicTimer.h
//
// Author David Barrett-Lennard
// (C)opyright Cedanet Pty Ltd 2021

#pragma once
#ifndef Ceda_cxThread_AsyncPeriodicTimer_H
#define Ceda_cxThread_AsyncPeriodicTimer_H

#include "Ceda/cxUtils/CedaAssert.h"
#include <boost/asio.hpp>
#include <memory>
#include <atomic>
#include <functional>

namespace ceda
{
class AsyncPeriodicTimer final :  public std::enable_shared_from_this<AsyncPeriodicTimer>
{
public:
    using ExecuteTaskFn = std::function<void(bool expired, std::atomic<bool>& abort)>;
    explicit AsyncPeriodicTimer(boost::asio::io_context& context);
    void Start(int timeoutMilliSecs, ExecuteTaskFn taskFn);
    void Reset(int timeoutMilliSecs = -1);
    void SynchronousStop();
private:
    void DoAsync();
    
    std::atomic<bool> abort_ = true;        // For aborting a task that is already executing

    /////////////// only accessed by the single-threaded context passed into the constructor
    int timeoutMilliSecs_ = 1;
    ExecuteTaskFn taskFn_;
    boost::asio::system_timer timer_;
};

inline std::shared_ptr<AsyncPeriodicTimer> MakeAsyncPeriodicTimer(boost::asio::io_context& context)
{
    return std::make_shared<AsyncPeriodicTimer>(context);
}
} // namespace ceda
#endif // include guard

AsyncPeriodicTimer.cpp

// AsyncPeriodicTimer.cpp
//
// Author David Barrett-Lennard
// (C)opyright Cedanet Pty Ltd 2021

#include "AsyncPeriodicTimer.h"
#include "ManualResetEvent.h"

namespace ceda
{
AsyncPeriodicTimer::AsyncPeriodicTimer(boost::asio::io_context& context) : 
    timer_(context)    // Construct a timer without setting an expiry time.
{
    cxAssert(!taskFn_);     // No task function is set, indicates not started
}

void AsyncPeriodicTimer::Start(int timeoutMilliSecs, ExecuteTaskFn taskFn)
{
    abort_ = false;
    auto self(shared_from_this());
    boost::asio::post(timer_.get_executor(), 
        [this, self, timeoutMilliSecs, taskFn]() 
        { 
            timeoutMilliSecs_ = timeoutMilliSecs;
            bool alreadyRunning = (bool) taskFn_;
            taskFn_ = std::move(taskFn);
            if (alreadyRunning)
            {
                timer_.expires_after(std::chrono::milliseconds(timeoutMilliSecs_));
            }
            else
            {
                DoAsync();
            }
        });
}

void AsyncPeriodicTimer::Reset(int timeoutMilliSecs)
{
    auto self(shared_from_this());
    boost::asio::post(timer_.get_executor(),
        [this, self, timeoutMilliSecs]()
        {
            if (taskFn_)
            {
                if (timeoutMilliSecs >= 0) timeoutMilliSecs_ = timeoutMilliSecs;

                // Changing the expiry time of a timer while there are pending asynchronous waits 
                // causes those wait operations to be cancelled. 
                timer_.expires_after(std::chrono::milliseconds(timeoutMilliSecs_));
            }
        });
}

void AsyncPeriodicTimer::SynchronousStop()
{
    // The async job should check this atomic bool regularly to see whether it should abort
    abort_ = true;

    if (ThreadsAreEnabled())
    {
        ManualResetEvent event;
        auto self(shared_from_this());
        boost::asio::post(timer_.get_executor(),
            [this, self, &event]()
            {
                taskFn_ = nullptr;
                timer_.cancel();
                event.Signal();
            });

        // Put this AsyncPeriodicTimer synchronously into a state where the taskFn which was passed 
        // to Start() is not being called and won't be called again.
        event.Wait();
    }
    else
    {
        taskFn_ = nullptr;
        timer_.cancel();
    }
}

// Always called by a thread that has locked the mutex
void AsyncPeriodicTimer::DoAsync()
{
    // Set an expiry time relative to now.
    timer_.expires_after(std::chrono::milliseconds(timeoutMilliSecs_));
        
    // asynchronous wait for timer to expire
    auto self(shared_from_this());
    timer_.async_wait(
        [this, self](const boost::system::error_code& ec)
        {
            if (taskFn_)
            {
                // Reset() causes pending  asynchronous waits to be cancelled.  
                // i.e. ec equals boost::asio::error::operation_aborted.
                bool expired = (ec != boost::asio::error::operation_aborted);
                taskFn_(expired, abort_);
                if (!abort_) DoAsync();  // Run again
            }
        });
}

} // namespace ceda

Testing

AsyncPeriodicTimerTest.cpp

// AsyncPeriodicTimerTest.cpp
//
// Author David Barrett-Lennard
// (C)opyright Cedanet Pty Ltd 2021

#include "Ceda/cxThread/AsyncPeriodicTimer.h"
#include "Ceda/cxThread/IoContextPool.h"
#include "Ceda/cxUtils/Tracer.h"
#include "Ceda/cxUtils/HPTime.h"
#include <iostream>

namespace AsyncPeriodicTimerTest
{
    void SimpleDemo()
    {
        Tracer() << "SimpleDemo\n";
        auto& io_context = ceda::GetDefaultIoContext();
        auto r = ceda::MakeAsyncPeriodicTimer(io_context);
        int timeoutMilliSecs = 50;
        r->Start(timeoutMilliSecs, [](bool expired, std::atomic<bool>& abort)
            {
                std::cout << "Executing task" << std::endl;
            });
        std::this_thread::sleep_for(std::chrono::milliseconds(300));
        r->SynchronousStop();
    }

    /*
    If the timeout is 0 then the task can be called at a high rate 
    (e.g. 100000 calls per second), invariably with expired=true.
    */
    void ZeroTimeOutExample()
    {
        Tracer() << "ZeroTimeOutExample\n";
        ceda::TraceIndenter indent(4);

        auto& io_context = ceda::GetDefaultIoContext();
        auto t = ceda::MakeAsyncPeriodicTimer(io_context);

        int numCancelled = 0;
        int numExpired = 0;

        t->Start(0, 
            [&](bool expired, std::atomic<bool>& abort)
            {
                if (expired) ++numExpired; else ++numCancelled; 
            });
        Sleep(100);
        t->SynchronousStop();
        Tracer() << "numCancelled = " << numCancelled << " numExpired = " << numExpired << '\n';
    }

    /*
    If the timeout is large and Reset() is called repeatedly then the task can be called at a high rate 
    (e.g. 100000 calls per second), invariably with expired=false.
    */
    void AbortExample()
    {
        Tracer() << "AbortExample\n";
        ceda::TraceIndenter indent(4);

        auto& io_context = ceda::GetDefaultIoContext();
        auto t = ceda::MakeAsyncPeriodicTimer(io_context);

        int numCancelled = 0;
        int numExpired = 0;

        t->Start(1000, 
            [&](bool expired, std::atomic<bool>& abort)
            {
                if (expired) ++numExpired; else ++numCancelled; 
            });
    
        ceda::HPTimer timer;
        while(timer.GetElapsedTimeInSeconds() < 0.1)
        {
            t->Reset();
        }

        t->SynchronousStop();
        Tracer() << "numCancelled = " << numCancelled << " numExpired = " << numExpired << '\n';
    }

    struct RepeatedTaskCounter
    {
        void Init(boost::asio::io_context& io_context)
        {
            art = ceda::MakeAsyncPeriodicTimer(io_context);
        }

        void Start()
        {
            count = 0;
            int timeoutMilliSecs = 1;
            art->Start(timeoutMilliSecs, [this](bool expired, std::atomic<bool>& abort)
                {
                    ++count;
                });
        }

        void Stop()
        {
            art->SynchronousStop();
            countJustAfterStop = count;
        }

        std::shared_ptr<ceda::AsyncPeriodicTimer> art;
        int count = 0;
        int countJustAfterStop = 0;
    };

    void StressTest(double timeForTestInSecs)
    {
        Tracer() << "StressTest\n";

        int numThreads = 4;

        std::vector<std::thread> threads(numThreads);
        for (int i=0 ; i < numThreads ; ++i)
        {
            threads[i] = std::thread([=]()
                {
                    ceda::TraceIndenter indent(4 + 4*i);
    
                    auto& io_context = ceda::GetDefaultIoContext();

                    int n = 1000;
                    std::vector<RepeatedTaskCounter> repeatedTasks(n);
                    for (auto& r : repeatedTasks)
                        r.Init(io_context);

                    ceda::HPTimer timer;
                    while(timer.GetElapsedTimeInSeconds() < timeForTestInSecs)
                    {
                        int total = 0;
                        for (int j=0 ; j < 10 ; ++j)
                        {
                            for (auto& r : repeatedTasks)
                                r.Start();
        
                            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        
                            for (auto& r : repeatedTasks)
                                r.Stop();

                            for (auto& r : repeatedTasks)
                                total += r.count;

                            // Wait a bit longer and check that the atomic counts are no longer changing
                            std::this_thread::sleep_for(std::chrono::milliseconds(1));
                            for (auto& r : repeatedTasks)
                            {
                                cxAlwaysAssert(r.count == r.countJustAfterStop);
                            }
                        }
                        Tracer() << total << '\n';
                    }
                });
        }

        for (auto& t : threads)
            t.join();
    }

    void WithAndWithoutReset()
    {
        Tracer() << "WithAndWithoutReset\n";
        ceda::TraceIndenter indent(4);

        auto& io_context = ceda::GetDefaultIoContext();
        auto t = ceda::MakeAsyncPeriodicTimer(io_context);
        ceda::HPTimer timer;
    
        Tracer() << "Start with timeout of 25ms\n";
        t->Start(20, 
            [&](bool expired, std::atomic<bool>& abort)
            {
                ceda::TraceIndenter indent(8);
                Tracer() << "Time: " << (int) (1000*timer.GetElapsedTimeInSeconds()) << "ms   task( expired=" << (expired?"true":"false") << " ) called\n";
                timer.Reset();
            });

        Tracer() << "Begin sleep 100ms\n";
        Sleep(100);
        Tracer() << "End sleep\n";

        Tracer() << "Calling Reset() repeatedly, preventing the timer from expiring\n";
        for (int i=0 ; i < 5 ; ++i)
        {
            timer.Reset();
            t->Reset();
            t->Reset();
            t->Reset();
            Sleep(10);

            t->Reset();
            Sleep(10);

            t->Reset();
            Sleep(10);

            t->Reset();
            Sleep(10);
                                                                                                                                                      
            t->Reset();
            Sleep(10);

            t->Reset();
            t->Reset();
            Sleep(10);
        }

        Tracer() << "Reset() with a new timeout of 100ms\n";
        timer.Reset(); t->Reset(100);
        Sleep(10);
        Tracer() << "Begin sleep 250ms\n";
        Sleep(250);
        Tracer() << "End sleep\n";

        t->SynchronousStop();
        Tracer() << "SynchronousStop() called\n";
    }

} // namespace AsyncPeriodicTimerTest

void UnitTestAsyncPeriodicTimer(double timeForTestInSecs)
{
    Tracer() << "UnitTestAsyncPeriodicTimer\n";
    ceda::TraceIndenter indent(4);

    AsyncPeriodicTimerTest::SimpleDemo();
    AsyncPeriodicTimerTest::ZeroTimeOutExample();
    AsyncPeriodicTimerTest::AbortExample();
    AsyncPeriodicTimerTest::WithAndWithoutReset();
    AsyncPeriodicTimerTest::StressTest(timeForTestInSecs);
}

Typical output:

UnitTestAsyncPeriodicTimer
    SimpleDemo
Executing task
Executing task
Executing task
Executing task
Executing task
    ZeroTimeOutExample
        numCancelled = 0 numExpired = 9157
    AbortExample
        numCancelled = 5969 numExpired = 0
    WithAndWithoutReset
        Start with timeout of 25ms
        Begin sleep 100ms
        Time: 26ms   task( expired=true ) called
        Time: 31ms   task( expired=true ) called
        Time: 31ms   task( expired=true ) called
        End sleep
        Calling Reset() repeatedly, preventing the timer from expiring
        Time: 0ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 14ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 0ms   task( expired=false ) called
        Time: 14ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 14ms   task( expired=false ) called
        Time: 0ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 14ms   task( expired=false ) called
        Time: 14ms   task( expired=false ) called
        Time: 14ms   task( expired=false ) called
        Time: 14ms   task( expired=false ) called
        Time: 0ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 14ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 14ms   task( expired=false ) called
        Time: 14ms   task( expired=false ) called
        Time: 0ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 14ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Time: 15ms   task( expired=false ) called
        Reset() with a new timeout of 100ms
        Time: 0ms   task( expired=false ) called
        Begin sleep 250ms
        Time: 107ms   task( expired=true ) called
        Time: 110ms   task( expired=true ) called
        End sleep
        SynchronousStop() called
    StressTest
    73491
        73838
                75244
            75927