Fan-out and fan-in with Parallel.For
Rendering a page per data row and merging the pages into one file is one I/O phase, then a CPU-bound fan-out and fan-in. Loading the template and data is I/OI/O-bound or CPU-bound?Async helps most when the work is waiting, and CPU-heavy work needs Task.Run.Opens in a new tab, done once. Rendering each row is CPU-bound. Merging is sequential, because the order matters and there is one output writer.
var streams = new List<Stream>();foreach (var row in rows){ streams.Add(renderer.Render(row)); // one core used}MergeAll(streams);The plain loop is correct but uses one core.
Two traps
A rendering engine is often not thread-safe. Concurrent renders on one instance throw or corrupt output, so each worker needs its own instance (see race conditionsRace conditionsThe result depends on the order in which threads happen to run.Opens in a new tab). A ConcurrentBag returns results in completion order, which shuffles the merged file, so each result keeps its row index.
The pattern
byte[] templateBytes = await File.ReadAllBytesAsync(templatePath); // I/O, oncevar rows = await LoadRowsAsync(); // I/O, once var rendered = new Stream[rows.Count]; // one slot per row keeps the order var options = new ParallelOptions{ MaxDegreeOfParallelism = Environment.ProcessorCount // don't oversubscribe the cores}; Parallel.For(0, rows.Count, options, i =>{ var renderer = new Renderer(); // a new instance per iteration: no shared mutable state renderer.LoadTemplate(templateBytes); rendered[i] = renderer.RenderPages(rows[i]); // CPU-bound work}); // Sequential merge, in the original row orderusing var merged = MergeInOrder(rendered);await merged.SaveAsync(outputPath); foreach (var s in rendered) s.Dispose(); // clean up the per-row streamsAn array with a slot per row keeps the order, and each iteration writes only its own slot. MaxDegreeOfParallelism at the core count avoids oversubscription. A renderer per iteration avoids sharing, and Parallel.For has a per-worker overload if creating one is expensive. The merge stays single-threaded.
Where it matters
Parallel.For blocks the calling thread until all iterations finish, which matters inside a request: heavy work inside an HTTP requestHeavy work inside an HTTP requestCores are shared by every request, so one request that takes them all slows the others.Opens in a new tab.