microsoft / cpprestsdk

The C++ REST SDK is a Microsoft project for cloud-based client-server communication in native code using a modern asynchronous C++ API design. This project aims to help C++ developers connect to and interact with services.
Other
7.99k stars 1.65k forks source link

PPLX Task Cancellation Semantics #564

Open SeverTopan opened 6 years ago

SeverTopan commented 6 years ago

Description

PPLX tasks will only be invoked as soon as their antecedents complete, even in the case of cancellation. Consider the task chain A > B > C. Once B is cancelled, C will only execute once A completes. As consequence, B's and C's lambda will remain allocated until A completes. This behaviour leads to two issues:

  1. It is a deviation from certain standard Task library implementations such as that of C#'s Task library.
  2. It can cause runtime memory leaks to occur when continuations are appended and canceled from long-running tasks, since continuations will not be deallocated even though they will never run (the term 'leak' is loose here, as its not technically a leak as the memory is still referenced. Its more like 'unbounded runtime memory growth'). This description will mainly focus on this issue.

The memory leaking problem especially manifests itself in the implementation of when_any. when_any seems to append continuations to all tasks passed into it which sets a task completion event once the first one completes and cancels the continuations all the other tasks. If one were to call when_any inside a loop, long running tasks can find themselves with many cancelled yet non-deallocated continuations.

Simple Example


class TestClass
{
public:
    TestClass(int val)
    {
        LOG(Severity::info) << "test class created";
    }

    ~TestClass()
    {
        LOG(Severity::info) << "test class destructed";
    }
};

void PplxCancellationExample()
{
    auto cts = concurrency::cancellation_token_source();

    LOG(Severity::info) << "test started";
    {
        auto val = std::make_shared<TestClass>();

        concurrency::create_task([cts]()
        {
            std::cin.get();

            LOG(Severity::info) << "task done";
        })
        .then([val]()
        {
            LOG(Severity::info) << "continuation done";
        }, cts.get_token());

    }
    cts.cancel();
    LOG(Severity::info) << "test ended";
}

When run, we observe

[2017-09-28 16:24:00.930817]: test started
[2017-09-28 16:24:00.932820]: test class 0 created
[2017-09-28 16:24:00.934820]: test ended

At this point, val has been created and passed to the continuation of the task by capture. val has left its scope, and the continuation to the create_task has been cancelled, but the execution hangs on std::cin. Since the continuation has been cancelled, it will not run, and the val that was captured cannot be accessed, however, note that we have not seen "test class 0 destructed" yet. This only occurs once std::cin is triggered:

[2017-09-28 16:24:07.800406]: task done
[2017-09-28 16:24:07.801889]: test class 0 destructed

The fact that cancelled continuations do not deallocate their contents can cause memory leak conditions if a set of tasks added as continuations onto a long-running antecedent. Even if elements are cancelled, the tasks will not be destructed until the antecedent runs to completion. This can cause major leaks with certain task design patterns.

stewartbright commented 6 years ago

This makes the use of PPLX pretty difficult for any long-running program. A simple analogy would be if the only way to receive a notification was to subscribe to an event ... but you can't ever unsubscribe. And, because tasks are not differentiable, you don't even know if you've already subscribed to this event or not, so you just keep subscribing and adding to runtime memory.