C# Parallel.For result ordering is a data-contract problem, not a promise that the scheduler will execute iterations in numerical order. For a fixed input collection with one output per item, allocate the output first and give each iteration exclusive ownership of its corresponding slot. Read the completed collection only after the synchronous loop finishes successfully. This separates three questions that are often confused: whether a collection is safe to mutate, whether each value still belongs to the correct input, and whether the whole batch succeeded.
This guide targets independent, local computations over synthetic data. Examples include projecting sample item identifiers into calculated values or preparing deterministic test fixtures. It does not cover remote requests, asynchronous workflow scheduling, or database updates. The runnable example uses only integers and in-memory arrays, so its outcome is inspectable without connecting to another system.
Why a lock around List.Add does not establish input order
A lock can protect a shared collection from overlapping writes, but it cannot make workers acquire that lock in input order. If downstream code pairs values by position, establish the identity-to-slot relationship before starting the work rather than trying to infer it from completion order.
The Microsoft List documentation states that instance members are not guaranteed to be thread safe and that applications must provide synchronization when reads and writes overlap. Concurrent calls to Add on the same ordinary list therefore create a correctness risk beyond cosmetic disorder. This article deliberately does not use an unsafe race as a demonstration. A race can appear harmless in one run, making it a poor deterministic acceptance test.
Adding a lock fixes a different layer of the problem. The resulting collection reflects the order in which workers obtained the lock, which is not necessarily the order of the original input. A concurrent collection also needs an explicit ordering contract: queue order describes enqueue operations, while a bag is not an ordered result format. Neither automatically reconstructs the identity of the original input item.
During review, start with the consumer. Does it pair this array with another array by index? Does it interpret the final element as the largest identifier? Does it check only the number of results and assume no identity was lost? Those questions determine the required contract before any container is selected. Synchronization protects a data structure; it does not supply missing business meaning.
Give each iteration an exclusive output slot
An indexed output array preserves the input snapshot's positions when every iteration owns a different slot and does not mutate shared child objects. The complete batch should become visible to its consumer only after the synchronous loop has returned successfully and its completion condition has been checked.
The basic pattern is straightforward: stabilize the input, allocate results with the same length, and compute results[i] inside Parallel.For. Execution order may vary without affecting the final mapping. Iteration zero can finish after iteration ten because neither needs to append to a shared growing collection. The important property is ownership of the destination, not the order in which a worker runs.
This pattern has boundaries. A variable number of outputs per input needs another representation, such as a separate local collection for each input followed by a defined flattening step. An unknown input count also requires a different allocation strategy. Choosing an array is justified here because the cardinality is fixed, not because arrays are universally preferable to concurrent collections.
Different slots are not a blanket guarantee of safe code. If every slot refers to the same mutable object, workers can still reach and modify the same state through those references. The example avoids that ambiguity by using integers and value tuples. For richer output, construct an independent result object within each iteration and review every mutable reference that leaves the loop body.
Allocation is not completion. A partially filled array already has its final length, and default values can look like legitimate data. Keep the candidate local, check successful completion, and only then hand it to another component. If Break or Stop is introduced later, revisit that contract instead of silently treating partial completion as a normal full result.
Preserve identity separately from business sorting
Indexed writes preserve the order of the input snapshot, not an implicit order by identifier, timestamp, or calculated value. When the consumer requires a different order, apply an explicit sorting rule after computation and retain the input identity alongside each result so that the mapping remains testable.
The fixture intentionally uses identifiers 30, 10, and 20 in that order. Each result stores both its Id and its squared Value. Indexed writes preserve 30, 10, 20. A separate OrderBy operation creates a new array ordered 10, 20, 30. The final element of the first array therefore has a different meaning from the final element of the second. That difference is expected and is asserted directly.
Calling a result merely “ordered” leaves too much undefined. State whether it preserves input order or follows a named sort key. If equal keys are possible, define the secondary key or the treatment of ties as well. Returning only numeric values while discarding their identifiers makes it much harder to distinguish a correct calculation from a value attached to the wrong input.
This distinction also creates a useful division of responsibility. The input provider defines the snapshot and its positions. The computation creates exactly one result for each position. The consumer requests any additional sorting or aggregation. A change to presentation order should not require changing how parallel workers are scheduled, and the tests should identify which contract failed.
Keep failures observable and choose parallelism deliberately
A failed synchronous parallel loop must remain a failed batch rather than releasing a partially populated array as complete output. Begin with one deliberate level of parallelism, then measure alternatives when justified; nested loops are a potential source of overhead, not a universal language-level prohibition.
The Parallel.For API documents AggregateException for exceptions raised by iterations. The failure fixture throws a synthetic exception at a fixed index, flattens the aggregate at the caller, and verifies that the expected cause is present. It also checks that the output handoff variable remains null. It does not assert exactly how many other iterations completed, because that detail depends on scheduling.
Microsoft's parallelism guidance specifically warns about over-parallelization in nested loops, while also describing circumstances that can justify parallelizing an inner loop. Examples include substantial inner work and sufficient processor resources. Start with a sequential baseline and a single parallel layer, then evaluate a more complicated structure against a real workload. Do not turn a useful default into an unsupported absolute rule.
The example sets MaxDegreeOfParallelism to 2 to keep this small fixture bounded. That is not a recommendation for every machine, nor a guarantee that two workers will run simultaneously. The tests intentionally avoid depending on observed concurrency or elapsed time. Squaring a few integers may be too cheap to benefit from parallel execution at all.
The source note contains project-specific timing observations. They are not reproduced here, generalized into a speed claim, or presented as Jerry's personal achievements. This article verifies ownership, identity, and failure handling. A performance claim would need separate measurements with a documented workload and execution conditions.
Local verification: three reproducible checks
The verification separates slot integrity, input identity, and failure containment into deterministic checks. Use the pinned SDK and runtime in a new test folder, run only the synthetic fixture, and treat any failed assertion or nonzero command exit as a failed check rather than accepting plausible-looking output.
Prerequisites are .NET SDK 10.0.400 and Microsoft.NETCore.App 10.0.11, with dotnet available in the terminal. The commands below work in Bash or PowerShell. Check the installed version lists first. If either required version is missing, stop rather than silently running a different toolchain. The project has no additional NuGet packages and does not require a network service.
- Run the preparation commands in a location reserved for disposable test projects. If ParallelOrderLab already exists, choose a different unused name instead of overwriting it. Create global.json using the exact content below before creating the console project.
dotnet --list-sdks
dotnet --list-runtimes
mkdir ParallelOrderLab
cd ParallelOrderLab
Save this as global.json:
{"sdk":{"version":"10.0.400","rollForward":"disable"}}
dotnet --version
dotnet new console --framework net10.0 --no-restore
- Replace the new ParallelOrderLab.csproj with the following project definition and replace Program.cs with the complete fixture below. These edits belong only in the newly created test project. The SDK selection and runtime configuration both disable automatic version substitution.
Exe
net10.0
enable
enable
10.0.11
Disable
using System.Runtime.InteropServices;
static void Require(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
Console.WriteLine($"Runtime: {RuntimeInformation.FrameworkDescription}");
var mode = args.Length == 1 ? args[0] : "all";
Require(new[] { "all", "slots", "identity", "failure" }.Contains(mode), "Unknown check");
var options = new ParallelOptions { MaxDegreeOfParallelism = 2 };
var checks = 0;
if (mode is "all" or "slots")
{
foreach (var count in new[] { 0, 1, 64 })
{
var results = new int[count];
var visits = new int[count];
var loop = Parallel.For(0, count, options, i =>
{
results[i] = checked((i + 1) * (i + 1));
Interlocked.Increment(ref visits[i]);
});
Require(loop.IsCompleted, "Loop not complete");
Require(results.Length == count, "Wrong length");
for (var i = 0; i
{
var id = input[i];
results[i] = (id, checked(id * id));
});
Require(loop.IsCompleted, "Loop not complete");
Require(results.Select(r => r.Id).SequenceEqual(input), "Input order lost");
Require(results.All(r => r.Value == r.Id * r.Id), "Identity/value mismatch");
var byId = results.OrderBy(r => r.Id).ToArray();
Require(byId.Select(r => r.Id).SequenceEqual(new[] { 10, 20, 30 }), "Sort failed");
Require(results[^1].Id == 20 && byId[^1].Id == 30, "Last has wrong meaning");
Console.WriteLine("PASS identity: input order differs from explicit ID order");
checks++;
}
if (mode is "all" or "failure")
{
int[]? published = null;
var observed = false;
try
{
var candidate = new int[16];
var loop = Parallel.For(0, candidate.Length, options, i =>
{
if (i == 5) throw new InvalidOperationException("Synthetic fixture failure");
candidate[i] = i + 1;
});
Require(loop.IsCompleted, "Incomplete candidate");
published = candidate;
}
catch (AggregateException ex)
{
observed = ex.Flatten().InnerExceptions.Any(e =>
e is InvalidOperationException && e.Message == "Synthetic fixture failure");
}
Require(observed, "Expected loop exception was not observed");
Require(published is null, "Partial result escaped");
Console.WriteLine("PASS failure: aggregate observed; partial result withheld");
checks++;
}
Console.WriteLine($"PASS total checks: {checks}");
- Run the following commands. The slots mode covers empty, single-item, and fixed-batch inputs. The identity mode compares preserved input order with explicit ID ordering. The failure mode checks the synthetic exception and the blocked handoff. A nonzero exit, missing expected PASS line, or version mismatch is a failure.
dotnet run --configuration Release -- slots
dotnet run --configuration Release -- identity
dotnet run --configuration Release -- failure
dotnet run --configuration Release -- all
The scheduler actually executed all using SDK 10.0.400 and .NET 10.0.11 and returned exit code 0. The following output was captured from that execution, not synthesized as an expected transcript. The three individual modes were also executed separately in the same environment.
Runtime: .NET 10.0.11
PASS slots: empty, single, and 64-item inputs
PASS identity: input order differs from explicit ID order
PASS failure: aggregate observed; partial result withheld
PASS total checks: 3
For slots, every nonempty slot must have exactly one recorded visit and the expected value, while the empty loop must complete normally. For identity, both the identifier/value relationship and the two meanings of the final element must hold. For failure, the expected synthetic cause must be observed and the candidate array must never be handed off.
The fixture uses a visit counter because checking only the final array length proves little: that length was chosen before any work ran. It avoids asserting the order of console messages from worker bodies and does not print from within the parallel loop. The useful observations are deterministic postconditions after the loop, not incidental details of a particular schedule.
A passing run demonstrates this fixture under the pinned environment. It does not prove that unrelated parallel code is safe, that every possible failure path is covered, or that the design improves throughput. If the application later introduces shared mutable children, early loop termination, or a different output cardinality, extend the tests before reusing the conclusion.
Evidence, boundaries, and the review checklist
The central conclusion is to assign output ownership using stable input positions and require successful batch completion before handoff. Official documentation establishes collection and loop behavior, while the local fixture demonstrates the proposed data contract; neither source of evidence establishes a general performance advantage.
The underlying development note was read completely. Its reusable observations about indexed collection and nested parallelism informed the topic, but project identifiers, environment details, and timing figures were not carried into the public article. Strong statements in the note were narrowed where necessary: container safety is distinct from ordering, separate array slots do not make shared child objects safe, and nested parallelism is not automatically incorrect.
Before adopting the pattern, review five questions. Is the input snapshot stable? Does each output slot have exactly one writer? Is identity preserved alongside the value? Can any failed or incomplete batch escape as complete output? Is the required ordering rule written explicitly? These checks turn “the loop seemed to finish” into an acceptance contract that another developer can inspect.
Keep this scope separate from asynchronous throttling of shared resources. That is a different problem requiring its own coordination design. Here the goal is a small, synchronous, fixed-cardinality computation whose result mapping remains correct regardless of which iteration runs first. Once that contract is reliable, performance experiments can be evaluated without confusing speed with correctness.
Official sources successfully opened and checked: