📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is a TaskScheduler, and what is the difference between TaskScheduler.Default and TaskScheduler.Current?

TaskScheduler responsibilities, the default ThreadPool scheduler, Current versus Default pitfalls with StartNew and ContinueWith, FromCurrentSynchronizationContext and ConcurrentExclusiveSchedulerPair.

Senior Theory Advanced concurrency task taskscheduler tpl

🎯 Key points

  • A TaskScheduler decides where delegate tasks and ContinueWith continuations run
  • TaskScheduler.Default is the ThreadPool
  • TaskScheduler.Current is the scheduler of the running task, else Default
  • StartNew and ContinueWith use Current unless told otherwise; Task.Run uses Default
  • await captures a non-default Current when there is no SynchronizationContext
  • ConcurrentExclusiveSchedulerPair can limit or serialise synchronous work
  • StartNew with an async lambda returns Task<Task>

⏱ 30-second answer

A TaskScheduler decides where and when queued delegate tasks run. TaskScheduler.Default queues to the ThreadPool. TaskScheduler.Current is the scheduler of the task currently executing, or Default if none. Task.Run always uses Default, but Task.Factory.StartNew and ContinueWith use Current unless you pass one, so code called from inside a task on a custom or UI scheduler can unexpectedly run there. Pass TaskScheduler.Default explicitly. Custom schedulers (such as ConcurrentExclusiveSchedulerPair) can limit concurrency or serialise work.

🗣 Full answer (senior)

How I would answer this in an interview

"A TaskScheduler decides where and when queued work runs: delegate tasks from Task.Factory.StartNew and continuations registered with ContinueWith. TaskScheduler.Default is the ThreadPool. TaskScheduler.Current is ambient: it's the scheduler of the task that's currently executing, or Default if no task is executing. The trap is that StartNew and ContinueWith use Current when you don't pass a scheduler. So library code called from inside a task running on the UI scheduler or a custom scheduler can suddenly run there, for example CPU work landing on the UI thread. Task.Run always uses Default, which is one reason it's the safer API. Also, when there's no SynchronizationContext, await captures a non-default Current, so code inside a custom-scheduled task resumes on that scheduler. My rule: application code uses Task.Run and await; any StartNew/ContinueWith gets an explicit scheduler."


1. What a scheduler does

Member Responsibility
QueueTask(Task) Accept a task to run later
TryExecuteTaskInline(Task, bool) Optionally run it right now on the current thread (e.g. when someone waits on it)
GetScheduledTasks() For debuggers
MaximumConcurrencyLevel Advertised limit

Promise tasks (I/O, Task.Delay, TaskCompletionSource) aren't "run" by a scheduler; schedulers matter for the continuations and delegate tasks attached to them.

2. Built-in schedulers

Scheduler Runs work on
TaskScheduler.Default ThreadPool
TaskScheduler.FromCurrentSynchronizationContext() The UI thread (WinForms/WPF), via Post
ConcurrentExclusiveSchedulerPair.ConcurrentScheduler Pool, at most N at a time
ConcurrentExclusiveSchedulerPair.ExclusiveScheduler Pool, one at a time, never concurrently with concurrent-side tasks

3. Current vs Default

API Scheduler used when not specified
Task.Run Default (also DenyChildAttach, CancellationToken.None)
Task.Factory.StartNew Current
task.ContinueWith(...) Current
new TaskFactory() Current at the time it's used
await continuation SynchronizationContext if present → else non-default Current → else pool
Parallel.For/ForEach ParallelOptions.TaskScheduler or Default
UI event -> ContinueWith(..., FromCurrentSynchronizationContext())   // runs on UI scheduler
            inside: Task.Factory.StartNew(LoadReport)                // Current = UI scheduler!
                    -> LoadReport runs on the UI thread -> app freezes

TaskCreationOptions.HideScheduler makes Current look like Default for code running inside a task, which is useful when you host untrusted library code on a custom scheduler.

4. What schedulers can (and can't) limit

Goal Scheduler helps? Better tool
Serialise synchronous work on a resource Yes: ExclusiveScheduler Or a single-reader Channel<T>
Limit CPU parallelism Yes ParallelOptions.MaxDegreeOfParallelism
Limit async operations (HTTP calls in flight) No: only synchronous segments are controlled SemaphoreSlim, Parallel.ForEachAsync
Thread affinity (STA COM, single UI thread) Yes: custom single-thread scheduler —

Production example

Our [WPF reporting app] froze for [about 10 seconds] whenever a user opened a large report. A dump showed the UI thread inside LoadReport. The code looked fine at first glance: a helper called Task.Factory.StartNew(LoadReport). But that helper was invoked from a continuation scheduled with TaskScheduler.FromCurrentSynchronizationContext(), so TaskScheduler.Current was the UI scheduler, and StartNew used it. I changed the helper to await Task.Run(LoadReport) and updated the UI after the await. The freeze disappeared, and we enabled analyser CA2008 ("do not create tasks without passing a TaskScheduler") as an error.

🔍 What the interviewer is checking

Checks knowledge of TPL internals that cause real bugs.

Green flags

  • Explains Current versus Default and the StartNew/ContinueWith defaults.
  • Knows Task.Run always uses the pool.
  • Mentions ConcurrentExclusiveSchedulerPair or UI schedulers.
  • Knows the StartNew plus async lambda trap.

Red flags

  • Thinks schedulers control async I/O.
  • Uses StartNew everywhere without arguments.

📚 Learn in depth

1. How Current is determined

public static TaskScheduler Current
{
    get
    {
        Task? current = Task.InternalCurrent;     // the task executing on this thread, if any
        if (current != null && (current.CreationOptions & TaskCreationOptions.HideScheduler) == 0)
            return current.ExecutingTaskScheduler ?? Default;
        return Default;
    }
}

It's a property of the currently executing task, not of the thread. Outside of any task (e.g. in an ASP.NET Core request after an await resumed on the pool), it's Default.

2. Task.Run vs StartNew options

Task.Run(f) Task.Factory.StartNew(f)
Scheduler Default Current
Creation options DenyChildAttach None
Async lambda Unwraps Task<Task> Returns Task<Task>
Cancellation Optional token Optional token
Use for Almost everything LongRunning, custom schedulers, special options

Correct explicit StartNew:

Task.Factory.StartNew(work, ct, TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning, TaskScheduler.Default);

3. Inlining

If a thread calls Wait() on a task that hasn't started and its scheduler allows inlining, the scheduler may run it right there (TryExecuteTaskInline). This avoids a thread hop but means the task runs on the waiting thread, possibly while that thread holds locks, which can surprise code that assumed a different thread.

4. ConcurrentExclusiveSchedulerPair with async code

var pair = new ConcurrentExclusiveSchedulerPair(TaskScheduler.Default, maxConcurrencyLevel: 4);
var exclusive = new TaskFactory(pair.ExclusiveScheduler);

await exclusive.StartNew(async () =>
{
    Step1();                 // exclusive
    await SaveAsync();       // releases the scheduler while the I/O is pending
    Step2();                 // resumes on the exclusive scheduler (Current captured), queued behind others
}).Unwrap();

Each synchronous segment is exclusive, but other work can run between segments, so it's not a true async lock. For mutual exclusion across awaits use SemaphoreSlim(1,1).

5. A minimal single-thread scheduler (STA example)

public sealed class SingleThreadScheduler : TaskScheduler, IDisposable
{
    private readonly BlockingCollection<Task> _queue = new();
    private readonly Thread _thread;

    public SingleThreadScheduler(ApartmentState apartment)
    {
        _thread = new Thread(() => { foreach (var t in _queue.GetConsumingEnumerable()) TryExecuteTask(t); })
        { IsBackground = true, Name = "sta-scheduler" };
        _thread.SetApartmentState(apartment);   // Windows only
        _thread.Start();
    }

    protected override void QueueTask(Task task) => _queue.Add(task);
    protected override bool TryExecuteTaskInline(Task task, bool prev) =>
        Thread.CurrentThread == _thread && TryExecuteTask(task);
    protected override IEnumerable<Task> GetScheduledTasks() => _queue.ToArray();
    public override int MaximumConcurrencyLevel => 1;
    public void Dispose() { _queue.CompleteAdding(); _thread.Join(); }
}

6. Analysers

Rule Catches
CA2008 StartNew/ContinueWith without an explicit TaskScheduler
VSTHRD105 (VS threading analyzers) Implicit use of TaskScheduler.Current

7. Version notes

Version Change
.NET Framework 4.0 TaskScheduler, ConcurrentExclusiveSchedulerPair arrived in 4.5
.NET 4.5 Task.Run, HideScheduler, DenyChildAttach
.NET Core+ Same API; no SynchronizationContext in ASP.NET Core means Current is usually Default
.NET 10 Unchanged

8. Try it yourself

  1. Run the code example and note which inner task runs on the exclusive scheduler.
  2. In a WPF app, use ContinueWith(..., FromCurrentSynchronizationContext()), call StartNew with a Thread.Sleep(5000) inside it, and watch the UI freeze.
  3. Add TaskCreationOptions.HideScheduler to the outer task and observe Current becomes Default inside it.
  4. Use the exclusive scheduler with an async lambda containing await Task.Delay(100); run two of them and log interleaving.
  5. Enable CA2008 and see which calls it flags in your codebase.
  6. Implement the single-thread scheduler and confirm all tasks run on the same ManagedThreadId.

💻 Code

var pair = new ConcurrentExclusiveSchedulerPair();
var factory = new TaskFactory(pair.ExclusiveScheduler);

await factory.StartNew(() =>
{
    Console.WriteLine($"Outer on exclusive: {TaskScheduler.Current == pair.ExclusiveScheduler}");

    // Pitfall: StartNew uses TaskScheduler.Current, so this is ALSO exclusive (serialised).
    Task inner = Task.Factory.StartNew(() =>
        Console.WriteLine($"Inner StartNew on exclusive: {TaskScheduler.Current == pair.ExclusiveScheduler}"));

    // Safe: explicit Default, or Task.Run.
    Task safe = Task.Factory.StartNew(
        () => Console.WriteLine($"Explicit default: {TaskScheduler.Current == TaskScheduler.Default}"),
        CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);

    Task run = Task.Run(() => Console.WriteLine($"Task.Run default: {TaskScheduler.Current == TaskScheduler.Default}"));
    return Task.WhenAll(safe, run); // inner runs after this outer task ends (exclusive)
}).Unwrap();

pair.Complete();
await pair.Completion;

⚠ Common mistakes

1. StartNew / ContinueWith without a scheduler in library code

What happens: a helper calls Task.Factory.StartNew(Work). Why it is wrong: it uses TaskScheduler.Current, which may be a UI or custom scheduler. Do instead: pass TaskScheduler.Default explicitly, or use Task.Run and await.

2. Expecting a scheduler to limit async operations

What happens: a concurrency-limited scheduler is used to cap concurrent HTTP calls. Why it is wrong: it limits only synchronous segments; I/O in flight is unbounded. Do instead: SemaphoreSlim or Parallel.ForEachAsync with MaxDegreeOfParallelism.

3. Treating ExclusiveScheduler as an async lock

What happens: async work is serialised by running it on the exclusive scheduler. Why it is wrong: other work can run between await segments. Do instead: SemaphoreSlim(1, 1) for mutual exclusion across awaits.

4. StartNew with an async lambda

What happens: await Task.Factory.StartNew(async () => ...) completes early. Why it is wrong: returns Task<Task>; you awaited only the outer task. Do instead: Task.Run(async () => ...) or .Unwrap().

5. Writing custom schedulers unnecessarily

What happens: a priority scheduler is written for ordinary server work. Why it is wrong: hard to get right (inlining, exceptions, observability); the pool is well tuned. Do instead: built-in options; custom schedulers only for real affinity needs.

6. Forgetting that Current flows into nested calls

What happens: a task started on a custom scheduler calls a library that uses ContinueWith. Why it is wrong: the library's continuations run on your scheduler, possibly serialised. Do instead: HideScheduler when hosting third-party code, or make libraries pass Default.

7. Blocking on a task that may be inlined

What happens: task.Wait() while holding a lock that the task also needs. Why it is wrong: inlining can run the task on the waiting thread; even without inlining, it can deadlock. Do instead: avoid blocking waits; await outside locks.

8. Using FromCurrentSynchronizationContext() from a non-UI thread

What happens: called where SynchronizationContext.Current is null. Why it is wrong: throws InvalidOperationException. Do instead: capture the scheduler on the UI thread first.

9. Assuming ASP.NET Core code is affected the same way

What happens: teams add TaskScheduler.Default everywhere in ASP.NET Core "for safety". Why it is wrong: harmless, but Current is almost always Default there; time is better spent elsewhere. Do instead: focus on library code and UI apps, enforce with CA2008.

10. Not making a custom scheduler observable

What happens: tasks pile up in a custom queue with no metrics. Why it is wrong: starvation inside your scheduler is invisible to ThreadPool metrics. Do instead: expose queue length and execution time metrics.

🏛 Senior insight: trade-offs

Schedulers matter more for what they break than for what they enable. My review rule is: StartNew and ContinueWith always get an explicit scheduler, and application code prefers Task.Run and await.

Need Tool
Run on the pool Task.Run
Serialise access to a resource ExclusiveScheduler or a Channel<T> with one reader
Limit CPU parallelism ParallelOptions.MaxDegreeOfParallelism
Limit async concurrency SemaphoreSlim, Parallel.ForEachAsync
Thread affinity (STA) Custom single-thread scheduler

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions ContinueWith defaults Why: Checks continuation options
    How to answer

    A continuation is code registered to run when a task completes. await creates continuations for you, with context capture, unwrapped results and normal exception flow. ContinueWith is the lower-level API: it runs regardless of outcome unless you pass options such as OnlyOnRanToCompletion or OnlyOnFaulted, uses TaskScheduler.Current by default, and returns Task<Task> for async lambdas (needs Unwrap). Continuations may run synchronously on the completing thread (ExecuteSynchronously, or by default for await when safe), so producers should use RunContinuationsAsynchronously to avoid inline re-entrancy.

  2. When: Candidate says Task.Run uses the pool Why: Checks Task fundamentals
    How to answer

    A Task is an object that represents an operation that will complete in the future, with a status, an optional exception and the ability to attach continuations. It is not a thread. Some tasks run code on a pool thread (Task.Run, called delegate tasks); many represent work with no thread at all, such as a pending network read, a timer (Task.Delay) or a TaskCompletionSource (promise tasks). You compose tasks with await, Task.WhenAll, Task.WhenAny and WaitAsync, and avoid blocking on them.

🛠 Real-world problems: how you handle them

Report load ran on the UI thread through TaskScheduler.Current
Situation
A [WPF desktop reporting app] froze for [10 s] when opening large reports.
Signals
The UI stopped repainting; a dump showed the UI thread inside `LoadReport`.
Investigation
A helper called `Task.Factory.StartNew(LoadReport)` from a continuation scheduled with `FromCurrentSynchronizationContext()`, so `Current` was the UI scheduler.
Resolution
Changed the helper to `Task.Run` and awaited the result, updating the UI after the await.
Prevention
Enabled the analyser rule CA2008 (do not create tasks without passing a TaskScheduler).

How to tell it (STAR)

Our [reporting app] froze on big reports. The dump showed the UI thread doing the load, even though the code used `StartNew`. I traced it to `TaskScheduler.Current` being the UI scheduler in that continuation. Switching to `Task.Run` fixed the freeze, and we turned on CA2008 to stop it recurring.

↩ Usually asked after

More in Tasks

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me