June 13th, 2025
heartlike2 reactions

Thread pool threads are like preschool: Leave things the way you found them

A customer wanted to use the Windows thread pool, but they also wanted to use COM from their work item. They saw in the COM documentation that each thread must call Co­Initialize(Ex) before using COM, so they planned on doing something like this:

thread_local bool isComInitialized = false;

auto DoWorkOnBackground()
{
    return TrySubmitThreadpoolCallback(
        WorkFunction, nullptr, nullptr);
}

void CALLBACK WorkFunction(
    [[maybe_unused]] PTP_CALLBACK_INSTANCE instance,
    [[maybe_unused]] void* context)
{
    if (!isComInitialized) {
        CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
        isComInitialized = true;
    }

    ⟦ do some work ⟧
}

The idea is that before each work function runs, it checks whether it has already initialized COM in apartment threaded mode for this thread. If not, it does the initialization, and then it remembers that the initialization has been done so it won’t do it again.

The problem with this approach is that it initializes COM on a thread pool thread but fails to clean up the initialization before returning the thread to the thread pool. The nickname for a thread in the thread pool that has been left in a bad state is poisoned.

When the next task runs on that thread, it will be running on a COM single-threaded apartment even though it didn’t expect to. That thread might perform a long blocking operation like Wait­For­Single­Object, thinking that it’s safe to do so because it’s on a background thread. Unfortunately, it’s secretly running on a COM single-threaded apartment, which is required to pump messages while waiting. The result is that you now have a UI thread that has stopped pumping messages, and the system will mark it as unresponsive. And the system might be broadcasting a message to all windows, and that includes the helper window that COM created to manage inbound calls to the single-threaded apartment. The broadcast hangs, and now you have problems like hangs in the System­Parameters­Info function or 30-second hangs when opening documents waiting for the DDE timeout.

Indeed, the problem occurs even before the thread is used to run another task. If there are no tasks waiting to run, then the thread is returned to the thread pool, where the thread simply blocks waiting for work. This block happens without pumping messages because the thread pool has no expectation that anybody just left windows lying around on the thread. And then you get the same problem of broadcast hangs.

Even if you don’t get a broadcast hang, the next task that runs on the thread pool thread might do a Co­Initialize­Ex(COINIT_MULTI­THREADED) to initialize the thread in the multithreaded apartment, and it will get the error RPC_E_CHANGED_MODE. There is really no recovery from this, so the task will fail, and then the poisoned thread pool thread gets returned to the thread pool, ready to terrorize the next task.

So leave thread pool threads the same way you found them. If you initialize COM, then uninitialize it. If you change the thread priority, then set it back. If you change the thread execution state to “continuous display required”, then change it back. That thread does not belong to you. You were merely given permission to borrow it. You are a guest in someone else’s house: If you want to put up your own posters, remember to take them down when you leave.

Bonus chatter: Don’t forget to do your cleanup even if the task fails. Using a C++ RAII type makes it easy to ensure that the cleanup occurs no matter how the function exits.

Topics
Code

Author

Raymond has been involved in the evolution of Windows for more than 30 years. In 2003, he began a Web site known as The Old New Thing which has grown in popularity far beyond his wildest imagination, a development which still gives him the heebie-jeebies. The Web site spawned a book, coincidentally also titled The Old New Thing (Addison Wesley 2007). He occasionally appears on the Windows Dev Docs Twitter account to tell stories which convey no useful information.

4 comments

Discussion is closed. Login to edit/delete existing comments.

Sort by :
  • Joshua Hudson

    Fun fact: RPC_E_CHANGED_MODE on CoInitialize from the thread pool thread is recoverable. Long story why I know this:

    We have this product with a component that processes messages from a hemodynamic system. While we were developing the new product we discovered it wouldn't be out in time; however this component was nearly ready when a new message kind became available from the hemodynamic system. So we decided to use the new component in the legacy product to avoid writing the new message kind processor in the legacy product.

    The way the legacy product worked is the native processor in C++ would start...

    Read more
  • Shawn Van Ness

    This sounds like something AppVerifier should check for. (Can it? Is there an API to ask “is current thread a threadpool thread?”)

  • Jyrki Vesterinen

    And there’s also the other option: if you don’t like being a guest, don’t be one. A custom thread pool isn’t all that difficult to write and it would give you your very own threads you can use however you please.