Processing a Sequence of Items in the Background
Outside of a server framework handling one request per thread for you, there are two everyday reasons to reach for more than one thread, and they are not the same reason. The first is to finish sooner, by doing several pieces of the work at the same time. The second is to finish later, elsewhere, by pushing the work off the current thread so the caller can get an answer immediately. Both improve how a program feels, but they call for completely different designs, and mixing them up is how a well-meaning optimisation turns into a server that stops answering requests. A single example runs through both: the world's simplest mail merge, which takes one template, substitutes each recipient's name into it, and sends the result. Sending a message is slow - real mail servers routinely take a second or more - and a web request usually has about thirty seconds before it times out, so a plain sequential loop starts failing at a few dozen recipients, which is a laughably small mail merge.
The core example measures every option on the same fake work: thirty-two messages, each taking sixty milliseconds to send. Sixty milliseconds is short enough to keep the demo quick and long enough to dominate the measurement, and thirty-two messages is comfortably more than the number of cores on a typical machine, which turns out to be the number that matters most:
What the example prints for each approach is not only the elapsed time but the peak number of messages in flight at the same time, which is the number that actually explains the timings and which barely varies between runs. It also prints the message and character totals, which are identical for every approach and are there to prove that all seven really did the same work:
The work itself comes in two versions, and the difference between them is the whole point of the second half of the table. SendMessage renders the message and then calls Thread.Sleep, standing in for a blocking network call: while it waits, it owns a thread and that thread can do nothing else. SendMessageAsync renders the same message and awaits Task.Delay, standing in for a genuinely asynchronous call: while it waits, it owns no thread at all. Both take the template and the recipient so that the rendering work is real, and both report to a meter so their overlap can be counted. Report prints one line of the table:
The meter is the measuring instrument rather than part of the lesson, and it is deliberately dull. It holds a small lock around a counter that goes up when a send starts and down when it finishes, remembering the highest value it ever saw. A lock rather than something cleverer is fine here because it is held for a few instructions while the surrounding work takes sixty milliseconds, so it cannot distort what it is measuring:
The first row is the baseline: an ordinary foreach, one message at a time, peak one in flight, and thirty-two sixty-millisecond sends add up to just over two seconds. This is the version that times out the web request. The second row gives each message its own Thread, and everything happens at once - peak thirty-two, finished in the time of a single send. It works, and for a desktop application it is often perfectly reasonable, but it has no upper bound: ten simultaneous users sending a hundred messages each is a thousand threads. Threads are cheaper than their reputation - a thousand of them cost only a fraction of a second to create and tear down - but ten thousand is noticeably slower and a hundred thousand is catastrophic, and unlike this example a real server also has actual work to do. Unbounded thread creation driven by user input is a denial-of-service vector wearing a helpful smile.
The third row hands the same blocking work to the thread pool with Task.Run, which is what the pool exists for: a bounded, self-tuning, reusable set of threads, with Task.WhenAll replacing the loop of joins. The peak tells the real story. The pool started with as many threads as the machine has cores, so only that many sends could be in flight at once, and the thirty-two messages went out in batches, taking roughly twice as long as the thread-per- message version. That is the thread pool working exactly as designed, and it is also the danger: blocking work occupies pool threads without using them, and the pool grows only slowly - a couple of extra threads per second - so a burst of blocking work starves everything else that shares the pool, including the web framework that is trying to accept the next request. If a program genuinely needs many pool threads at once and cannot wait for the pool to grow into it, ThreadPool.SetMinThreads tells the pool up front. The fourth row, Parallel.ForEach, is the same thread pool work with nicer syntax - the loop and the waiting collapse into one statement that reads almost like the original foreach - and it lands on the same numbers.
The fifth row is where the shape changes. It drops Task.Run entirely, calls the asynchronous send directly for every recipient, and awaits them all with Task.WhenAll. All thirty-two are in flight at once and the whole batch finishes in the time of one send, matching the thread-per-message row without creating a single thread. This is worth sitting with, because it is the central insight of asynchronous programming applied to a real problem: the thread pool was never the bottleneck, blocking was. Waiting for a mail server is not work, and code that is not working should not be holding a thread. It is also why wrapping an asynchronous call in Task.Run, which appears in a great deal of real code, usually accomplishes nothing except an extra hop through the pool.
The last two rows are a warning about Parallel.ForEachAsync, the asynchronous member of the Parallel family, which also offers Invoke, For and ForEach. Switching to it looks like a pure upgrade and is not. Its default MaxDegreeOfParallelism is the processor count, and while that is the right answer for work that burns CPU, this option does not limit threads, it limits items in flight. For work that spends its life waiting, that is precisely the wrong thing to cap: the sixth row is pinned to the core count and takes twice as long as it needs to, for no benefit whatsoever. The seventh row sets MaxDegreeOfParallelism explicitly and immediately matches the plain WhenAll version. The synchronous Parallel.ForEach has a related trap in the other direction: its documentation says it does not limit parallelism by default and that MaxDegreeOfParallelism can only lower it, while in practice it does apply a limit of its own - and whenever documented and observed behaviour disagree, neither one is safe to build on, so the defensive move is to pass an explicit value that is correct under either reading. Running the core example on an eighteen-core machine, in release configuration and without a debugger attached - measuring under a debugger can inflate results many times over - prints:
Everything so far has been about finishing sooner, and all of it still makes the caller wait for the last message. The second half of the problem asks a different question: does the caller need to wait at all? Nobody refreshing a web page cares whether the mail has physically left the building; they care that the request was accepted. If the answer can be sent immediately and the sending can happen afterwards, the performance problem disappears rather than being optimised - and, as a bonus, the messages can go out at a civilised pace instead of a thousand at once, which mail providers appreciate. The naive version of this is to move the whole loop onto one background Thread, which is one thread per request instead of one per message and is genuinely fine for a desktop application, where the only person who suffers from too many threads is the person who asked for them. On a server, with many users and sustained load, it is still unbounded, and the answer is the work queue pattern: a queue that anyone may add to, and a small fixed number of dedicated threads - one, here - that do nothing but take the next item and process it:
Building a correct work queue by hand involves a surprising number of details, and BlockingCollection supplies all of them. Only three of its members matter for this pattern. Add puts an item on the queue. CompleteAdding announces that nothing further is coming. GetConsumingEnumerable returns something a foreach loop can walk: it hands over items as they arrive, blocks when the queue is empty instead of spinning, and ends the loop cleanly once CompleteAdding has been called and the backlog is drained. The worker uses a dedicated Thread rather than the thread pool on purpose - this thread runs for the lifetime of the program, so borrowing a pool thread forever would take one out of circulation without ever giving it back, which is exactly the starvation problem described above:
Two details in that class are more important than they look. The first is that QueueMailMerge renders each message before putting it on the queue, so a queue entry is a finished message rather than a recipe for one; if processing one entry fails, exactly one message is affected instead of the whole batch. The second is that Sent is a plain field updated only by the worker thread and read only after Stop has joined it - the join is what makes that read safe, and without it this would be the very race the memory-model chapter warns about. The scenario output shows the payoff plainly: the request queues five messages and returns in effectively no time, while the actual sending goes on afterwards on a single named thread, in order:
One thing this queue cannot do is survive. BlockingCollection lives in memory, so a crash, a restart or a pulled power cable takes the backlog with it, which makes it suitable for best-effort work only. Work that genuinely must not be lost belongs in a persistent queue - a database table you write yourself, or a dedicated service such as RabbitMQ, AWS SQS or Azure Storage Queues. Persistent queues force a decision the in-memory version hides, because reading an item and removing it become separate steps. Remove it after processing and a crash in between means the item is processed twice, which is at-least-once delivery; remove it first and the same crash means it is never processed at all, which is at-most-once. The exactly-once delivery everyone actually wants is generally unachievable, and not because of the queue: if the connection to the mail server drops after the message was sent but before the confirmation arrives, nothing in the system can tell whether it went out. Since losing work is almost always worse than repeating it, at-least-once is the usual choice - which then obliges you to handle the poison message, the entry that crashes the processor every time and, with at-least-once delivery, is retried forever in an infinite crash-restart loop. The standard defence is to count failures per item and move repeat offenders to a dead letter queue where they can be inspected later instead of taking the system down.
The short version of the guidance is to decide first which problem you have. If the caller needs the result, process in parallel: for genuinely asynchronous work, call it for each item and combine with Task.WhenAll, adding no threads at all; for blocking work, use the thread pool through Task.Run or Parallel.ForEach, and keep in mind that you are renting threads that other parts of the process need back. Reach for a thread per item only for a bounded, modest number of items, and never let a user's input decide how many threads to create. If you use the Parallel class on anything larger than a small collection, set MaxDegreeOfParallelism deliberately and measure it rather than trusting the default - especially with ForEachAsync, whose default is calibrated for CPU-bound work. And if the caller does not need the result, do not optimise the wait, remove it: hand the items to a work queue, answer immediately, and let a dedicated thread work through the backlog at its own pace - reaching for a persistent queue as soon as losing that backlog would matter. Both examples are run one after the other from the program entry point: