High-Performance .NET: Async, Multithreading, and Parallel Programming Parallel Loops in .NET Created: 19 Jan 2026 Updated: 27 Jul 2026

Managing State with ParallelLoopState and Local State

Parallel.For() and Parallel.ForEach() run the iterations of a loop on several threads at the same time. That raises two practical questions that a normal for or foreach loop answers for free:

  1. How do I stop the loop early? The C# keywords break and continue do not exist inside a delegate, so the parallel loops offer a ParallelLoopState object instead.
  2. How do I accumulate a result without a lock? Updating one shared variable from every iteration is slow and error-prone, so the parallel loops offer a thread-local state parameter that each thread keeps privately and merges only once at the end.

Both features are provided through extra parameters on the body delegate, and both are optional. This article explains them with five examples, ordered from easy to harder:

  1. Step 1: thread-local state in Parallel.For() using localInit, body and localFinally.
  2. Step 2: thread-local state in Parallel.ForEach(), where the private state is a List<string> instead of a number.
  3. Step 3: ParallelLoopState.Break() and the LowestBreakIteration property.
  4. Step 4: ParallelLoopState.Stop() and the ShouldExitCurrentIteration property.
  5. Step 5: skipping single iterations with plain C# logic, without touching ParallelLoopState at all.

Features and Design

The ParallelLoopState Parameter

When the body delegate declares a ParallelLoopState parameter, the loop passes an object that gives fine-grained control over the execution:

  1. Stop() – halts the loop globally. Iterations that have not started yet are never started, and iterations that are already running are asked to give up. Stop() is not index-aware: nothing is guaranteed to run after it is called.
  2. Break() – stops iterations whose index is higher than the current one, while guaranteeing that every iteration with a lower index still runs. Use it when the order of the items matters, for example "process everything up to the first bad record".
  3. LowestBreakIteration – a long? holding the smallest index on which Break() was called, or null if Break() was never called. An iteration can read it to decide whether it is still needed.
  4. ShouldExitCurrentIterationtrue when the current iteration no longer needs to do its work, because Break() or Stop() was called (or the loop was cancelled or an exception was thrown).
  5. IsStopped and IsExceptional – report whether Stop() was called and whether another iteration threw an exception.

Important rule: Break() and Stop() cannot be mixed in the same loop. Calling one after the other throws an InvalidOperationException.

After the loop returns, the ParallelLoopResult structure reports what happened: IsCompleted is false when the loop ended early, and LowestBreakIteration repeats the break index (it stays null after Stop()).

The Local State Parameter

Both parallel loops can maintain a custom state per thread. Three delegates are needed:

  1. An initialization function (localInit) – called once per thread before that thread processes its first iteration. It returns the starting value of the private state.
  2. A main body delegate (body) – called for every iteration. It receives the private state and must return the updated state, which is then handed to the next iteration that runs on the same thread.
  3. A finalization action (localFinally) – called once per thread after that thread has finished all of its iterations. It receives the final private value, which is where the merge into a shared result belongs.

The signatures look like this:

Parallel.For<TLocal>(
int fromInclusive,
int toExclusive,
Func<TLocal> localInit,
Func<int, ParallelLoopState, TLocal, TLocal> body,
Action<TLocal> localFinally)

Parallel.ForEach<TSource, TLocal>(
IEnumerable<TSource> source,
Func<TLocal> localInit,
Func<TSource, ParallelLoopState, TLocal, TLocal> body,
Action<TLocal> localFinally)

Because the private state is visible to exactly one thread, the body needs no lock at all. Only localFinally touches shared data, and it runs once per thread instead of once per iteration. For a loop over a million numbers, that turns a million synchronized updates into a handful.

In Parallel.ForEach() the state parameter can also be used without an initializer and finalizer. In that case its type is long and it simply contains the index of the current element in the enumeration.

Break, Stop and Skip Compared

TechniqueEffect on the loopGuaranteeTypical use
Break()Ends the loop, but only for higher indicesEvery index below the break index is processedOrdered data: "stop at the first bad record"
Stop()Ends the loop globallyNone; work may be abandoned anywhereSearching: one match is enough
return in the bodyEnds only the current iterationAll other iterations still runFiltering: ignore some items

Step 1: Code Example – Thread-Local State in Parallel.For

public static void RunLocalStateInParallelFor()
{
Console.WriteLine("=== Step 1: Thread-local state in Parallel.For ===");

// The shared result. Only the localFinally delegate writes to it, and it uses
// Interlocked because several threads may finish at the same moment.
long grandTotal = 0;

// How many threads actually took part. It is printed at the end so you can see
// that localInit and localFinally run once per thread, not once per iteration.
int threadPortions = 0;

// Parallel.For<TLocal>(fromInclusive, toExclusive, localInit, body, localFinally)
// fromInclusive: the first index (1 here, because the numbers 1..1000 are summed).
// toExclusive: the first index that is NOT processed (1001, so 1000 is included).
// localInit: runs once per thread and returns the starting value of that
// thread's private state. 0L is used because a sum starts at zero.
// body: runs once per iteration, receives the private state and must
// RETURN the updated state for the next iteration on that thread.
// localFinally: runs once per thread after its last iteration and receives the
// final private value. This is where the merge into the shared
// result happens.
ParallelLoopResult result = Parallel.For(
1,
1001,
localInit: () => 0L,
body: (number, loopState, subtotal) =>
{
// No locking is needed here: "subtotal" belongs to this thread only.
subtotal += number;

// Forgetting this return would throw away the whole subtotal, so the
// body of a stateful loop must always return the new state.
return subtotal;
},
localFinally: subtotal =>
{
// Interlocked.Add adds the value in one uninterruptible step and returns
// the new total, so two threads can never lose an update.
Interlocked.Add(ref grandTotal, subtotal);
Interlocked.Increment(ref threadPortions);

Console.WriteLine($"Thread {Environment.CurrentManagedThreadId} finished with subtotal {subtotal}");
});

// The total is always the same (1 + 2 + ... + 1000 = 500500), even though the
// subtotals above are different on every run. That is the point of local state:
// an unpredictable split, but a deterministic final result.
Console.WriteLine($"Loop completed all iterations: {result.IsCompleted}");
Console.WriteLine($"Number of thread portions: {threadPortions}");
Console.WriteLine($"Grand total of 1..1000: {grandTotal}");
Console.WriteLine();
}

How It Works

  1. The loop sums the numbers 1 to 1000. fromInclusive is 1 and toExclusive is 1001, because the upper bound is never executed.
  2. localInit: () => 0L gives every participating thread a fresh long subtotal that starts at zero. This delegate runs once per thread, not once per number.
  3. The body adds the current number to the thread's own subtotal and returns it. The return value is essential: it becomes the subtotal argument of the next iteration on that same thread. A body that forgets to return the state silently loses all the accumulated work.
  4. No lock, no Interlocked, and no lock statement appear in the body, because subtotal is private to one thread.
  5. localFinally runs once per thread and merges the finished subtotal into grandTotal with Interlocked.Add, which performs the addition as one uninterruptible operation.
  6. The subtotals differ wildly from run to run, and some of them are tiny, because .NET decides dynamically how many iterations each thread receives. The final total is always exactly 500500.

Sample Output

=== Step 1: Thread-local state in Parallel.For ===
Thread 4 finished with subtotal 1050
Thread 16 finished with subtotal 10760
Thread 11 finished with subtotal 10642
Thread 7 finished with subtotal 118365
Thread 10 finished with subtotal 22662
Thread 9 finished with subtotal 701
Thread 8 finished with subtotal 306798
Thread 1 finished with subtotal 27716
Thread 6 finished with subtotal 801
Thread 14 finished with subtotal 901
Thread 13 finished with subtotal 2
Thread 12 finished with subtotal 102
Loop completed all iterations: True
Number of thread portions: 12
Grand total of 1..1000: 500500

The number of thread portions and the individual subtotals change on every run and on every machine. Only the grand total is deterministic, which is exactly what a correct aggregation must guarantee.

Step 2: Code Example – Thread-Local State in Parallel.ForEach

public static void RunLocalStateInParallelForEach()
{
Console.WriteLine("=== Step 2: Thread-local state in Parallel.ForEach ===");

// Twelve short words keep the output small enough to read in one screen.
List<string> words = new List<string>
{
"apple", "bridge", "cloud", "desk", "engine", "forest",
"garden", "harbor", "island", "jacket", "kitchen", "ladder"
};

// The finished per-thread lists are collected here. ConcurrentBag<T> is used
// because localFinally can run on several threads at the same time.
ConcurrentBag<List<string>> finishedBatches = new ConcurrentBag<List<string>>();

// Parallel.ForEach<TSource, TLocal>(source, localInit, body, localFinally)
// The shape is the same as in Step 1, but the body receives the ITEM instead of
// an index. TLocal is List<string> here instead of long.
Parallel.ForEach(
words,
localInit: () => new List<string>(),
body: (word, loopState, batch) =>
{
// A plain List<string> is safe here because no other thread can see it.
batch.Add(word.ToUpperInvariant());
return batch;
},
localFinally: batch =>
{
finishedBatches.Add(batch);
Console.WriteLine($"Thread {Environment.CurrentManagedThreadId} handled {batch.Count} word(s): {string.Join(", ", batch)}");
});

// Every word is processed exactly once, so the batches always add up to 12,
// even though the split between threads changes on every run.
int processedWords = finishedBatches.Sum(batch => batch.Count);

Console.WriteLine($"Batches produced: {finishedBatches.Count}");
Console.WriteLine($"Words processed in total: {processedWords} of {words.Count}");
Console.WriteLine();
}

How It Works

  1. The thread-local state does not have to be a number. Here TLocal is List<string>, so every thread builds its own small batch of results.
  2. localInit: () => new List<string>() creates one fresh list per thread. Because that list is never shared while the thread is working, a plain List<string> is perfectly safe inside the body – no ConcurrentBag<T> and no lock are needed there.
  3. The body converts the word to upper case, adds it to the private batch, and returns the same list instance so the next iteration on that thread can keep filling it.
  4. localFinally publishes the finished batch to the shared ConcurrentBag<List<string>>. This is the moment when the data becomes visible to other threads, so a thread-safe collection is required here.
  5. The number of batches equals the number of participating threads, so it is unpredictable. The sum of all batch sizes is always 12, because Parallel.ForEach() processes each item exactly once.

Sample Output

=== Step 2: Thread-local state in Parallel.ForEach ===
Thread 8 handled 1 word(s): BRIDGE
Thread 10 handled 1 word(s): GARDEN
Thread 4 handled 1 word(s): FOREST
Thread 1 handled 1 word(s): APPLE
Thread 14 handled 1 word(s): ISLAND
Thread 13 handled 1 word(s): CLOUD
Thread 12 handled 1 word(s): HARBOR
Thread 9 handled 1 word(s): JACKET
Thread 6 handled 1 word(s): DESK
Thread 11 handled 1 word(s): KITCHEN
Thread 7 handled 1 word(s): LADDER
Thread 16 handled 1 word(s): ENGINE
Batches produced: 12
Words processed in total: 12 of 12

With only twelve very fast items, .NET often gives each thread a single word, so twelve batches of one word each appear. With more items or slower work, fewer threads handle larger batches, and the per-thread advantage of local state becomes much more visible.

Step 3: Code Example – Break() and LowestBreakIteration

public static void RunBreak()
{
Console.WriteLine("=== Step 3: Break() stops the iterations after the current index ===");

// Twenty log lines that must be processed from the oldest to the newest.
// Line 8 is the first corrupted one; everything after it is useless.
string[] logLines = Enumerable.Range(0, 20)
.Select(number => number == 8 ? "CORRUPTED" : $"line-{number}")
.ToArray();

// The indices that were really processed. A thread-safe collection is required
// because several iterations add to it at the same time.
ConcurrentBag<int> processedIndices = new ConcurrentBag<int>();

ParallelLoopResult result = Parallel.For(0, logLines.Length, (index, loopState) =>
{
// A short delay makes the example behave like real work and gives the other
// iterations time to notice the break.
Thread.Sleep(10);

if (logLines[index] == "CORRUPTED")
{
Console.WriteLine($"Index {index} is corrupted. Calling Break().");

// Break() lets the loop finish everything before this index.
// Alternative: Stop() would abandon those earlier indices as well
// (see Step 4). Break() and Stop() cannot be mixed in the same loop.
loopState.Break();
return;
}

// LowestBreakIteration holds the smallest index where Break() was called,
// or null when Break() has not been called yet. Checking it lets an
// iteration give up when a smaller index already broke the loop.
if (loopState.LowestBreakIteration.HasValue && index > loopState.LowestBreakIteration.Value)
{
return;
}

processedIndices.Add(index);
});

// The only promise Break() makes is that every index BELOW the break index is
// processed. This check is therefore true on every run.
bool allLowerIndicesProcessed = Enumerable.Range(0, 8).All(index => processedIndices.Contains(index));

// IsCompleted is false because the loop ended early, and LowestBreakIteration is
// always 8. Some indices above 8 may still appear in the list below: those
// iterations had already started before the break became visible to them.
Console.WriteLine($"Loop completed all iterations: {result.IsCompleted}");
Console.WriteLine($"Lowest index where Break() was called: {result.LowestBreakIteration}");
Console.WriteLine($"All indices below the break were processed: {allLowerIndicesProcessed}");
Console.WriteLine($"Processed indices: {string.Join(", ", processedIndices.OrderBy(index => index))}");
Console.WriteLine();
}

How It Works

  1. Twenty imaginary log lines are processed. Line 8 is marked CORRUPTED, which means that every line after it is meaningless and should not be processed.
  2. The delegate now has the shape Action<int, ParallelLoopState>. The second argument is supplied by the loop itself; nothing has to be created by hand.
  3. When the corrupted line is found, loopState.Break() is called. This tells the loop: "iterations with a higher index are no longer required, but everything below me must still run".
  4. LowestBreakIteration is checked by the other iterations. Once it has a value, any iteration with a bigger index returns immediately instead of doing useless work.
  5. Break() gives one guarantee only: all indices below the break index are processed. That is why allLowerIndicesProcessed is True on every run, while a few indices above 8 may still appear – those iterations had already started before the break became visible to them.
  6. result.IsCompleted is False, and result.LowestBreakIteration reports 8, the index that broke the loop.

Sample Output

=== Step 3: Break() stops the iterations after the current index ===
Index 8 is corrupted. Calling Break().
Loop completed all iterations: False
Lowest index where Break() was called: 8
All indices below the break were processed: True
Processed indices: 0, 1, 2, 3, 4, 5, 6, 7

On a machine with many cores, the list of processed indices sometimes contains extra values such as 16, 18. That is normal and does not break the contract: Break() promises that lower indices will run, not that higher indices will never run.

Step 4: Code Example – Stop() and ShouldExitCurrentIteration

public static void RunStop()
{
Console.WriteLine("=== Step 4: Stop() ends the whole loop immediately ===");

// A simple search: one badge number must be found in a list of employees.
List<string> badgeNumbers = Enumerable.Range(1, 20)
.Select(number => $"BADGE-{number:D2}")
.ToList();

const string wantedBadge = "BADGE-07";

// Only two badges are inspected at the same time. Without this limit all twenty
// items would start together and the effect of Stop() would be invisible.
ParallelOptions options = new ParallelOptions
{
MaxDegreeOfParallelism = 2
};

int inspectedCount = 0;
int exitedEarlyCount = 0;

ParallelLoopResult result = Parallel.ForEach(badgeNumbers, options, (badge, loopState) =>
{
// ShouldExitCurrentIteration is true as soon as Break() or Stop() was called
// by any iteration (and also when the loop was cancelled). Checking it FIRST
// avoids starting work that will be thrown away anyway. It only helps for
// iterations that had ALREADY started; iterations that never started are
// simply never called by the loop.
if (loopState.ShouldExitCurrentIteration)
{
Interlocked.Increment(ref exitedEarlyCount);
return;
}

Thread.Sleep(10);
Interlocked.Increment(ref inspectedCount);

if (badge == wantedBadge)
{
Console.WriteLine($"Found {badge}. Calling Stop().");

// Stop() asks every other iteration to give up, no matter what index or
// position it has. Iterations that are already running still finish their
// current statement, which is why IsStopped is checked in long bodies.
loopState.Stop();
}
});

// IsCompleted is false, and LowestBreakIteration is null because Stop() is not
// index-aware. The exact counts change from run to run, but the loop always ends
// before all twenty badges have been inspected.
Console.WriteLine($"Loop completed all items: {result.IsCompleted}");
Console.WriteLine($"Lowest break iteration (null after Stop): {result.LowestBreakIteration?.ToString() ?? "null"}");
Console.WriteLine($"Badges inspected: {inspectedCount} of {badgeNumbers.Count}");
Console.WriteLine($"Iterations that started and exited early: {exitedEarlyCount}");
Console.WriteLine($"Iterations that never started: {badgeNumbers.Count - inspectedCount - exitedEarlyCount}");
Console.WriteLine();
}

How It Works

  1. The scenario is a search: twenty badge numbers are scanned and only one of them, BADGE-07, is wanted. Once it is found, no further work has any value.
  2. MaxDegreeOfParallelism = 2 keeps only two badges "inside" the loop body at a time. Without that limit all twenty short iterations would start almost simultaneously and Stop() would have nothing left to prevent.
  3. ShouldExitCurrentIteration is checked at the very beginning of the body. It becomes true after any iteration calls Break() or Stop(), so a long-running body can abandon its work instead of finishing it uselessly.
  4. When the wanted badge is found, loopState.Stop() ends the loop globally. Unlike Break(), it makes no promise about lower indices: anything still queued is simply never started.
  5. The final counters separate the two ways an iteration can be avoided: it either started and returned early (ShouldExitCurrentIteration) or it was never invoked at all because the loop had already ended.
  6. result.LowestBreakIteration stays null, which is the clearest sign that Stop() is not index-aware.

Sample Output

=== Step 4: Stop() ends the whole loop immediately ===
Found BADGE-07. Calling Stop().
Loop completed all items: False
Lowest break iteration (null after Stop): null
Badges inspected: 15 of 20
Iterations that started and exited early: 0
Iterations that never started: 5

The exact numbers change from run to run, because they depend on how far the second worker had progressed when the match was found. What never changes is that IsCompleted is False and that fewer than twenty badges were inspected.

Step 5: Code Example – Skipping Single Iterations

public static void RunSkippingIterations()
{
Console.WriteLine("=== Step 5: Skipping single iterations with your own logic ===");

// The numbers 0..9 are read, but only the odd ones are counted.
int oddSum = 0;
int skipped = 0;

Parallel.For(0, 10, index =>
{
// "return" leaves this iteration only. The loop itself is not affected, so
// all other indices still run. This is the difference from Break()/Stop().
if (index % 2 == 0)
{
Interlocked.Increment(ref skipped);
return;
}

Interlocked.Add(ref oddSum, index);
});

// Deterministic result: 1 + 3 + 5 + 7 + 9 = 25, and 5 even indices are skipped.
Console.WriteLine($"Even indices skipped: {skipped}");
Console.WriteLine($"Sum of the odd indices: {oddSum}");
Console.WriteLine();
}

How It Works

  1. Not every "skip" needs ParallelLoopState. Custom logic plus the return keyword is enough when only some items must be ignored.
  2. return inside the body ends the current iteration only. It is the parallel equivalent of continue in a normal loop, not of break.
  3. Five even indices return immediately, and the five odd indices are added to oddSum.
  4. Interlocked is used because oddSum and skipped are shared by all iterations. (With the technique from Step 1, these counters could also be kept as thread-local state and merged at the end.)
  5. The result is fully deterministic: 1 + 3 + 5 + 7 + 9 = 25, with exactly 5 skipped indices.

Sample Output

=== Step 5: Skipping single iterations with your own logic ===
Even indices skipped: 5
Sum of the odd indices: 25

When to Use

Use the local state parameter when:

  1. The loop produces an aggregate: a sum, a count, a maximum, or a collection of results.
  2. The loop body would otherwise lock or use Interlocked on every single iteration.
  3. Each thread needs an expensive per-thread resource (for example a buffer or a random number generator) that must be created once and released at the end.

Use Break() when the source is ordered and everything before a certain point still matters – for example parsing a file until the first invalid line.

Use Stop() when a single result ends the job, such as searching for one match, or when an error makes all remaining work pointless.

Use a plain return when individual items only need to be filtered out and the loop should keep going.

Things to avoid:

  1. Forgetting to return the state from the body delegate – the accumulated value is silently lost.
  2. Merging into shared data inside the body instead of inside localFinally – that throws away the entire benefit of local state.
  3. Calling both Break() and Stop() in the same loop – it throws an InvalidOperationException.
  4. Expecting Break() or Stop() to abort iterations instantly – running iterations are only asked to exit, so long bodies should check ShouldExitCurrentIteration themselves.
  5. Assuming that thread-local state means one state per iteration – it is one state per thread, reused across many iterations.


Share this lesson: