Chapter 8: Chapter 8. Designing concurrent code
The introduction shifts from building thread-safe data structures to designing useful concurrent code, outlining the decisions and tradeoffs the chapter will examine.
After this chapter you will be able to
- Thread and data design decisions. Describe the concurrent-design decisions highlighted here: how many threads to use, what each thread does, what data they share, how access is synchronized, and which threads must wait for others. Explain that these choices affect code clarity and that shared-data layout also affects performance.
- Generalist and specialist threads. Explain the choice between threads that take on whatever work is needed and threads dedicated to particular kinds of work, including why neither arrangement is always faster.
- Upfront data partitioning. Explain how to assign elements to worker threads before processing, for example in consecutive chunks, so each thread works independently on its assigned elements until it finishes.
- Final result reduction. Describe when independently processed work needs a final reduction, why a simple std::for_each needs none, and how a reduction such as accumulate can itself be parallelized rather than left entirely serial.
- Recursive Quicksort work division. Explain why Quicksort cannot be divided into fixed data chunks before processing and why its recursive calls can run concurrently after partitioning.
- Bounded pending chunk scheduling. Describe how the example puts recursively generated chunks on a thread-safe stack and uses a hardware-concurrency-based limit on worker threads instead of launching a thread for every recursive call.
- Shared stack performance tradeoff. Explain why limiting threads does not guarantee good performance: managing workers adds complexity, and their shared work stack can become a contention point even if it is lock-free.
- Specialist thread responsibilities. Distinguish dividing work by task type from giving threads different chunks of data: specialist threads perform different responsibilities, even if they sometimes work on the same data.
- Responsiveness and thread communication. Explain how separate threads can simplify continuously running tasks and keep event handling responsive, while completion updates and cancellation still require communication and suitable synchronization.
- Pipeline stages and output cadence. Trace how a pipeline assigns successive operations to separate threads and passes data items between stages through queues. Explain why it suits repeated processing of independent items, including inputs discovered as work proceeds, and why it can give steadier output after an initial delay rather than finishing a whole batch sooner.
- Performance across concurrency goals. Explain why performance matters both when concurrency is intended to speed up code on multiple processors and when threads are used to separate concerns.
- Processor capacity and oversubscription. Explain why useful parallelism depends on the number of threads the hardware can run concurrently: too few runnable threads leave capacity unused, while too many cause switching overhead called oversubscription.
- Hardware concurrency scaling limits. Explain how std::thread::hardware_concurrency() can guide thread-count scaling, and why using its result independently in multiple calls can oversubscribe the machine. Describe how std::async() or a carefully used thread pool can coordinate work, while other applications may still affect available capacity.
- Shared counter cache contention. Explain why concurrent reads of shared data are usually cheap, but repeated updates to a shared atomic counter can cause cache ping-pong. Describe why even a relaxed atomic increment needs the latest counter value.
- Mutex contention across processors. Describe how repeated mutex acquisition and release can transfer the mutex’s data between processor caches, adding costs beyond waiting for the lock. Distinguish mutex waiting from an atomic-operation processor stall, and explain why even readers can contend on a reader-writer mutex.
- False sharing mechanism. Explain why threads repeatedly updating separate, nearby values can still cause cache ping-pong: caches transfer ownership of whole cache lines, and the values may occupy the same line.
- Thread-aware data layout. Describe how to arrange data to reduce false sharing: keep data used by one thread close together, but place data updated by different threads far enough apart to occupy separate cache lines. State that C++17's std::hardware_destructive_interference_size in <new> provides a spacing threshold for the compilation target.
- Per-thread data locality. Explain how placing data used by one thread close together can improve performance: nearby data is more likely to share a cache line, so fewer lines need loading and less cache space is wasted on irrelevant data.
- Locality during thread switching. Explain why poor per-thread data locality matters more when more runnable threads share cores: switching threads can require reloading their cache lines, and moving a thread to another core can require transferring its data’s cache lines.
- Useful threads versus oversubscription. Distinguish extra threads that keep processors busy while others wait from oversubscription, where more threads are ready to run than processors can execute. Explain that heavy task switching adds overhead and can worsen cache-proximity problems.
- Oversubscription sources and remedies. Describe how unbounded thread creation during data division or a CPU-bound division by task type can cause oversubscription. Explain when to limit worker threads, when a different work division may be needed, and why such a change should be justified by measured performance.
- Data layout performance levers. Explain that contention, false sharing, and data proximity are key performance considerations, and that changing data layout or which elements each thread processes can improve performance.
- Row and column partitioning. Explain how assigning adjacent rows or columns of a row-major result matrix to threads affects their writes and the risk of false sharing.
- Rectangular block tradeoff. Describe why rectangular output blocks can reduce reads from the two source matrices compared with whole-row assignments, while retaining the false-sharing risk of column-based division.
- Access pattern performance choices. Explain how to choose and assess a division of large-array work by considering both reads and writes, adjusting block size to the problem and processor count, and profiling options on the target architecture.
- Thread data placement principles. Explain how to distribute data so each thread works on nearby data, needs as little data as practical, and accesses data sufficiently far from other threads’ data to avoid false sharing.
- Mutex and data cache interference. Explain the performance tradeoff of placing a mutex near the data it protects, including how a competing thread’s lock attempt can invalidate the owner’s cached data.
- Scalability with added cores. Explain that concurrent code is scalable when adding processing cores improves performance, through faster execution or greater throughput; ideal scaling is linear.
- Exception safety as correctness. Explain why exception safety is a correctness concern in concurrent code: an exception can leave broken invariants, cause race conditions, or terminate the application unexpectedly. Distinguish this from scalability, which code can lack and still work.
- Exception hazards in parallel accumulation. Explain why an exception in a worker thread cannot propagate directly to the caller, and why an exception on the calling thread becomes dangerous after threads have been spawned but before they are joined. Distinguish those cases from operations performed before spawning or after joining.
- Worker results and exceptions through futures. Describe how std::packaged_task and std::future let each worker return its block result or capture an exception, which future::get rethrows on the calling thread. Explain how the caller combines the future results with its initial value and the final block.
- Joining threads on every exit. Explain why capturing worker exceptions in futures does not by itself protect against a calling-thread exception while threads remain joinable. Describe joining every remaining thread on exit, preferably through a guard whose destructor handles both normal and exceptional exits.
- Async futures and recursive cleanup. Describe the std::async version’s recursive division into chunks and explain its exception safety: a failed asynchronous task is rethrown by get(), while destruction of its future during an exception waits for the task to finish.
- Two measures of scalability. Distinguish reducing the time for an action from increasing the amount of data processed in a given time as processors are added. Explain why a design should identify which measure matters before choosing how to divide work between threads.
- Amdahl’s serial-work limit. Explain Amdahl’s law as a simplified estimate that splits work into serial and parallel sections: more processors can shorten the parallel part, but not the serial part. Describe how the serial fraction limits speedup, including the section’s one-third example.
- Practical limits and design levers. Explain why Amdahl’s simplified estimate does not fully predict real programs: tasks are not infinitely divisible, and threads may wait for other threads or I/O rather than remain CPU-bound. Describe how reducing serial work or waits, having other useful work ready during waits, and supplying enough data to keep parallel work going can improve performance.
- Blocked threads and spare capacity. Explain why a thread that waits for I/O, a mutex, another thread, or a timer can leave a processor idle, and how additional threads can use that capacity.
- Thread count tradeoffs. Describe the virus scanner’s file-search and scanning pipeline as an example of adding workers around I/O waits, and explain why the best thread count must be measured rather than assumed.
- Dedicated GUI and task threads. Describe how a dedicated GUI thread can continue processing events while another thread performs a long-running task, with the threads communicating when an event affects that task.
- Task-dependent design examples. Explain that the importance of earlier concurrency-design issues depends on the task, and that three familiar Standard Library functions will be implemented in parallel to show how those issues apply.
- Parallel for_each execution order. Distinguish sequential std::for_each, which visits elements in range order, from a parallel version, which may process elements concurrently and gives no guarantee about their processing order.
- Upfront division into blocks. Explain how the thread-based implementation divides a known range into contiguous blocks before processing, using std::thread::hardware_concurrency() to choose a thread count under the stated assumption that it is the only parallel task running. Describe why independent element operations permit this division and why contiguous blocks help avoid false sharing.
- Worker exception propagation. Explain why the thread-based version uses std::packaged_task and std::future<void> even though workers return no result: calling get() on the futures passes worker exceptions to the caller. Contrast this with std::async, whose future's get() provides the same exception propagation.
- Recursive async range division. Describe the std::async version's recursive division: split the range in half, run one half asynchronously and the other directly, and use ordinary std::for_each when a range is too small to divide profitably. Explain why it divides during execution rather than fixing thread blocks up front: the number of threads the library will use is unknown.
- Early termination across workers. Explain why parallel find must tell other workers to stop when a match is found, and describe how a shared atomic done flag provides that signal.
- Promise based result handling. Describe how the thread-based implementation uses a promise for a match or exception, and why it joins all workers before inspecting the result when no match is possible.
- Recursive async search. Trace how the std::async version divides a large range, searches one half directly and the other asynchronously, and uses a shared done flag and future to finish safely.
- Running total dependencies and tradeoffs. Explain why a running total cannot be computed by independently summing chunks, and compare chunk-based forward propagation with increasingly distant pairwise updates. Describe why the chunk approach suits fewer processors while pairwise updates offer more parallelism at the cost of more total work.
- Chunk totals and future handoffs. Trace how the chunk-based implementation computes local partial sums, passes each chunk’s final cumulative value through a promise and future, and updates that final element before the remaining elements. Explain how exceptions travel along the same chain to the final chunk.
- Reusable barrier and departing threads. Describe how the barrier holds threads until the required count arrives, then resets its spaces and advances its generation for the next round. Explain why departing threads must reduce both the future participant count and the current round’s remaining spaces.
- Pairwise rounds and alternating buffers. Trace the pairwise implementation as each element adds the value one, then two, then four and increasingly more positions earlier. Explain why threads synchronize after each round and alternate reads and writes between the original range and a buffer, copying final values back when needed.
What it builds on
- Summary (chapter 6) · Earlier work on lock-based concurrent data structures.
- Summary (chapter 7) · Earlier work on lock-free concurrent data structures.
- Protecting shared data with mutexes (chapter 3) · Protecting shared data through synchronization.
- Waiting for an event or other condition (chapter 4) · Waiting for another thread’s operation or condition.
- Choosing the number of threads at runtime (chapter 2) · Provides the earlier accumulate example whose results are combined after worker threads finish.
- Returning values from background tasks (chapter 4) · Introduces asynchronous tasks as an alternative way to run work.
- Making (std::)promises (chapter 4) · Introduces promises for delivering results between threads.
- The pros and cons of lock-free data structures (chapter 7) · Discusses tradeoffs of lock-free data structures.
Section by section
Introduction
The introduction shifts from building thread-safe data structures to designing useful concurrent code, outlining the decisions and tradeoffs the chapter will examine.
- Broader concurrent design scope: Explain that thread-safe data structures are only building blocks: designing larger concurrent programs also requires dividing work and considering performance, exception safety, and scalability. Describe the chapter’s parallel Standard Library algorithms as examples of principles that apply throughout an application.
Why it matters: It puts the chapter’s algorithm examples in the context of designing whole concurrent applications. - Thread and data design decisions: Describe the concurrent-design decisions highlighted here: how many threads to use, what each thread does, what data they share, how access is synchronized, and which threads must wait for others. Explain that these choices affect code clarity and that shared-data layout also affects performance.
Why it matters: These decisions connect the division of work to correct coordination and efficient execution.
Key terms: concurrent code, data structures, parallel algorithms, exception safety, scalability, how many threads, shared data, synchronize accesses, wait, clarity
8.1 Techniques for dividing work between threads
This section uses a house-building analogy to introduce choices about how many threads to use and whether to assign them general or specialized work.
- Generalist and specialist threads: Explain the choice between threads that take on whatever work is needed and threads dedicated to particular kinds of work, including why neither arrangement is always faster.
Why it matters: How work is assigned to threads affects both performance and the clarity of the code. - Thread counts and workload: Describe why the number and mix of threads should reflect the work available, and how handling multiple jobs at once can keep a specialist busy who would otherwise be idle.
Why it matters: A thread assignment that suits one workload may be inefficient when the amount or mix of work changes.
Key terms: generalist threads, specialist threads, number of threads
8.1.1 Dividing data between threads before processing begins
This section explains how to parallelize element-wise work by dividing data among threads before processing, then combining results when necessary, and why some algorithms need a different approach.
- Upfront data partitioning: Explain how to assign elements to worker threads before processing, for example in consecutive chunks, so each thread works independently on its assigned elements until it finishes.
Why it matters: This is a simple starting pattern for parallelizing operations over a data set. - Final result reduction: Describe when independently processed work needs a final reduction, why a simple std::for_each needs none, and how a reduction such as accumulate can itself be parallelized rather than left entirely serial.
Why it matters: Combining results can become a serial step even when the main work runs in parallel. - Limits of upfront division: Explain why dividing data before processing does not suit algorithms whose divisions become clear only during processing, such as Quicksort.
Why it matters: Recognizing this limit tells you when to look for another way to divide work.
Key terms: std::for_each, consecutive chunks, worker threads, reduction, accumulate, final serial step, Quicksort, recursive algorithms
8.1.2 Dividing data recursively
The section uses parallel Quicksort to show how recursively generated work can be shared among a bounded number of threads, along with the costs and limits of that design.
- Recursive Quicksort work division: Explain why Quicksort cannot be divided into fixed data chunks before processing and why its recursive calls can run concurrently after partitioning.
Why it matters: The partitioning step reveals independent work that can be assigned to different threads. - Bounded pending chunk scheduling: Describe how the example puts recursively generated chunks on a thread-safe stack and uses a hardware-concurrency-based limit on worker threads instead of launching a thread for every recursive call.
Why it matters: Bounding threads retains recursive parallelism without creating an unbounded number of threads. - Chunk completion and worker shutdown: Trace how a sorting thread obtains a posted chunk’s result, helps process other chunks while waiting, and shuts down worker threads after sorting finishes.
Why it matters: The example must both deliver sorted results and ensure its background workers finish cleanly. - Shared stack performance tradeoff: Explain why limiting threads does not guarantee good performance: managing workers adds complexity, and their shared work stack can become a contention point even if it is lock-free.
Why it matters: Removing unbounded thread creation can expose a different bottleneck in the shared work queue. - Limits of data based division: Distinguish cases with a fixed data set, where data can be divided before or during processing, from dynamically generated or externally arriving data, where dividing work by task type may make more sense.
Why it matters: The source of the work affects which division strategy is appropriate.
Key terms: Quicksort, pivot, partition, recursive calls, std::async(), std::thread::hardware_concurrency(), thread-safe stack, pending chunks, thread pool, try_sort_chunk
8.1.3 Dividing work by task type
This section explains how to divide concurrent work by task type, including specialist threads and pipelines, and when those designs help or hinder responsiveness and performance.
- Specialist thread responsibilities: Distinguish dividing work by task type from giving threads different chunks of data: specialist threads perform different responsibilities, even if they sometimes work on the same data.
Why it matters: Choosing responsibilities rather than just data chunks can make each thread’s purpose clearer. - Responsiveness and thread communication: Explain how separate threads can simplify continuously running tasks and keep event handling responsive, while completion updates and cancellation still require communication and suitable synchronization.
Why it matters: Separate threads avoid a manually interleaved main loop, but they do not eliminate coordination. - Poorly separated thread concerns: Describe how extensive shared data or frequent waiting between specialist threads can signal the wrong division of responsibilities, and how combining closely coupled threads or extracting a shared responsibility may help.
Why it matters: Too much communication can undermine the simplicity sought by separating concerns. - Pipeline stages and output cadence: Trace how a pipeline assigns successive operations to separate threads and passes data items between stages through queues. Explain why it suits repeated processing of independent items, including inputs discovered as work proceeds, and why it can give steadier output after an initial delay rather than finishing a whole batch sooner.
Why it matters: A pipeline trades startup delay for a regular flow of completed items, which can matter more than batch completion time.
Key terms: dividing work by task type, separating concerns, single responsibility, main loop, user interface thread, background task, synchronization, shared data, waiting, communication between threads
8.2 Factors affecting the performance of concurrent code
This introduction frames multithreaded performance as a design concern and previews processor count and work assignment as factors to examine.
- Performance across concurrency goals: Explain why performance matters both when concurrency is intended to speed up code on multiple processors and when threads are used to separate concerns.
Why it matters: Using threads should not make an application slower on a newer multicore machine. - Processor count and work assignment: Describe the two factors this introduction highlights: the number of processors in the target system and which data elements each thread processes. Explain that changing only the latter can dramatically change performance.
Why it matters: Small changes in how work is assigned can have large performance effects.
Key terms: concurrency, multiple processors, separate concerns, performance, processors, data elements, threads
8.2.1 How many processors?
This section explains how processor availability and hardware layout affect thread-count decisions and the performance of concurrent code.
- Processor capacity and oversubscription: Explain why useful parallelism depends on the number of threads the hardware can run concurrently: too few runnable threads leave capacity unused, while too many cause switching overhead called oversubscription.
Why it matters: Matching runnable work to available processing capacity is a starting point for performance decisions. - Hardware layout and measurement: Describe why the same nominal capacity can perform differently on a single multicore processor, multiple single-core processors, or multiple multicore processors, and why testing on the target hardware matters when possible.
Why it matters: A design that performs well on a development machine may behave differently on a customer's hardware. - Hardware concurrency scaling limits: Explain how std::thread::hardware_concurrency() can guide thread-count scaling, and why using its result independently in multiple calls can oversubscribe the machine. Describe how std::async() or a carefully used thread pool can coordinate work, while other applications may still affect available capacity.
Why it matters: A hardware-based thread count is useful only if the application accounts for competing work. - Problem size and algorithm choice: Explain why the fastest algorithm can depend on problem size relative to the number of processing units: with enough processors, an algorithm doing more total operations may finish sooner because each processor does little work.
Why it matters: Total operation count alone does not determine elapsed time on a highly parallel machine.
Key terms: processing units, threads ready to run, oversubscription, number (and structure) of processors, target hardware, std::thread::hardware_concurrency(), std::async(), thread pools, size of the problem, number of processing units
8.2.2 Data contention and cache ping-pong
This section explains how writes to shared data make cache lines move between processors, creating contention that can erase the performance gains of additional threads.
- Shared counter cache contention: Explain why concurrent reads of shared data are usually cheap, but repeated updates to a shared atomic counter can cause cache ping-pong. Describe why even a relaxed atomic increment needs the latest counter value.
Why it matters: The cost of moving shared data between caches can outweigh the work threads do between updates. - Mutex contention across processors: Describe how repeated mutex acquisition and release can transfer the mutex’s data between processor caches, adding costs beyond waiting for the lock. Distinguish mutex waiting from an atomic-operation processor stall, and explain why even readers can contend on a reader-writer mutex.
Why it matters: A mutex can become a performance bottleneck even when the protected data is rarely changed. - Reducing competition for memory: State the section’s general design direction: reduce the chances that threads compete to update the same memory location. Qualify that advice by noting that false sharing can still cause cache ping-pong even when each location is accessed by only one thread.
Why it matters: Avoiding an obviously shared variable is helpful, but it is not a complete cure for cache ping-pong.
Key terms: cache ping-pong, high contention, low contention, std::memory_order_relaxed, fetch_add, mutex, single-writer, multiple-reader mutex, processor stall, false sharing
8.2.3 False sharing
The section explains how cache-line granularity can make independent threads interfere with one another and how data layout can prevent it.
- False sharing mechanism: Explain why threads repeatedly updating separate, nearby values can still cause cache ping-pong: caches transfer ownership of whole cache lines, and the values may occupy the same line.
Why it matters: Separate variables do not guarantee independent cache access. - Thread-aware data layout: Describe how to arrange data to reduce false sharing: keep data used by one thread close together, but place data updated by different threads far enough apart to occupy separate cache lines. State that C++17's std::hardware_destructive_interference_size in <new> provides a spacing threshold for the compilation target.
Why it matters: Data placement can remove cache-line contention without changing which thread owns each value.
Key terms: cache line, cache ping-pong, false sharing, std::hardware_destructive_interference_size, <new>
8.2.4 How close is your data?
This section explains how keeping each thread’s data close together can reduce cache misses, especially when threads share cores or move between them.
- Per-thread data locality: Explain how placing data used by one thread close together can improve performance: nearby data is more likely to share a cache line, so fewer lines need loading and less cache space is wasted on irrelevant data.
Why it matters: Data layout affects how much useful work a processor gets from each cache line. - Locality during thread switching: Explain why poor per-thread data locality matters more when more runnable threads share cores: switching threads can require reloading their cache lines, and moving a thread to another core can require transferring its data’s cache lines.
Why it matters: The cost of scattered data can grow when threads compete for caches and processor time. - Constructive interference size: Describe std::hardware_constructive_interference_size as a C++17 constant in <new> giving the maximum number of consecutive bytes guaranteed to share a cache line when suitably aligned, and explain that fitting data needed together within that size can potentially reduce cache misses.
Why it matters: The constant provides a portable size to consider when grouping data that is used together.
Key terms: data proximity, cache line, cache miss, false sharing, task switching, oversubscription, cache lines, time slice, std::hardware_constructive_interference_size, <new>
8.2.5 Oversubscription and excessive task switching
The section explains when extra threads help, when too many runnable threads hurt performance, and how to respond to oversubscription.
- Useful threads versus oversubscription: Distinguish extra threads that keep processors busy while others wait from oversubscription, where more threads are ready to run than processors can execute. Explain that heavy task switching adds overhead and can worsen cache-proximity problems.
Why it matters: The number of threads alone does not tell you whether they are helping or hurting performance. - Oversubscription sources and remedies: Describe how unbounded thread creation during data division or a CPU-bound division by task type can cause oversubscription. Explain when to limit worker threads, when a different work division may be needed, and why such a change should be justified by measured performance.
Why it matters: The remedy depends on how the work was divided, and a redesign is worthwhile only if it improves an actual performance problem.
Key terms: oversubscription, task switching, processors, cache proximity, data division, worker threads, task type, CPU-bound, division of work
8.3 Designing data structures for multithreaded performance
This section introduces how data layout and the assignment of data to threads affect multithreaded performance, beyond making a data structure safe for concurrent access.
- Performance versus concurrent safety: Distinguish designing a data structure for multithreaded performance from making it safe for concurrent access. Explain that data layout can affect performance even when a thread's data is not shared.
Why it matters: A thread-safe design can still perform poorly because of how its data is laid out. - Data layout performance levers: Explain that contention, false sharing, and data proximity are key performance considerations, and that changing data layout or which elements each thread processes can improve performance.
Why it matters: These considerations guide the data-structure changes explored next.
Key terms: multithreaded performance, safe for concurrent access, data layout, contention, false sharing, data proximity
8.3.1 Dividing array elements for complex operations
Uses matrix multiplication to show how dividing array work among threads changes false sharing, cache use, and performance.
- Row and column partitioning: Explain how assigning adjacent rows or columns of a row-major result matrix to threads affects their writes and the risk of false sharing.
Why it matters: How threads split the output can affect performance even when they do the same calculation. - Rectangular block tradeoff: Describe why rectangular output blocks can reduce reads from the two source matrices compared with whole-row assignments, while retaining the false-sharing risk of column-based division.
Why it matters: A division of work that improves output writes may not minimize input reads. - Access pattern performance choices: Explain how to choose and assess a division of large-array work by considering both reads and writes, adjusting block size to the problem and processor count, and profiling options on the target architecture.
Why it matters: Changing the work partition can improve performance without changing the underlying algorithm.
Key terms: row-major, false sharing, cache lines, adjacent rows, adjacent columns, rectangular blocks, source matrices, cache miss, data access patterns, block size
8.3.2 Data access patterns in other data structures
This section applies cache-locality and false-sharing considerations to trees and mutex-protected data, then shows how padding can help diagnose false sharing.
- Thread data placement principles: Explain how to distribute data so each thread works on nearby data, needs as little data as practical, and accesses data sufficiently far from other threads’ data to avoid false sharing.
Why it matters: Data placement can determine whether threads benefit from caching or interfere with one another. - Binary tree locality tradeoffs: Describe why binary trees are difficult to partition evenly and how separating tree nodes from the data they reference can sometimes help cache behavior.
Why it matters: A layout that looks scattered can still be useful when threads traverse shared structure but modify separate data. - Mutex and data cache interference: Explain the performance tradeoff of placing a mutex near the data it protects, including how a competing thread’s lock attempt can invalidate the owner’s cached data.
Why it matters: Even a thread that fails to acquire a lock can slow the thread currently using the protected data. - Padding as false sharing test: Describe how to test for false sharing by adding large padding between elements that different threads may access concurrently, then interpreting any performance improvement.
Why it matters: Padding provides a practical way to check whether cache-line interference is causing a slowdown.
Key terms: data distribution, false sharing, std::hardware_destructive_interference_size, binary trees, subtree, tree nodes, mutex, read-modify-write atomic operation, cache line, padding
8.4 Additional considerations when designing for concurrency
This introduction adds exception safety and scalability to the concerns involved in designing concurrent code, and explains why exception safety comes first.
- Scalability with added cores: Explain that concurrent code is scalable when adding processing cores improves performance, through faster execution or greater throughput; ideal scaling is linear.
Why it matters: This gives you a way to judge whether more processors actually benefit the code. - Exception safety as correctness: Explain why exception safety is a correctness concern in concurrent code: an exception can leave broken invariants, cause race conditions, or terminate the application unexpectedly. Distinguish this from scalability, which code can lack and still work.
Why it matters: The section addresses exception safety first because its failure can break the program, not just limit its performance.
Key terms: scalable, throughput, linear, exception safety, invariants, race conditions
8.4.1 Exception safety in parallel algorithms
The section makes parallel accumulation exception-safe by identifying where exceptions can terminate a program, then showing how futures and reliable thread cleanup prevent that outcome.
- Exception hazards in parallel accumulation: Explain why an exception in a worker thread cannot propagate directly to the caller, and why an exception on the calling thread becomes dangerous after threads have been spawned but before they are joined. Distinguish those cases from operations performed before spawning or after joining.
Why it matters: The remedy depends on where an exception can occur and which threads are still running. - Worker results and exceptions through futures: Describe how std::packaged_task and std::future let each worker return its block result or capture an exception, which future::get rethrows on the calling thread. Explain how the caller combines the future results with its initial value and the final block.
Why it matters: Futures move worker failures to a place where the caller can handle them alongside the results. - Joining threads on every exit: Explain why capturing worker exceptions in futures does not by itself protect against a calling-thread exception while threads remain joinable. Describe joining every remaining thread on exit, preferably through a guard whose destructor handles both normal and exceptional exits.
Why it matters: Reliable joining prevents an exception during setup or result collection from turning into program termination. - Async futures and recursive cleanup: Describe the std::async version’s recursive division into chunks and explain its exception safety: a failed asynchronous task is rethrown by get(), while destruction of its future during an exception waits for the task to finish.
Why it matters: The async version shows how library-managed task lifetime can simplify exception-safe parallel code.
Key terms: parallel_accumulate, std::thread, std::terminate, join, std::packaged_task, std::future, get(), accumulate_block, joinable(), thread_guard
8.4.2 Scalability and Amdahl’s law
Explains how serial work limits the speedup from additional processors and how keeping processors busy improves scalability.
- Two measures of scalability: Distinguish reducing the time for an action from increasing the amount of data processed in a given time as processors are added. Explain why a design should identify which measure matters before choosing how to divide work between threads.
Why it matters: The right way to divide work depends on what kind of improvement you need. - Amdahl’s serial-work limit: Explain Amdahl’s law as a simplified estimate that splits work into serial and parallel sections: more processors can shorten the parallel part, but not the serial part. Describe how the serial fraction limits speedup, including the section’s one-third example.
Why it matters: It shows why adding processors alone cannot overcome work that must remain serial. - Practical limits and design levers: Explain why Amdahl’s simplified estimate does not fully predict real programs: tasks are not infinitely divisible, and threads may wait for other threads or I/O rather than remain CPU-bound. Describe how reducing serial work or waits, having other useful work ready during waits, and supplying enough data to keep parallel work going can improve performance.
Why it matters: Keeping processors supplied with useful work matters as much as adding processors.
Key terms: scalability, processors, dividing work between threads, Amdahl’s law, serial sections, parallel sections, serial fraction, speedup, CPU-bound, waiting
8.4.3 Hiding latency with multiple threads
This section explains how to keep processors useful when threads wait, while weighing extra threads against other ways to avoid idle time.
- Blocked threads and spare capacity: Explain why a thread that waits for I/O, a mutex, another thread, or a timer can leave a processor idle, and how additional threads can use that capacity.
Why it matters: A thread count based only on processor count can leave computing capacity unused when threads often block. - Thread count tradeoffs: Describe the virus scanner’s file-search and scanning pipeline as an example of adding workers around I/O waits, and explain why the best thread count must be measured rather than assumed.
Why it matters: Extra threads help only until their useful work is outweighed by costs such as task switching. - Useful work without extra threads: Describe how asynchronous I/O or doing pending work oneself can use time that a thread would otherwise spend blocked; distinguish this throughput aim from adding threads to handle external events promptly.
Why it matters: Avoiding a wait can be an alternative to adding workers, while responsiveness can be a separate reason to add them.
Key terms: blocked threads, physical processing units, spare CPU time, pipeline, I/O-bound, scanning threads, task switching, asynchronous I/O, outstanding chunks, responsiveness
8.4.4 Improving responsiveness with concurrency
The section shows how separating GUI event handling from a long-running task keeps an application responsive without mixing event-loop code into the task.
- Single-threaded event-loop constraint: Explain why a long-running task is awkward in a single-threaded event-driven application: the event loop must handle input frequently, so the task must yield to it or call event-handling code itself.
Why it matters: This explains why a lengthy operation can make a GUI appear to lock up. - Dedicated GUI and task threads: Describe how a dedicated GUI thread can continue processing events while another thread performs a long-running task, with the threads communicating when an event affects that task.
Why it matters: Separating the work lets the interface remain responsive without repeatedly interrupting the task to run GUI code.
Key terms: event loop, get_event(), process(), dedicated GUI thread, event-handling thread, task thread, messages
8.5 Designing concurrent code in practice
The section introduces three parallel Standard Library algorithm implementations as practical examples of task-dependent concurrent design, beginning with std::for_each.
- Task-dependent design examples: Explain that the importance of earlier concurrency-design issues depends on the task, and that three familiar Standard Library functions will be implemented in parallel to show how those issues apply.
Why it matters: The examples are meant to show how design considerations change with the task, not provide one universal recipe. - Illustrative implementations and starting point: Describe the implementations as usable demonstrations of techniques rather than state-of-the-art algorithms, and state that the sequence starts with std::for_each because it is conceptually the simplest parallel algorithm.
Why it matters: This sets expectations for what the examples are intended to teach and why std::for_each comes first.
Key terms: parallel versions, C++ Standard Library, std::for_each, hardware concurrency
8.5.1 A parallel implementation of std::for_each
This section shows two ways to implement parallel std::for_each while accounting for unordered execution and exceptions from worker tasks.
- Parallel for_each execution order: Distinguish sequential std::for_each, which visits elements in range order, from a parallel version, which may process elements concurrently and gives no guarantee about their processing order.
Why it matters: The function applied to each element must not rely on the calls happening in range order. - Upfront division into blocks: Explain how the thread-based implementation divides a known range into contiguous blocks before processing, using std::thread::hardware_concurrency() to choose a thread count under the stated assumption that it is the only parallel task running. Describe why independent element operations permit this division and why contiguous blocks help avoid false sharing.
Why it matters: Choosing how to divide the range affects both the implementation and its performance. - Worker exception propagation: Explain why the thread-based version uses std::packaged_task and std::future<void> even though workers return no result: calling get() on the futures passes worker exceptions to the caller. Contrast this with std::async, whose future's get() provides the same exception propagation.
Why it matters: A void-returning parallel algorithm can still need a way to report worker failures to its caller. - Recursive async range division: Describe the std::async version's recursive division: split the range in half, run one half asynchronously and the other directly, and use ordinary std::for_each when a range is too small to divide profitably. Explain why it divides during execution rather than fixing thread blocks up front: the number of threads the library will use is unknown.
Why it matters: The async version partitions work without assuming control over how many threads the library creates.
Key terms: std::for_each, parallel implementation, std::thread::hardware_concurrency(), contiguous blocks, false sharing, std::packaged_task, std::future<void>, std::async, get(), recursive
8.5.2 A parallel implementation of std::find
The section builds two parallel versions of std::find that stop unnecessary searching, handle results and exceptions, and accept that parallel search may return a later match.
- Early termination across workers: Explain why parallel find must tell other workers to stop when a match is found, and describe how a shared atomic done flag provides that signal.
Why it matters: Without early stopping, a parallel search may do enough unnecessary work to lose to sequential std::find. - Promise based result handling: Describe how the thread-based implementation uses a promise for a match or exception, and why it joins all workers before inspecting the result when no match is possible.
Why it matters: A missing match must return normally rather than leave the caller waiting forever on an unset future. - Recursive async search: Trace how the std::async version divides a large range, searches one half directly and the other asynchronously, and uses a shared done flag and future to finish safely.
Why it matters: This version shows how recursive division and futures simplify worker management while retaining early stopping. - Parallel match ordering: Distinguish the match returned by these parallel searches from the first match guaranteed by sequential std::find.
Why it matters: Callers must not rely on parallel find returning the earliest matching element.
Key terms: std::find, done_flag, atomic variable, early, std::promise, std::future, join, exception, std::async, std::ref
8.5.3 A parallel implementation of std::partial_sum
This section implements parallel running totals in two ways and explains how dependencies, processor count, synchronization, and exceptions affect the designs.
- Running total dependencies and tradeoffs: Explain why a running total cannot be computed by independently summing chunks, and compare chunk-based forward propagation with increasingly distant pairwise updates. Describe why the chunk approach suits fewer processors while pairwise updates offer more parallelism at the cost of more total work.
Why it matters: The dependency between elements determines both the algorithm and when parallelism is worthwhile. - Chunk totals and future handoffs: Trace how the chunk-based implementation computes local partial sums, passes each chunk’s final cumulative value through a promise and future, and updates that final element before the remaining elements. Explain how exceptions travel along the same chain to the final chunk.
Why it matters: Passing the end value early lets the next chunk make progress while the current chunk finishes its other elements. - Reusable barrier and departing threads: Describe how the barrier holds threads until the required count arrives, then resets its spaces and advances its generation for the next round. Explain why departing threads must reduce both the future participant count and the current round’s remaining spaces.
Why it matters: Correct barrier accounting keeps the pairwise steps aligned without leaving threads waiting forever. - Pairwise rounds and alternating buffers: Trace the pairwise implementation as each element adds the value one, then two, then four and increasingly more positions earlier. Explain why threads synchronize after each round and alternate reads and writes between the original range and a buffer, copying final values back when needed.
Why it matters: Round-by-round synchronization and separate read and write locations prevent threads from using inconsistent intermediate sums. - Synchronization and exception limitations: Explain the implementation limits noted for the two approaches: chunk tasks must run concurrently because they await results produced partway through other tasks, while the shown pairwise version is not exception-safe if a worker throws. Describe the stated costs or limitations of its simple spinning barrier.
Why it matters: These limitations matter when deciding whether an illustrative parallel implementation is safe and practical to use.
Key terms: std::partial_sum, forward propagation, incremental pairwise algorithm, process_chunk, std::promise, std::future, std::for_each, barrier, wait(), done_waiting()
8.6 Summary
The chapter summary connects ways to divide work with performance and correctness concerns, then introduces thread pools as the next topic.
Where this is used later
- Thread pools (chapter 9) · Develops the thread-pool pattern of workers taking jobs from pending work.
- Waiting for tasks submitted to a thread pool (chapter 9) · Revisits waiting for submitted work to complete.
- Avoiding contention on the work queue (chapter 9) · Addresses contention on a thread pool’s work queue.
- Testing the performance of multithreaded code (chapter 11) · The effect of a serial portion of work on scalability.
- Tasks that wait for other tasks (chapter 9) · examines tasks that wait for other tasks
- (chapter intro) (chapter 10) · Uses parallel implementations of algorithms as examples of concurrent-code design.
- Interrupting threads (chapter 9) · Develops the broader issue of interrupting threads.
Concepts explained in this chapter
- access patternA data access pattern is the way a computation reads or writes locations in memory over time.
- false sharingFalse sharing is a performance problem in which distinct data items accessed by different threads occupy the same cache line, causing unnecessary cache-line transfers.
- barrierA synchronization primitive that makes participating threads wait until all have reached a synchronization point.
- forward-propagation algorithmA parallel prefix-sum technique that computes sums in chunks and propagates each chunk’s accumulated total to subsequent chunks.
- incremental pairwise algorithmA parallel prefix-sum technique that repeatedly combines values with partial sums from increasingly larger distances.
- task-based work divisionAssignment of portions of a computation to different threads for execution.
- resource contentionThread contention is competition among threads for access to shared data, a resource, or a synchronization primitive.
- exception safetyException safety is the property of a program or operation maintaining defined behavior and resource integrity when exceptions occur.
- find`std::find` is a C++ standard library algorithm that returns the first element in a range equal to a specified value, or the end of the range if none is found.
- parallel algorithmAn algorithm that can distribute its work across multiple execution agents.
- futureAn object representing a value that may become available later, often as the result of asynchronous work.
- std::async`std::async` is a C++ standard library function template that launches a function asynchronously or defers its execution and returns a future for its result.
- std::packaged_task`std::packaged_task` is a C++ standard library class template that wraps a callable and stores its result in a shared state accessible through a future.
- pipelineA pipeline is a processing pattern in which work passes through a sequence of stages.
- separation of concernsSeparation of concerns is a software design principle that organizes a system so distinct concerns are handled independently.
- recursionRecursion is a technique in which a function or process calls itself to solve smaller instances of a problem.
- cache ping-pongCache ping-pong is performance-degrading cache-coherence traffic caused by multiple threads repeatedly accessing the same shared data.
- thread poolA thread pool is a managed collection of reusable threads that execute submitted tasks.
- scalabilityScalability is the ability of a system or algorithm to increase its capacity as available resources increase.
- oversubscriptionA condition in which more threads are run than the hardware can support concurrently.
- dataset splitData-based work division assigns different portions of input data to different threads for processing.
- task switchingA scheduling technique in which a processor alternates execution among tasks, running part of each in turn.
- concurrencyConcurrency is the structuring and execution of multiple tasks whose progress overlaps, whether or not they run at the exact same time.
- multithreadingA programming technique in which a program uses multiple threads of execution within a process.
- spinlockA mutex implementation in which a waiting thread repeatedly attempts to acquire the lock rather than blocking.
- read-modify-write operationA read-modify-write operation atomically reads a value, modifies it, and writes the result as one operation.
- serial regionA portion of a program in which one thread performs the computation rather than multiple threads working concurrently.
- data structureAn organization of data that supports particular operations for storing, accessing, or modifying it.
- synchronizationSynchronization is the coordination of operations across threads so their effects are correctly ordered or observed.
These guides are generated from the text of Manning books by Manning's knowledge graph and link to the sections they draw on. The full books are available on liveBook and at manning.com.
