⚑ Async/Await Thread Switching Pitfall

Why Thread-Local State Gets Lost After Await



Press β†’ or Space to navigate

πŸ“‹ Agenda

πŸ”΄ The Problem

Real-World Symptom

What Was Happening?

πŸ” Understanding Thread-Local State

What is Thread-Local State?

In Windows Security:

ImpersonateLoggedOnUser(token)  β†’ Sets impersonation on CURRENT thread
RevertToSelf()                   β†’ Clears impersonation on CURRENT thread
Key Point: Impersonation is bound to the thread, NOT the code!

⚑ The Async/Await Thread Switch

Before Await

Thread A: [Your Code]
    ↓
Set impersonation
    ↓
Start async work
    ↓
await ← yields here
    ↓
(Thread A returns to pool)

After Await

Thread B: (picked from pool)
    ↓
Continuation code
    ↓
Finally block
    ↓
RevertToSelf() ← Wrong thread!
Thread AThread B
βœ… Has impersonation set
πŸ”„ Returns to pool (STILL impersonated!)
❌ No impersonation
⚠️ RevertToSelf() does nothing

πŸ”¬ Why Does The Thread Change?

The Mechanics of async/await

await does NOT pause the thread. It releases the thread back to the pool and schedules a continuation to run when the async operation completes.

Step-by-Step:

  1. Before await: Thread 1 executes your code, sets impersonation
  2. At await: The compiler transforms your code into a state machine
  3. Thread 1 released: Returns to ThreadPool, still carrying impersonation!
  4. Async work completes: TaskScheduler picks any available thread from pool
  5. Continuation runs: Thread 12 (different!) resumes after the await

Key Insight:

The ThreadPool doesn't care about thread-local state. It just grabs the first available thread to run your continuation. That thread knows nothing about what Thread 1 was doing.

πŸ“Š Visual Flow: The Bug

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ ProcessUserAsync("User1") β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ Thread 1 THREAD 12 β”‚ β”‚ β”‚ β”‚ 1. ImpersonateLoggedOnUser() β”‚ β”‚ βœ“ Thread 1 is now User1 β”‚ β”‚ β”‚ β”‚ 2. await Task.WhenAll(...) β”‚ β”‚ └── Thread 1 returns to pool ──┐ β”‚ β”‚ (STILL IMPERSONATED!) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ └──▢ 3. Continuation runs β”‚ β”‚ Thread 12 has NO impersonation! β”‚ β”‚ β”‚ β”‚ 4. finally { RevertToSelf() } β”‚ β”‚ ❌ Returns FALSE - Nothing to revert! β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Thread Pool State After User1:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Thread 1 β”‚ Thread 12          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ πŸ”΄User1 β”‚βœ…No Impersonation β”‚  ← Thread 1 is DIRTY!
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ“Š Visual Flow: The Failure

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ ProcessUserAsync("User2") β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ Thread 1 (reused from pool - still impersonated as User1!) β”‚ β”‚ β”‚ β”‚ 1. Code assumes it's running as LOCAL ADMIN (high privilege) β”‚ β”‚ But actually still impersonated as User1 from previous operation! β”‚ β”‚ β”‚ β”‚ 2. Tries to perform admin-level operation β”‚ β”‚ (e.g., access protected resource) β”‚ β”‚ β”‚ β”‚ ❌ FAILS! Running as User1, not admin β”‚ β”‚ ❌ Returns ACCESS_DENIED (0x5) β”‚ β”‚ β”‚ β”‚ 3. Error handling catches and cleans up β”‚ β”‚ βœ“ Thread 1 is now clean again β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Predictable Pattern:

UserThread StateResultAfter
User 1Cleanβœ… SuccessThread dirty
User 2Dirty❌ Fail + CleanupThread clean
User 3Cleanβœ… SuccessThread dirty
User 4Dirty❌ Fail + CleanupThread clean

🎬 Live Demo

Running the Demo

cd c:\Users\zhenxingl\upm\AppxPreRegisterTool\AsyncThreadSwitchDemo
dotnet run

Watch For:

Expected Output Pattern:

[User1] Thread 1: Set thread-local state to 'User1'
[User1] Thread 12: ⚠️ THREAD SWITCH! Was on Thread 1
[User1] Thread 12: ❌ State LOST!
[User1] Thread 12: ❌ CLEANUP FAILED! No state to clear.

πŸ“‹ The Problem Code Pattern

// ❌ WRONG: async method with thread-local state
static async Task ProcessUserAsync(string userName)
{
    try
    {
        ThreadLocalState.Value = userName;     // Thread A: Sets state
        
        await Task.Delay(100);                  // ⚠️ THREAD SWITCH HAPPENS HERE!
        
        // Now on Thread B - state is LOST!
    }
    finally
    {
        ThreadLocalState.Value = null;         // Thread B: Nothing to clear!
    }
}
The Problem: The await keyword allows the thread to switch, but the finally block expects to run on the same thread that set up the state.

βœ… The Solution

Encapsulate Async Work in Separate Function

// βœ… CORRECT: Synchronous wrapper keeps impersonation on same thread
static void ProcessUser(string userName)  // No async!
{
    ImpersonateLoggedOnUser(userToken);  // Thread A
    try
    {
        // Call async work and BLOCK - do NOT await here!
        DoAsyncWorkAsync().Wait();       // Async work runs on thread pool
    }
    finally
    {
        RevertToSelf();                  // Thread A - SAME thread! βœ…
    }
}

// Async work is isolated - no impersonation concerns here
static async Task DoAsyncWorkAsync()
{
    await Task.WhenAll(registrationTasks);
    // Thread switches are fine here - no thread-local state
}

βœ… Why The Solution Works

ThreadAction
Thread AImpersonateLoggedOnUser() - sets impersonation
Thread ACalls DoAsyncWorkAsync().Wait() - BLOCKS but stays on Thread A
Thread B/C/DAsync work runs on thread pool (no impersonation needed)
Thread Afinally block runs - RevertToSelf() on SAME thread βœ…

Key Insight:

Using .Wait() instead of await causes the calling thread to block but not yield. The thread stays parked until the async work completes, guaranteeing the finally block runs on the same thread.

πŸ”‘ Key Takeaways

1. Async/Await Can Switch Threads

Code before await and code after await may run on different threads

2. Thread-Local State Doesn't Transfer

Impersonation, ThreadLocal<T>, and other thread-bound state stays on the original thread

3. Finally Blocks Run on Continuation Thread

Cleanup code in finally cannot access the original thread's state

4. Encapsulate Async Work Separately

Keep thread-local setup/cleanup in a synchronous wrapper, isolate async work in separate functions

⚠️ Red Flags to Watch For

Dangerous Code Pattern:

// 🚨 DANGEROUS: Setting thread state before await
async Task DoWork()
{
    SetThreadState();      // impersonation, culture, etc
    try
    {
        await SomeAsyncOperation();  // Thread switch happens here!
    }
    finally
    {
        ClearThreadState();          // Wrong thread!
    }
}

Affected APIs:

✨ Best Practices

❌ WRONG

async Task Bad()
{
    Impersonate();
    try
    {
        await Work();
    }
    finally
    {
        Revert(); // Wrong thread!
    }
}

βœ… CORRECT

void Good()
{
    Impersonate();
    try
    {
        Work().Wait();
    }
    finally
    {
        Revert(); // Same thread!
    }
}

Additional Tips:

πŸ“š Summary

ConceptKey Point
ProblemAsync/await switches threads; thread-local state stays on original thread
SymptomAlternating failures (odd users work, even users fail), ACCESS_DENIED
Root CauseCleanup runs on different thread than setup
SolutionEncapsulate async work; keep impersonation in sync wrapper
PreventionNever await inside impersonation try/finally block

πŸ™‹ Questions?

Resources:

Key Pattern Applied:

void ProcessUser()            // Sync wrapper
{
    Impersonate();
    try
    {
        AsyncWork().Wait();  // Block here
    }
    finally
    {
        RevertToSelf();       // Same thread!
    }
}

πŸŽ‰ Thank You!


"The best bug fixes are often one line of code,
but finding that line requires understanding the whole system."
1 / 18