TaskScheduler responsibilities, the default ThreadPool scheduler, Current versus Default pitfalls with StartNew and ContinueWith, FromCurrentSynchronizationContext and ConcurrentExclusiveSchedulerPair.
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.
"A
TaskSchedulerdecides where and when queued work runs: delegate tasks fromTask.Factory.StartNewand continuations registered withContinueWith.TaskScheduler.Defaultis the ThreadPool.TaskScheduler.Currentis ambient: it's the scheduler of the task that's currently executing, orDefaultif no task is executing. The trap is thatStartNewandContinueWithuseCurrentwhen 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.Runalways usesDefault, which is one reason it's the safer API. Also, when there's noSynchronizationContext,awaitcaptures a non-defaultCurrent, so code inside a custom-scheduled task resumes on that scheduler. My rule: application code usesTask.Runandawait; anyStartNew/ContinueWithgets an explicit scheduler."
| 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.
| 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 |
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.
| 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 | — |
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.
Checks knowledge of TPL internals that cause real bugs.
Green flags
Current versus Default and the StartNew/ContinueWith defaults.Task.Run always uses the pool.ConcurrentExclusiveSchedulerPair or UI schedulers.StartNew plus async lambda trap.Red flags
StartNew everywhere without arguments.Current is determinedpublic 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.
Task.Run vs StartNew optionsTask.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);
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.
ConcurrentExclusiveSchedulerPair with async codevar 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).
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(); }
}
| Rule | Catches |
|---|---|
| CA2008 | StartNew/ContinueWith without an explicit TaskScheduler |
| VSTHRD105 (VS threading analyzers) | Implicit use of TaskScheduler.Current |
| 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 |
ContinueWith(..., FromCurrentSynchronizationContext()), call StartNew with a Thread.Sleep(5000) inside it, and watch the UI freeze.TaskCreationOptions.HideScheduler to the outer task and observe Current becomes Default inside it.await Task.Delay(100); run two of them and log interleaving.ManagedThreadId.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;
StartNew / ContinueWith without a scheduler in library codeWhat 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.
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.
ExclusiveScheduler as an async lockWhat 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.
StartNew with an async lambdaWhat 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().
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.
Current flows into nested callsWhat 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.
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.
FromCurrentSynchronizationContext() from a non-UI threadWhat happens: called where SynchronizationContext.Current is null.
Why it is wrong: throws InvalidOperationException.
Do instead: capture the scheduler on the UI thread first.
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.
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.
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 |
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.
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.
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.
Last updated 31 Oct 2026.