High-Performance .NET: Async, Multithreading, and Parallel Programming Tasks in .Net Created: 27 Jul 2026 Updated: 28 Aug 2026

Implementing Complex Task Hierarchies with Child Tasks

In .NET a task is allowed to create other tasks. A task that is created inside the body of another running task is called a child task (or a nested task), and the task that created it is called the parent task. By nesting tasks you can build a task hierarchy: one top-level task that supervises several smaller units of work.

The single most important question about a child task is: does the parent care about it or not? The answer decides three things at once – when the parent is considered complete, where the child's exception ends up, and how much bookkeeping you have to do yourself.

  1. Attached child task – the parent does not complete until the child completes, and the child's exception is delivered to the parent.
  2. Detached child task – the parent completes immediately, ignores the child, and the child's exception is never seen by the parent.

This article explains the concept with two examples:

  1. Core concept example (Run()) – attached children, detached children, exception propagation from an attached child, and the effect of TaskCreationOptions.DenyChildAttach.
  2. Real-world example (RunNightlyBackupScenario()) – a nightly backup job where one parent task supervises one attached child task per folder.

Features and Design

Attached child tasks

An attached child is created with Task.Factory.StartNew() and the TaskCreationOptions.AttachedToParent option. The runtime then links the new task to the task that is currently executing on that thread. The consequences are:

  1. The parent enters the WaitingForChildrenToComplete state when its own delegate has finished but children are still running.
  2. The parent only reaches RanToCompletion when every attached child has finished.
  3. If an attached child throws, the parent becomes Faulted as well, even when the parent's own body never threw anything.
  4. A single Wait() (or await) on the parent covers the whole hierarchy.

The relationship is transitive: a child can itself be a parent of further attached children, so an arbitrarily deep tree can be waited on with one call at the root.

Detached child tasks

A detached child is an ordinary task that just happens to be created inside another task. Every task created with Task.Run() is detached. The consequences are:

  1. The parent finishes as soon as its own delegate returns, no matter what the child is doing.
  2. The child's exception stays inside the child task. If nobody observes that task, the failure is silent.
  3. You must keep a reference to the child yourself and wait for it explicitly, otherwise the program may exit before the child has even been scheduled.

When AttachedToParent is ignored

Requesting AttachedToParent is only a request; the parent can refuse it. The option is silently ignored in two situations:

  1. The parent was created with Task.Run(). Task.Run() is defined as StartNew plus DenyChildAttach, so it never accepts children.
  2. The parent was created with Task.Factory.StartNew() and the explicit TaskCreationOptions.DenyChildAttach option.

In both cases the child is created as a normal detached task and no error or warning is produced. This is a very common source of confusion: the code looks like it builds a hierarchy, but there is none.

Managing nested lifetimes

Attached children make lifetime management almost free, because completion of the root implies completion of the whole tree. Detached children have to be tracked by hand, for example by collecting them in a thread-safe collection and passing that collection to Task.WaitAll() or Task.WhenAll(). That technique works, but it has a subtle race: the snapshot of the collection can be taken before a deeply nested task has been added, which leaves that task out of the wait list. When the hierarchy is more than one level deep, attached children are the safer design.

Code Example 1: Core Concept

The first example builds the same tiny hierarchy four times, changing only the parent-child relationship, so the difference in behaviour is easy to compare.

namespace ConsoleApp;

/// <summary>
/// Demonstrates complex task hierarchies: a task that starts other tasks (child tasks) and
/// the two possible relationships between a parent and its children.
///
/// Two examples are provided:
/// 1) <see cref="Run"/> - the core concept: attached child tasks,
/// detached child tasks, how an exception of an
/// attached child reaches the parent, and how
/// TaskCreationOptions.DenyChildAttach turns an
/// attached child back into a detached one.
/// 2) <see cref="RunNightlyBackupScenario"/> - a small, realistic backup job where one parent
/// task supervises one attached child task per
/// folder.
/// </summary>
public static class ChildTaskExample
{
/// <summary>
/// Core concept example.
/// A child task is simply a task that is created inside the body of another task.
/// The important question is whether the child is ATTACHED to the parent (the parent
/// waits for it) or DETACHED from the parent (the parent ignores it).
/// </summary>
public static void Run()
{
Console.WriteLine("=== Child tasks: core concept ===");
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 1: attached child tasks.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 1: attached child tasks ---");

// Only Task.Factory.StartNew can create a parent that accepts attached children.
// Task.Run cannot be used here, because it always behaves like DenyChildAttach.
Task packOrder = Task.Factory.StartNew(() =>
{
// TaskCreationOptions.AttachedToParent ties this child to the task that is
// currently running. The parent stays in the WaitingForChildrenToComplete state
// until every attached child has finished.
// The 400 ms delay only simulates slow work; it is the LONGEST child, so it is
// easy to see that the parent really waits for it.
Task.Factory.StartNew(
() =>
{
Thread.Sleep(400);
Console.WriteLine("[attached child] Box 1 packed.");
},
TaskCreationOptions.AttachedToParent);

// A second attached child with a shorter delay (200 ms), so the two children
// finish in a predictable order: box 2 first, box 1 second.
Task.Factory.StartNew(
() =>
{
Thread.Sleep(200);
Console.WriteLine("[attached child] Box 2 packed.");
},
TaskCreationOptions.AttachedToParent);

// The parent body itself has almost no work, so this line is printed first.
Console.WriteLine("[parent] Parent body finished.");
});

// Wait() blocks the calling thread until the parent is completely done.
// Because the children are attached, one single Wait() covers the whole hierarchy.
packOrder.Wait();

// RanToCompletion is only reached after all attached children finished as well.
Console.WriteLine($"[parent] Status after Wait(): {packOrder.Status}");
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 2: detached child tasks.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 2: detached child tasks ---");

// The child reference is stored in a variable that lives OUTSIDE the parent, so the
// main thread can still reach the child after the parent has completed.
// Without such a reference a detached child cannot be waited on at all.
Task? printLabel = null;

Task shipOrder = Task.Run(() =>
{
// Task.Run always creates a detached child. The parent does not know about it
// and will not wait for it.
// The 400 ms delay makes the parent finish clearly before the child does.
printLabel = Task.Run(() =>
{
Thread.Sleep(400);
Console.WriteLine("[detached child] Shipping label printed.");
});

Console.WriteLine("[parent] Parent body finished.");
});

shipOrder.Wait();

// The parent is already complete even though its child is still running.
Console.WriteLine($"[parent] Status after Wait(): {shipOrder.Status}");
Console.WriteLine($"[parent] Child still running? {printLabel is { IsCompleted: false }}");

// A detached child must be waited on explicitly, otherwise the program could end
// before the child ever runs.
printLabel?.Wait();
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 3: an exception thrown by an attached child reaches the parent.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 3: error handling with attached children ---");

Task inspectOrder = Task.Factory.StartNew(() =>
{
Task.Factory.StartNew(
() => throw new InvalidOperationException("Box 3 arrived damaged."),
TaskCreationOptions.AttachedToParent);

Console.WriteLine("[parent] Parent body finished without any error.");
});

try
{
// The parent body itself never throws, but the failure of the attached child is
// promoted to the parent, so Wait() throws here.
inspectOrder.Wait();
}
catch (AggregateException error)
{
// A parent collects the errors of all its attached children, therefore the
// exception is always an AggregateException. Flatten() removes the nested
// AggregateException layers so the real messages can be printed directly.
foreach (Exception inner in error.Flatten().InnerExceptions)
{
Console.WriteLine($"[main] Caught from the child: {inner.Message}");
}
}

Console.WriteLine($"[parent] Status after the failure: {inspectOrder.Status}");
Console.WriteLine();

// ----------------------------------------------------------------------------
// Part 4: DenyChildAttach turns an attached child into a detached one.
// ----------------------------------------------------------------------------
Console.WriteLine("--- Part 4: DenyChildAttach ---");

Task? ignoredChild = null;

// TaskCreationOptions.DenyChildAttach tells the runtime that this task refuses every
// attachment request. Task.Run(...) is exactly the same as StartNew with this option,
// which is why children created inside a Task.Run parent are always detached.
Task archiveOrder = Task.Factory.StartNew(
() =>
{
// AttachedToParent is requested, but the parent denies it, so this child is
// silently created as a detached task.
ignoredChild = Task.Factory.StartNew(
() =>
{
Thread.Sleep(300);
Console.WriteLine("[ignored child] Finished after the parent had already completed.");
},
TaskCreationOptions.AttachedToParent);

Console.WriteLine("[parent] Parent body finished.");
},
TaskCreationOptions.DenyChildAttach);

archiveOrder.Wait();

// RanToCompletion again, this time without waiting for the child at all.
Console.WriteLine($"[parent] Status after Wait(): {archiveOrder.Status}");

// The rejected child has to be waited on by hand, just like any other detached task.
ignoredChild?.Wait();
Console.WriteLine();
}

// What the code above does: it builds the same small hierarchy four times. First with
// attached children, where a single Wait() on the parent covers all children. Then with
// detached children, where the parent completes first and every child must be waited on
// separately. Then it shows that an attached child's exception is delivered to the parent
// as an AggregateException. Finally it shows that DenyChildAttach (and therefore Task.Run)
// makes the runtime ignore the AttachedToParent request.
}

How It Works

  1. Part 1 – attached children. The parent is created with Task.Factory.StartNew(), and both children add TaskCreationOptions.AttachedToParent. The parent's own body finishes almost immediately, but packOrder.Wait() does not return until both boxes are packed. The status printed afterwards is RanToCompletion, which proves the children are included in the parent's completion.
  2. Part 2 – detached children. The parent is created with Task.Run(), so the child is detached. shipOrder.Wait() returns while the child is still sleeping, which is why Child still running? prints True. The child reference is kept in the outer variable printLabel, because without it the main thread would have no way to wait for the child. Note that the child is not returned from the lambda: returning a Task from Task.Run would unwrap it and accidentally make the parent wait.
  3. Part 3 – error propagation. The parent body itself never throws, yet inspectOrder.Wait() throws an AggregateException and the parent ends up Faulted. This is the centralised error handling that attached children give you: one try/catch around the root sees the failures of the whole tree. Flatten() is used because nested hierarchies produce nested AggregateException objects.
  4. Part 4 – DenyChildAttach. The child explicitly asks for AttachedToParent, but the parent was created with TaskCreationOptions.DenyChildAttach, so the request is silently dropped. The behaviour is identical to Part 2: the parent completes first and the child has to be waited on by hand.

Output

=== Child tasks: core concept ===

--- Part 1: attached child tasks ---
[parent] Parent body finished.
[attached child] Box 2 packed.
[attached child] Box 1 packed.
[parent] Status after Wait(): RanToCompletion

--- Part 2: detached child tasks ---
[parent] Parent body finished.
[parent] Status after Wait(): RanToCompletion
[parent] Child still running? True
[detached child] Shipping label printed.

--- Part 3: error handling with attached children ---
[parent] Parent body finished without any error.
[main] Caught from the child: Box 3 arrived damaged.
[parent] Status after the failure: Faulted

--- Part 4: DenyChildAttach ---
[parent] Parent body finished.
[parent] Status after Wait(): RanToCompletion
[ignored child] Finished after the parent had already completed.

Code Example 2: Real-World Scenario

The second example shows the practical value of attached children. A nightly backup job copies several folders in parallel, and the caller only has to wait for one task.

/// <summary>
/// Real-world example.
/// A nightly backup job: one parent task supervises one attached child task per folder.
/// The caller only has to wait for the parent, and can be sure that every folder has
/// been copied when that single Wait() returns.
/// </summary>
public static void RunNightlyBackupScenario()
{
Console.WriteLine("=== Real-world scenario: a nightly backup job ===");

// The folders to back up and how long each one takes, in milliseconds.
// Fixed durations keep the console output the same on every run.
(string Folder, int Milliseconds)[] folders =
[
("Documents", 500),
("Photos", 300),
("Invoices", 100)
];

// The parent task represents the whole backup job.
// StartNew is required because the job needs attached children.
Task backupJob = Task.Factory.StartNew(() =>
{
Console.WriteLine("Backup job started.");

foreach ((string folder, int milliseconds) in folders)
{
// Each folder is copied by its own attached child task, so the folders are
// backed up in parallel instead of one after another.
// The loop variables are captured safely, because each iteration of a foreach
// loop creates its own copy of them.
Task.Factory.StartNew(
() =>
{
Thread.Sleep(milliseconds);
Console.WriteLine($" Folder '{folder}' copied.");
},
TaskCreationOptions.AttachedToParent);
}

Console.WriteLine("All folder copies have been scheduled.");
});

// One single Wait() is enough: the job is only complete when every attached child
// has copied its folder. No list of tasks has to be kept anywhere.
backupJob.Wait();

Console.WriteLine("Backup job finished, every folder is safe.");
Console.WriteLine();
}

// What the code above does: it starts one parent task for the whole backup job and one
// attached child task per folder. The folders are copied in parallel, and because the
// children are attached, waiting for the parent alone guarantees that all of them are
// finished before the summary line is printed.

How It Works

  1. The array of tuples describes three folders with fixed, different durations, so the completion order is predictable and easy to explain.
  2. The parent task represents the backup job as a whole. Its body only schedules work: for each folder it starts one attached child task and then returns.
  3. Because the children are attached, the job is not finished when the loop ends. The parent moves into WaitingForChildrenToComplete and stays there until the last folder has been copied.
  4. backupJob.Wait() is a single, simple synchronisation point. There is no list of tasks to maintain, no Task.WhenAll() call, and no risk of forgetting one of the children.
  5. If one folder failed, the exception would surface at that same Wait() call inside an AggregateException, so the whole job can be reported as failed in one place.

Output

=== Real-world scenario: a nightly backup job ===
Backup job started.
All folder copies have been scheduled.
Folder 'Invoices' copied.
Folder 'Photos' copied.
Folder 'Documents' copied.
Backup job finished, every folder is safe.

When to Use

Use attached child tasks when:

  1. A logical unit of work is naturally split into several parallel sub-steps that must all finish before the unit is considered done.
  2. You want one synchronisation point and one try/catch for the whole hierarchy.
  3. The hierarchy is more than one level deep, where tracking tasks manually becomes error prone.

Use detached child tasks when:

  1. The nested work is genuinely independent, for example fire-and-forget logging or telemetry.
  2. You deliberately want the parent to report completion early, and you accept the responsibility of observing the child yourself.

Things to avoid:

  1. Expecting Task.Run() to accept attached children – it never does.
  2. Combining async lambdas with AttachedToParent. Attachment only covers the synchronous part of the delegate, so the parent will not wait for the awaited continuations.
  3. Leaving detached children unobserved: their exceptions disappear, and the process can exit before they run.

Modern alternatives are worth remembering. In async code, collecting the child tasks and awaiting Task.WhenAll() expresses the same idea more explicitly and works correctly with await. Attached child tasks remain the most convenient tool inside synchronous, CPU-bound task trees created with Task.Factory.StartNew().


Share this lesson: