Asynchronous Best Practices in C++/CX (Part 2)

This is part two in a series of articles on asynchronous coding in C++/CX. See the introduction here.

  1. Prefer a task chain to nested tasks
  2. Be aware of thread scheduling rules
  3. Be aware of object and parameter lifetimes
  4. Consider the effect of OS suspend/resume
  5. Style: never put a try/catch block around a task chain.
  6. References

2. Be Aware of Thread Scheduling Rules

  • When working with non-threadsafe code, you will usually prefer to continue on the same thread and not have to deal with multiple threads contending for the same data.
  • Helpfully, Universal Windows Platform (UWP) apps have a user interface thread inside a Single Threaded Apartment. On that thread, continuations (either a task .then() statement or code after a co_await statement) will almost always be scheduled to continue on the same thread.
    • Exception #1: if the initial task in a chain is not an IAsyncAction or IAsyncOperation (or their WithProgress variants), then the continuation may “break out” of the apartment to a different thread.
    • Exception #2: in my tests, I found that mixing coroutines and task-based continuations could still break out of the apartment. Calling a coroutine and then following it with a .then() task-based continuation seemed to cause it, although I haven’t dug very deep into the exact conditions.
  • If a continuation is not on the UI thread, then the guarantees go out the window. For threadpool threads, a continuation can be scheduled on any thread.
    • So, partway through a coroutine, you may find yourself on an entirely different thread.
    • I imagine this is to prevent “starvation” of asynchronous chains when running on a compute-intensive thread, for example. But it can be quite a gotcha.
    • The concurrency::create_task() method can take a task_continuation_context::use_current() parameter to (try) to force execution to stay on the same thread, but there’s no equivalent for coroutines. It’s not clear to me that this always works, however; I need to test further and explore.
  • When in doubt: use std::this_thread::get_id() to check the thread id over the course of an asynchronous chain of operation.
  • When on a threadpool thread, my preference is to write a standalone coroutine in a functional style rather than as a traditional method within a class. I avoid using the this pointer, pass inputs to the coroutine by value and return a result from the asynchronous chain. If I want to modify an object, I’ll write an accompanying method that calls the coroutine, then schedules a final task on the original thread to mutate the object. Here’s an example:
    class MyClass {
    private:
      static concurrency::task<std::wstring> Coroutine(wstring filename) {
        // ... do some stuff, open file with co_await, read data, etc...
        co_return result;
      }
    public:
      void MemberFnAsync() {
        concurrency::create_task(Coroutine(m_filename),
          task_continuation_context::use_current())
        .then([this](wstring result) {
          this->SetTextFromFile(result);
        });
      }
    };

3. Be Aware of Object and Parameter Lifetimes

  • When passing data into asynchronous code, you want to pass by value. That applies to both lambda captures (for continuation tasks) and to coroutine parameters.
  • Why? If you pass do a traditional C++ calling convention like this:
    concurrency::task<void> MyCoroutineAsync (const wstring &str) {
    // do some stuff.
    co_await OtherFn();
    AnotherFn(str);
    }

    … the str parameter will typically be valid until the first co_await statement, but at that point execution will return to the caller until OtherFn() completes. The str parameter may well then be destroyed—especially if it was an rvalue or a stack variable. By the time the coroutine resumes and AnotherFn() is called, str will be a dangling pointer.

  • The underlying issue is that the coroutine task chain outlives its caller and therefore can’t count on references to data from its caller.
  • This applies to reference and pointer data. Pass-by-value is usually preferred, but of course smart pointer types are also valid.
    • Just be sure to pass smart pointers in a manner that creates a separate reference count: std::shared_ptr<T>, not const std::shared_ptr<T>&

Leave a Reply

Your email address will not be published. Required fields are marked *