1

I wrote a custom TaskScheduler which is supposed to execute given tasks on the same thread. This task scheduler is used with a custom task factory. This task factory executes an async method ReadFileAsync which calls another async method ReadToEndAsync of StreamReader.

I've noticed that after using ReadToEndAsync().ConfigureAwait(false), the current task scheduler is reverted back to the default one, ThreadPoolTaskScheduler. If I remove ConfigureAwait(false), the custom task scheduler SameThreadTaskScheduler is kept. Why? Is there any way to use ConfigureAwait(false) with the same custom scheduler after its execution?

I've tried multiple things, but the result is the same:

  • Change the enum flags of the custom TaskFactory
  • Using a custom synchronization context that Posts the callback synchronously instead of the thread pool
  • Change and revert the synchronization inside the task function executed on the task factory
  • Change and revert the synchronization outside of the task function executed on the task factory
public static class Program
{
    private static readonly string DesktopPath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);

    public static void Main()
    {
        _ = AsyncHelper.RunSynchronously(ReadFileAsync);
    }

    private static async Task<string> ReadFileAsync()
    {
        // Prints "SameThreadTaskScheduler"
        Console.WriteLine(TaskScheduler.Current.GetType().Name);

        using var fs = File.OpenText(Path.Combine(DesktopPath, "hello.txt"));
        var content = await fs.ReadToEndAsync().ConfigureAwait(false); // <-------- HERE

        // With ReadToEndAsync().ConfigureAwait(false), prints "ThreadPoolTaskScheduler"
        // With ReadToEndAsync() only, prints "SameThreadTaskScheduler"
        Console.WriteLine(TaskScheduler.Current.GetType().Name);

        return content;
    }
}

public static class AsyncHelper
{
    private static readonly TaskFactory SameThreadTaskFactory = new TaskFactory(
        CancellationToken.None,
        TaskCreationOptions.None,
        TaskContinuationOptions.None,
        new SameThreadTaskScheduler());

    public static TResult RunSynchronously<TResult>(Func<Task<TResult>> func)
    {
        var oldContext = SynchronizationContext.Current;

        try
        {
            SynchronizationContext.SetSynchronizationContext(null);
            return SameThreadTaskFactory.StartNew(func).Unwrap().GetAwaiter().GetResult();
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(oldContext);
        }
    }
}

public sealed class SameThreadTaskScheduler : TaskScheduler
{
    public override int MaximumConcurrencyLevel => 1;

    protected override void QueueTask(Task task)
    {
        this.TryExecuteTask(task);
    }

    protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
    {
        this.TryExecuteTask(task);
        return true;
    }

    protected override IEnumerable<Task> GetScheduledTasks()
    {
        return Enumerable.Empty<Task>();
    }
}
Anthony Simmon
  • 1,579
  • 12
  • 26
  • 1
    It would help a lot to know *why* you're calling `ConfigureAwait(false)` if you care about keeping custom state consistent. – Damien_The_Unbeliever Jan 21 '21 at 16:00
  • 1
    It seems that you are frustrated because the `ConfigureAwait(false)` does exactly what is supposed to do. Which is kinda strange. It's like asking why the `+` operator performs addition, and how you could stop it from performing addition. Well, OK. If you don't like that the `ConfigureAwait(false)` configures the `await` so that it doesn't capture the current context or scheduler, what would you like it to do? Would you like it to be a no-op? – Theodor Zoulias Jan 21 '21 at 18:51
  • What are you trying to accomplish with your custom scheduler? Can it be accomplished with `AsyncLocal` instead? Are you blocking on asynchronous code? – Stephen Cleary Jan 21 '21 at 23:37
  • I was trying to understand why the current custom task scheduler changes after calling async with `ConfigureAwait(false)`. With your comments and answers, I get it now! – Anthony Simmon Jan 22 '21 at 05:31
  • My story is that I'm working on a desktop .NET 4.5 application that uses a custom task scheduler to limit the number of thread that can be spawned. While trying to migrate it to .NET Core 3.1, I had to replace use of `HttpWebRequest`with `HttpClient`. At first, I noticed that it was using the custom thread-limiter task scheduler, resulting in thread starvation. That's why I'm trying to ensure that `HttpClient` async methods and callbacks were executed on the same thread with another task scheduler. – Anthony Simmon Jan 22 '21 at 05:31
  • The `TaskScheduler`s are useless regarding asynchronous operations. A `TaskScheduler` specifies where to run the delegate of a delegate-based task. But asynchronous methods create promise-style tasks, not delegate-based tasks. A promise-style task cannot be scheduled because it is already started upon creation, and typically [it's not running on a thread](https://blog.stephencleary.com/2013/11/there-is-no-thread.html). For limiting the concurrency of async operations you can look [here](https://stackoverflow.com/questions/10806951/how-to-limit-the-amount-of-concurrent-async-i-o-operations/). – Theodor Zoulias Jan 22 '21 at 11:37

1 Answers1

2

The parameter continueOnCapturedContext in ConfigureAwait(bool continueOnCapturedContext) has the following meaning: If true is specified, this means that the continuation should be marshaled back to the original context captured. If false is specified, the continuation may run on an arbitrary context.

The synchronization context is an abstraction for scheduling. A TaskScheduler is a concrete implementation. So by specifying ConfigureAwait(false), you state that any TaskScheduler can be used. If you want to use your special TaskScheduler, than use ConfigureAwait(true).

For more information on this topic, take a look at this post.

Xaver
  • 1,035
  • 9
  • 17