Search code examples
c#async-awaitiasyncenumerableconfigureawait

What does ConfigureAwait(false) in an async await foreach loop do?


I am trying to understand the impact of setting ConfigureAwait(false) in an async foreach loop?

await foreach (var item in data.ConfigureAwait(false))
{
  //...
}

Exactly where do we lose the synchronization context? When would I want to set ConfigureAwait(false) inside the foreach look as above?

I understand the point of doing it in the async data source, but what about the foreach loop?

I would expect the context inside the loop to have no synchronization context, but my experiments show that the context is still there.

Here's my sample Winform app:

private async void button1_Click(object sender, EventArgs e)
{
    var dataSource = new DataFactory();

    IAsyncEnumerable<int> data = dataSource.GetData();

    await foreach (var item in data.ConfigureAwait(false))
    {
        Console.WriteLine(item);
        DataFactory.PrintContext("Data - item: " + item);
    }
}

public class DataFactory
{
    public async IAsyncEnumerable<int> GetData()
    {
        for (int i = 0; i < 10; i++)
        {
            await Task.Delay(250).ConfigureAwait(true);
            PrintContext("Generating Data: " + i);
            yield return i;
        }
    }

    public static void PrintContext(string message)
    {
        Debug.WriteLine($"{message}, TID={Environment.CurrentManagedThreadId}, SyncContext.Current={(SynchronizationContext.Current?.GetType().Name?.ToString() ?? "null")}");
    }
}

Sample output when I run the code:

Generating Data: 0, TID=1, SyncContext.Current=WindowsFormsSynchronizationContext
Data - item: 0, TID=1, SyncContext.Current=WindowsFormsSynchronizationContext
Generating Data: 1, TID=1, SyncContext.Current=WindowsFormsSynchronizationContext
Data - item: 1, TID=1, SyncContext.Current=WindowsFormsSynchronizationContext
Generating Data: 2, TID=1, SyncContext.Current=WindowsFormsSynchronizationContext
Data - item: 2, TID=1, SyncContext.Current=WindowsFormsSynchronizationContext
Generating Data: 3, TID=1, SyncContext.Current=WindowsFormsSynchronizationContext
Data - item: 3, TID=1, SyncContext.Current=WindowsFormsSynchronizationContext

Solution

  • The GetData iterator captures the ambient synchronization context on each await, because the await is configured with ConfigureAwait(true). So the continuation after the await runs on the captured synchronization context. Since the GetData is enumerated on the UI thread, the ambient synchronization context is the WindowsFormsSynchronizationContext, which is captured, so the continuation runs on the UI thread.

    Your expectation that using the external ConfigureAwait(false) on the enumeration would somehow redirect the enumeration to the ThreadPool is not based. The ConfigureAwait just configures the capturing of the context. It's not a redirector/context-switcher. In case there is a context present, the ConfigureAwait(false) will not capture it, but the context will stay there. The ConfigureAwait(false) is not a context-eliminator. It's not a call to action. It's a declaration of indifference. It basically says: "I don't care if a context exists, and I will act like it doesn't, even if it does."

    A common misconception is that in the absence of a synchronization context, the await continuation is running on the ThreadPool. In reality the continuation is running on whatever thread completed the awaited Task. You can find an experimental demonstration of this fact in this answer.

    In case you want to run the enumeration on the ThreadPool, you can just enclose the whole enumeration in a Task.Run:

    await Task.Run(async () =>
    {
        await foreach (var item in data)
        {
            Console.WriteLine(item);
            DataFactory.PrintContext("Data - item: " + item);
        }
    }
    

    This way the GetData iterator will find no synchronization context to capture, making the internal ConfigureAwait(true) irrelevant. The enumeration will start on the ThreadPool (because of the Task.Run), and will stay on the ThreadPool because that's where the Task.Delay is completed by design. So it's coincidental. The Task.Run by itself doesn't guarantee that the whole enumeration will happen on the ThreadPool. It only affects where the enumeration starts.