Preliminary. ThinkMeta ConcurrentTasks and this documentation are preliminary; everything is subject to change.

Ping-pong

Two tasks hand the processor back and forth a million times. Each Release wakes the other task, each Wait suspends the calling one, so every round trip is two suspends and two resumes. The program measures how many round trips it manages per second – a direct look at the cost of suspending and resuming a task.

The two tasks

// Each semaphore has exactly one waiter - the task it wakes - so the single-waiter
// variant fits.
class PingTask : public TC::Tasks::Task
{
public:
    PingTask(Semaphore_s& ping, Semaphore_s& pong) noexcept
        : ping_(ping),
          pong_(pong)
    {}

    void Run() noexcept override
    {
        for (std::size_t i = 0; i < RoundTrips; ++i) {
            pong_.Release(); // never suspends
            ping_.Wait();    // suspends until the pong task answers
            ++count;
        }
    }

    std::size_t count = 0;

private:
    Semaphore_s& ping_;
    Semaphore_s& pong_;
};

class PongTask : public TC::Tasks::Task
{
public:
    PongTask(Semaphore_s& ping, Semaphore_s& pong) noexcept
        : ping_(ping),
          pong_(pong)
    {}

    void Run() noexcept override
    {
        for (std::size_t i = 0; i < RoundTrips; ++i) {
            pong_.Wait();
            ping_.Release();
            ++count;
        }
    }

    std::size_t count = 0;

private:
    Semaphore_s& ping_;
    Semaphore_s& pong_;
};

Release adds a permit and resumes the waiting task, if there is one; it never suspends. Wait takes a permit, suspending the task while there is none. A permit released before the other side waits is not lost: the next Wait takes it at once.

Running them

// Two threads, so that a Release can wake the other task on the other thread.
// With one thread both tasks share it and every round trip is two plain task
// switches.
TC::SchedulerInfo   info{.numberOfThreads = 2};
TC::ITaskScheduler* scheduler
    = TC::CreateTaskScheduler(TC::SchedulerKind::Default, &info);
if (scheduler == nullptr)
    return 1;
scheduler->Start();

bool ok = false;
{
    // The semaphores, the tasks and the group outlive the tasks: they are
    // destroyed only after Stop.
    Semaphore_s ping;
    Semaphore_s pong;
    PingTask    pingTask(ping, pong);
    PongTask    pongTask(ping, pong);

    TC::Synchronization::TaskGroup_s group; // main is its only waiter
    group.AddTask(
        &pingTask); // before AddTask: the group must count the task in first
    group.AddTask(&pongTask);

    const auto start = std::chrono::steady_clock::now();
    scheduler->AddTask(&pingTask);
    scheduler->AddTask(&pongTask);
    group.Wait();
    const std::chrono::duration<double> elapsed
        = std::chrono::steady_clock::now() - start;

    scheduler->Stop();

    ok = pingTask.count == RoundTrips && pongTask.count == RoundTrips;
    std::printf("%zu round trips in %.3f s: %.1f million per second (%s)\n",
                RoundTrips, elapsed.count(),
                static_cast<double>(RoundTrips) / elapsed.count() / 1e6,
                ok ? "ok" : "MISMATCH");
}

The TaskGroup_s counts both tasks in before they are added to the scheduler and lets main pass once both have exited.

Full program

// Ping-pong: two tasks hand the processor back and forth through two semaphores and
// count the round trips per second.
#include <Windows.h>

#include <chrono>
#include <cstddef>
#include <cstdio>

#include <ThinkMeta.ConcurrentTasks/Core.h>
import ThinkMeta.ConcurrentTasks.Synchronization.Semaphore;
import ThinkMeta.ConcurrentTasks.Synchronization.TaskGroup;
import ThinkMeta.ConcurrentTasks.Tasks.Task;

namespace TC = ThinkMeta::ConcurrentTasks;
using TC::Synchronization::Semaphore_s;

constexpr std::size_t RoundTrips = 1'000'000;

// Each semaphore has exactly one waiter - the task it wakes - so the single-waiter
// variant fits.
class PingTask : public TC::Tasks::Task
{
public:
    PingTask(Semaphore_s& ping, Semaphore_s& pong) noexcept
        : ping_(ping),
          pong_(pong)
    {}

    void Run() noexcept override
    {
        for (std::size_t i = 0; i < RoundTrips; ++i) {
            pong_.Release(); // never suspends
            ping_.Wait();    // suspends until the pong task answers
            ++count;
        }
    }

    std::size_t count = 0;

private:
    Semaphore_s& ping_;
    Semaphore_s& pong_;
};

class PongTask : public TC::Tasks::Task
{
public:
    PongTask(Semaphore_s& ping, Semaphore_s& pong) noexcept
        : ping_(ping),
          pong_(pong)
    {}

    void Run() noexcept override
    {
        for (std::size_t i = 0; i < RoundTrips; ++i) {
            pong_.Wait();
            ping_.Release();
            ++count;
        }
    }

    std::size_t count = 0;

private:
    Semaphore_s& ping_;
    Semaphore_s& pong_;
};

int main()
{
    TC::ConvertThreadToTask();

    // Two threads, so that a Release can wake the other task on the other thread.
    // With one thread both tasks share it and every round trip is two plain task
    // switches.
    TC::SchedulerInfo   info{.numberOfThreads = 2};
    TC::ITaskScheduler* scheduler
        = TC::CreateTaskScheduler(TC::SchedulerKind::Default, &info);
    if (scheduler == nullptr)
        return 1;
    scheduler->Start();

    bool ok = false;
    {
        // The semaphores, the tasks and the group outlive the tasks: they are
        // destroyed only after Stop.
        Semaphore_s ping;
        Semaphore_s pong;
        PingTask    pingTask(ping, pong);
        PongTask    pongTask(ping, pong);

        TC::Synchronization::TaskGroup_s group; // main is its only waiter
        group.AddTask(
            &pingTask); // before AddTask: the group must count the task in first
        group.AddTask(&pongTask);

        const auto start = std::chrono::steady_clock::now();
        scheduler->AddTask(&pingTask);
        scheduler->AddTask(&pongTask);
        group.Wait();
        const std::chrono::duration<double> elapsed
            = std::chrono::steady_clock::now() - start;

        scheduler->Stop();

        ok = pingTask.count == RoundTrips && pongTask.count == RoundTrips;
        std::printf("%zu round trips in %.3f s: %.1f million per second (%s)\n",
                    RoundTrips, elapsed.count(),
                    static_cast<double>(RoundTrips) / elapsed.count() / 1e6,
                    ok ? "ok" : "MISMATCH");
    }

    scheduler->Destroy();
    TC::ConvertTaskToThread();
    return ok ? 0 : 1;
}

Source and project on GitHub

Build and run

A console program. With the core's props file imported and the library referenced (see Set up a project) it needs nothing else. It runs well under a second; the rate depends on the machine:

1000000 round trips in 0.700 s: 1.4 million per second (ok)

What to notice

  • One waiter per semaphore. Only the ping task waits on ping, only the pong task on pong, so the cheaper Semaphore_s fits. With two tasks waiting on the same _s object the program would be invalid – see Single-waiter variants.
  • Threads matter. The program creates two scheduler threads, so a Release may resume the other task on the other thread. With numberOfThreads = 1 both tasks share one thread and every round trip is two plain task switches; change the setting and compare the rates on your machine.
  • Lifetimes. The semaphores, the tasks and the group are destroyed only after Stop: an object a task waits on, signals or reports its exit to must outlive that task. See Lifetimes.
  • Count in first. AddTask on the group comes before AddTask on the scheduler, so a task cannot exit before it is counted.

See also