Pitfalls mixing PPL+await

Imagine you have a C++/CX codebase with a lot of asynchronous logic written using the classic Parallel Patterns Library (PPL) tasks:

task<void> FooAsync(StorageFolder ^folder) {
  return create_task(folder->TryGetItemAsync(L"foo.txt"))
    .then([](IStorageItem ^item) {
      // do some stuff, cast to a file, etc.
    });
}

It’s all running on the UI thread, so it’s in a Single Threaded Apartment, and each task in your chain of .then() continuations is guaranteed to also be on the UI thread.

Now, you learn about Microsoft’s relatively new co_await feature, recently accepted in an adapted form in the C++20 spec. So you start using it:

task<void> FooAsyncCo(StorageFolder ^folder) {
  IStorageItem ^item =
    co_await folder->TryGetItemAsync(L"foo.txt");
  // do some stuff, cast to a file, etc.
}

So far so good: all code inside FooAsyncCo() also runs on the UI thread, nice and clean.

Finally, you integrate a little of this co_await code into your heavily PPL codebase. Helpfully, co_await returns a task, just like your existing async PPL method. Another developer sees that you have a bunch of methods returning task<void> and decides to start building on that API using PPL:

FooAsyncCo().then([]() {
  // more stuff, after coroutine.
  // KABOOM. Running on a threadpool thread, *not* the UI thread.
  // We accidentally busted out of the STA.
});

Uh-oh. When you mix the two async approaches… things break. And none of the documentation – or honestly anything I’ve read on the web to date – gives any hint of this issue.

Not A Fix

Well… let’s see. The docs do give us one tip: a task chain is only guaranteed to remain inside the Single Threaded Apartment if the chain starts with an IAsyncAction or IAsyncOperation

The UI of a UWP app runs in a single-threaded apartment (STA). A task whose lambda returns either an IAsyncAction or IAsyncOperation is apartment-aware. If the task is created in the STA, then all of its continuations will run also run in it by default, unless you specify otherwise. In other words, the entire task chain inherits apartment-awareness from the parent task. This behavior helps simplify interactions with UI controls, which can only be accessed from the STA.

Asynchronous programming in C++/CX: Managing the Thread Context

Well… TryGetItemAsync does return an IAsyncOperation. That would appear to be the root task in the chain… so coroutines must be treated differently, with the coroutine itself being the root.

Well, what if we tried making the coroutine return an IAsyncAction?

IAsyncAction ^FooAsyncCoAction(StorageFolder ^folder) {
  return create_async([folder]() -> task<void> {
    // KABOOM - this is now out of the apartment.
    IStorageItem ^item =
      co_await folder->TryGetItemAsync(L"foo.txt");
    // do some stuff, cast to a file, etc.
  });
}
create_task(FooAsyncCo())
.then([]() {
  // more stuff, after coroutine.
  // ok now!
});

No dice. Now the coroutine runs off-thread, while the continuation runs correctly on the UI thread.

Two Actual Fixes

With a little more reading, I found Raymond Chen’s blog post about PPL and apartment-aware tasks. That led to this successful solution:

task<void> completed_apartment_aware_task() {
  return create_task(create_async([](){}));
}

completed_apartment_aware_task()
.then(FooAsyncCo)
.then([]() {
  // Ok!
});

This one actually works. By rooting the PPL chain in an IAsyncAction, the rest of the chain retains apartment awareness, and everything stays on the UI thread.

And I’d earlier found a different but more fragile solution:

create_task(FooAsyncCo, task_continuation_context::use_current())
.then([]() {
  // Ok!
});

While this works, it’s… a bit hard to tell whether the root task, or the continuation, or what-all needs use_current(), and it’s always felt fragile to me. And if it gets called from a threadpool thread, it’s unclear to the caller what happens with use_current().

Conclusion

I’ve got to say… this is a brutal pitfall. Any existing codebase is going to have a lot of PPL tasks in it, and wholesale migration to co_await isn’t going to happen all in one go, at least if there’s any conditional/loop/exception logic in the PPL-based code. Anyone who’s tried to migrate to co_await has probably run into this pitfall.

We haven’t adopted the completed_apartment_aware_task() as a root task throughout our codebase yet, but I’m hopeful that will at least offer a path forward. Just… still quite error-prone.

Leave a Reply

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