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:
- How do I stop the loop early? The C# keywords
breakandcontinuedo not exist inside a delegate, so the parallel loops offer aParallelLoopStateobject instead. - 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:
- Step 1: thread-local state in
Parallel.For()usinglocalInit,bodyandlocalFinally. - Step 2: thread-local state in
Parallel.ForEach(), where the private state is aList<string>instead of a number. - Step 3:
ParallelLoopState.Break()and theLowestBreakIterationproperty. - Step 4:
ParallelLoopState.Stop()and theShouldExitCurrentIterationproperty. - Step 5: skipping single iterations with plain C# logic, without touching
ParallelLoopStateat 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:
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.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".LowestBreakIteration– along?holding the smallest index on whichBreak()was called, ornullifBreak()was never called. An iteration can read it to decide whether it is still needed.ShouldExitCurrentIteration–truewhen the current iteration no longer needs to do its work, becauseBreak()orStop()was called (or the loop was cancelled or an exception was thrown).IsStoppedandIsExceptional– report whetherStop()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:
- An initialization function (
localInit) – called once per thread before that thread processes its first iteration. It returns the starting value of the private state. - 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. - 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:
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
| Technique | Effect on the loop | Guarantee | Typical use |
|---|---|---|---|
Break() | Ends the loop, but only for higher indices | Every index below the break index is processed | Ordered data: "stop at the first bad record" |
Stop() | Ends the loop globally | None; work may be abandoned anywhere | Searching: one match is enough |
return in the body | Ends only the current iteration | All other iterations still run | Filtering: ignore some items |
Step 1: Code Example – Thread-Local State in Parallel.For
How It Works
- The loop sums the numbers 1 to 1000.
fromInclusiveis1andtoExclusiveis1001, because the upper bound is never executed. localInit: () => 0Lgives every participating thread a freshlongsubtotal that starts at zero. This delegate runs once per thread, not once per number.- The body adds the current number to the thread's own subtotal and returns it. The return value is essential: it becomes the
subtotalargument of the next iteration on that same thread. A body that forgets to return the state silently loses all the accumulated work. - No lock, no
Interlocked, and nolockstatement appear in the body, becausesubtotalis private to one thread. localFinallyruns once per thread and merges the finished subtotal intograndTotalwithInterlocked.Add, which performs the addition as one uninterruptible operation.- 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
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
How It Works
- The thread-local state does not have to be a number. Here
TLocalisList<string>, so every thread builds its own small batch of results. localInit: () => new List<string>()creates one fresh list per thread. Because that list is never shared while the thread is working, a plainList<string>is perfectly safe inside the body – noConcurrentBag<T>and no lock are needed there.- 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.
localFinallypublishes the finished batch to the sharedConcurrentBag<List<string>>. This is the moment when the data becomes visible to other threads, so a thread-safe collection is required here.- 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
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
How It Works
- 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. - 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. - 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". LowestBreakIterationis checked by the other iterations. Once it has a value, any iteration with a bigger index returns immediately instead of doing useless work.Break()gives one guarantee only: all indices below the break index are processed. That is whyallLowerIndicesProcessedisTrueon every run, while a few indices above 8 may still appear – those iterations had already started before the break became visible to them.result.IsCompletedisFalse, andresult.LowestBreakIterationreports8, the index that broke the loop.
Sample Output
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
How It Works
- 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. MaxDegreeOfParallelism = 2keeps only two badges "inside" the loop body at a time. Without that limit all twenty short iterations would start almost simultaneously andStop()would have nothing left to prevent.ShouldExitCurrentIterationis checked at the very beginning of the body. It becomestrueafter any iteration callsBreak()orStop(), so a long-running body can abandon its work instead of finishing it uselessly.- When the wanted badge is found,
loopState.Stop()ends the loop globally. UnlikeBreak(), it makes no promise about lower indices: anything still queued is simply never started. - 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. result.LowestBreakIterationstaysnull, which is the clearest sign thatStop()is not index-aware.
Sample Output
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
How It Works
- Not every "skip" needs
ParallelLoopState. Custom logic plus thereturnkeyword is enough when only some items must be ignored. returninside the body ends the current iteration only. It is the parallel equivalent ofcontinuein a normal loop, not ofbreak.- Five even indices return immediately, and the five odd indices are added to
oddSum. Interlockedis used becauseoddSumandskippedare 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.)- The result is fully deterministic: 1 + 3 + 5 + 7 + 9 = 25, with exactly 5 skipped indices.
Sample Output
When to Use
Use the local state parameter when:
- The loop produces an aggregate: a sum, a count, a maximum, or a collection of results.
- The loop body would otherwise lock or use
Interlockedon every single iteration. - 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:
- Forgetting to return the state from the body delegate – the accumulated value is silently lost.
- Merging into shared data inside the body instead of inside
localFinally– that throws away the entire benefit of local state. - Calling both
Break()andStop()in the same loop – it throws anInvalidOperationException. - Expecting
Break()orStop()to abort iterations instantly – running iterations are only asked to exit, so long bodies should checkShouldExitCurrentIterationthemselves. - Assuming that thread-local state means one state per iteration – it is one state per thread, reused across many iterations.