20

We are using this code snippet from StackOverflow to produce a Task that completes as soon as the first of a collection of tasks completes successfully. Due to the non-linear nature of its execution, async/await is not really viable, and so this code uses ContinueWith() instead. It doesn't specify a TaskScheduler, though, which a number of sources have mentioned can be dangerous because it uses TaskScheduler.Current when most developers usually expect TaskScheduler.Default behavior from continuations.

The prevailing wisdom appears to be that you should always pass an explicit TaskScheduler into ContinueWith. However, I haven't seen a clear explanation of when different TaskSchedulers would be most appropriate.

What is a specific example of a case where it would be best to pass TaskScheduler.Current into ContinueWith(), as opposed to TaskScheduler.Default? Are there rules of thumb to follow when making this decision?

For context, here's the code snippet I'm referring to:

public static Task<T> FirstSuccessfulTask<T>(IEnumerable<Task<T>> tasks)
{
    var taskList = tasks.ToList();
    var tcs = new TaskCompletionSource<T>();
    int remainingTasks = taskList.Count;
    foreach(var task in taskList)
    {
        task.ContinueWith(t =>
            if(task.Status == TaskStatus.RanToCompletion)
                tcs.TrySetResult(t.Result));
            else
                if(Interlocked.Decrement(ref remainingTasks) == 0)
                    tcs.SetException(new AggregateException(
                        tasks.SelectMany(t => t.Exception.InnerExceptions));
    }
    return tcs.Task;
}
StriplingWarrior
  • 151,543
  • 27
  • 246
  • 315

4 Answers4

11

Probably you need to choose a task scheduler that is appropriate for actions that an executing delegate instance performs.

Consider following examples:

Task ContinueWithUnknownAction(Task task, Action<Task> actionOfTheUnknownNature)
{
    // We know nothing about what the action do, so we decide to respect environment
    // in which current function is called
    return task.ContinueWith(actionOfTheUnknownNature, TaskScheduler.Current);
}

int count;
Task ContinueWithKnownAction(Task task)
{
    // We fully control a continuation action and we know that it can be safely 
    // executed by thread pool thread.
    return task.ContinueWith(t => Interlocked.Increment(ref count), TaskScheduler.Default);
}

Func<int> cpuHeavyCalculation = () => 0;
Action<Task> printCalculationResultToUI = task => { };
void OnUserAction()
{
    // Assert that SynchronizationContext.Current is not null.
    // We know that continuation will modify an UI, and it can be safely executed 
    // only on an UI thread.
    Task.Run(cpuHeavyCalculation)
        .ContinueWith(printCalculationResultToUI, TaskScheduler.FromCurrentSynchronizationContext());
}

Your FirstSuccessfulTask() probably is the example where you can use TaskScheduler.Default, because the continuation delegate instance can be safely executed on a thread pool.

You can also use custom task scheduler to implement custom scheduling logic in your library. For example see Scheduler page on Orleans framework website.

For more information check:

Leonid Vasilev
  • 11,910
  • 4
  • 36
  • 50
  • Thank you. You've given specific, code-based examples of when various schedulers would be appropriate, and specifically addressed the underlying question of what task scheduler to use in the code I was considering. This is the kind of answer I was looking for. – StriplingWarrior Jul 19 '18 at 14:40
8

I'll have to rant a bit, this is getting way too many programmers into trouble. Every programming aid that was designed to make threading look easy creates five new problems that programmers have no chance to debug.

BackgroundWorker was the first one, a modest and sensible attempt to hide the complications. But nobody realizes that the worker runs on the threadpool so should never occupy itself with I/O. Everybody gets that wrong, not many ever notice. And forgetting to check e.Error in the RunWorkerCompleted event, hiding exceptions in threaded code is a universal problem with the wrappers.

The async/await pattern is the latest, it makes it really look easy. But it composes extraordinarily poorly, async turtles all the way down until you get to Main(). They had to fix that eventually in C# version 7.2 because everybody got stuck on it. But not fixing the drastic ConfigureAwait() problem in a library. It is completely biased towards library authors knowing what they are doing, notable is that a lot of them work for Microsoft and tinker with WinRT.

The Task class bridged the gap between the two, its design goal was to make it very composable. Good plan, they could not predict how programmers were going to use it. But also a liability, inspiring programmers to ContinueWith() up a storm to glue tasks together. Even when it doesn't make sense to do so because those tasks merely run sequentially. Notable is that they even added an optimization to ensure that the continuation runs on the same thread to avoid the context switch overhead. Good plan, but creating the undebuggable problem that this web site is named for.

So yes, the advice you saw was a good one. Task is useful to deal with asynchronicity. A common problem that you have to deal with when services move into the "cloud" and latency gets to be a detail you can no longer ignore. If you ContinueWith() that kind code then you invariably care about the specific thread that executes the continuation. Provided by TaskScheduler, low odds that it isn't the one provided by FromCurrentSynchronizationContext(). Which is how async/await happened.

Hans Passant
  • 922,412
  • 146
  • 1,693
  • 2,536
  • Much appreciated rant. Maybe you can clarify a bit some of the wording. In particular the last sentence is a bit terse and I'm not at all sure what it meant to say. – sehe Jul 17 '18 at 00:05
  • Not sure how to improve it, the continuation after await is biased to run on the UI thread to make it easy to update the user interface. Forcing programmers to monkey with ConfigureAwait() in a library and not actually knowing what to pass because they don't know how their library is getting used. – Hans Passant Jul 17 '18 at 00:10
  • The rant is somewhat informational, but I still don't feel like it answered the question. Are you saying that because my method doesn't know how it's going to be used, there's no way for me to know which TaskScheduler I should use for these continuations? – StriplingWarrior Jul 17 '18 at 15:19
3

If current task is a child task, then using TaskScheduler.Current will mean the scheduler will be that which the task it is in, is scheduled to; and if not inside another task, TaskScheduler.Current will be TaskScheduler.Default and thus use the ThreadPool.

If you use TaskScheduler.Default, then it will always go to the ThreadPool.

The only reason you would use TaskScheduler.Current:

To avoid the default scheduler issue, you should always pass an explicit TaskScheduler to Task.ContinueWith and Task.Factory.StartNew.

From Stephen Cleary's post ContinueWith is Dangerous, Too.

There's further explanation here from Stephen Toub on his MSDN blog.

Ben Hall
  • 1,353
  • 10
  • 19
  • 1
    I kind of feel like this answer mostly reiterated the background I already provided for my question, but didn't answer the question part. Can you speak to specific examples or rules of thumb about when to use TaskScheduler.Current? – StriplingWarrior Jul 16 '18 at 21:23
  • - StartNew() by default using TaskScheduler.Current if we not specify other Schedule type (how i know). I found code like this inMSDN link which u give: > private void button_Click(…) > { > … // #1 on the UI thread > Task.Factory.StartNew(() => > { > … // #2 long-running work, so offloaded to non-UI thread > }).ContinueWith(t => > { > … // #3 back on the UI thread > }, TaskScheduler.FromCurrentSynchronizationContext()); > } – zeroG Dec 01 '22 at 16:47
  • - Here we see first StartNew() works on UI thread cuz default TaskScheduler is Current. - I mean first StartNew() is not child task, StartNew() is not located inside some task > … // #1 on the UI thread > Task.Factory.StartNew(() => > { - but u saying: "if not inside another task, TaskScheduler.Current will be TaskScheduler.Default and thus use the ThreadPool." :which means (how i understand) StartNew() needs to work on some thread which from ThreadPool, but how we see thread is ui thread. Please can u explain in which part i making mistake? – zeroG Dec 01 '22 at 16:47
3

I most certainly don't think I am capable of providing bullet proof answer but I will give my five cents.

What is a specific example of a case where it would be best to pass TaskScheduler.Current into ContinueWith(), as opposed to TaskScheduler.Default?

Imagine you are working on some web api that webserver naturally makes multithreaded. So you need to compromise your parallelism because you don't want to use all the resources of your webserver, but at the same time you want to speed up your processing time, so you decide to make custom task scheduler with lowered concurrency level because why not.

Now your api needs to query some database and order the results, but these results are millions so you decide to do it via Merge Sort(divide and conquer), then you need all your child tasks of this algorithm to be complient with your custom task scheduler (TaskScheduler.Current) because otherwise you will end up taking all the resources for the algorithm and your webserver thread pool will starve.

When to use TaskScheduler.Current, TaskScheduler.Default, TaskScheduler.FromCurrentSynchronizationContext(), or some other TaskScheduler

  • TaskScheduler.FromCurrentSynchronizationContext() - Specific for WPF, Forms applications UI thread context, you use this basically when you want to get back to the UI thread after being offloaded some work to non-UI thread

example taken from here

private void button_Click(…) 
{ 
    … // #1 on the UI thread 
    Task.Factory.StartNew(() => 
    { 
        … // #2 long-running work, so offloaded to non-UI thread 
    }).ContinueWith(t => 
    { 
        … // #3 back on the UI thread 
    }, TaskScheduler.FromCurrentSynchronizationContext()); 
}
  • TaskScheduler.Default - Almost all the time when you don't have any specific requirements, edge cases to collate with.
  • TaskScheduler.Current - I think I've given one generic example above, but in general it should be used when you have either custom scheduler or you explicitly passed TaskScheduler.FromCurrentSynchronizationContext() to TaskFactory or Task.StartNew method and later you use continuation tasks or inner tasks (so pretty damn rare imo).
kuskmen
  • 3,648
  • 4
  • 27
  • 54