Parallel Directory Traversal
Introduction
This article presents a complete concurrent programming example in C++, using it as a case study to explore dynamic task generation, work-completion detection, and the safe aggregation of partial results.
Given a directory path, our goal is to produce a statistical summary of the contents of its
directory tree. For each file extension found (.txt, .zip, and so on), we will determine both
the number of files and their cumulative size. The analysis will also report the total number of
subdirectories discovered during the traversal.
In this implementation, we will parallelize the directory tree traversal itself. Each worker thread will process directories retrieved from a shared concurrent queue, discover any subdirectories they contain, and dynamically push them into the queue so that they can later be processed by any available worker. As a result, multiple threads will be able to explore different branches of the directory tree simultaneously while collecting partial statistics that will finally be merged into a single global result.
This approach will allow us to generate summaries such as the following for a given root directory:
std::optional<T&>,
together with the module system introduced in C++20. The latter allows us to organize the code efficiently into well-defined
components. If necessary, adapting the solution to the traditional structure based on header (.hpp) and
implementation (.cpp) files would be straightforward.
The project can already be compiled with GCC 16.2, which provides support for C++ contracts and modules. However, some module features are still unavailable, most notably private module fragments (
module :private;).
Module 1: dynamic_task_queue
We begin by implementing a blocking concurrent work queue, dynamic_task_queue<T>, specifically
designed for scenarios in which tasks can dynamically generate additional tasks during their execution.
This data structure simplifies the parallelization of graph traversals, including the directory
hierarchies considered in this article.
The class stores pending tasks of type T in a private std::queue<T> named tasks_.
It also maintains a counter, active_, that tracks the number of tasks currently being
processed. This counter is incremented whenever a task is acquired and decremented when the task completes.
As a result, the queue can detect global completion automatically, which occurs when there are neither
pending tasks nor tasks in progress. The global termination condition is therefore: tasks_.empty() and active_ == 0.
Both the queue and the counter are protected by a std::mutex1, while a std::condition_variable2 is
used to block worker threads whenever no work is available and to wake them up when new tasks are
added or when global completion is detected.
tasks_ queue does not
necessarily imply that the computation has finished: a worker thread may still be processing a task
and could generate additional tasks at a later stage. The role of the active_ counter is precisely
to distinguish between these two situations.
The queue interface will be based on an acquire/complete protocol, where every task acquired by a worker
through the acquire() member function must eventually be reported as completed through a matching call to the complete()
function. In addition, complete() may register any new tasks discovered during the processing of the acquired task.
Specifically:
-
acquire(): If work is available, it retrieves a task from the front of the queue, removes it from the pending-work list, and internally increments theactive_task counter. If there are no pending tasks but other workers are still processing work, the call blocks until new work becomes available or the computation finishes. When there are neither pending nor active tasks left, the function returns an empty result (std::nullopt), signalling that the worker may terminate. -
complete(): Performs two actions while holding the internal mutex: (i) it records the completion of a task previously acquired viaacquire()by decrementing theactive_counter, and (ii) it pushes newly discovered tasks into the queue. If no pending or active tasks remain after the operation, it signals global completion so all workers can finish. If there is pending work (tasks_.empty() == false), it wakes up a blocked worker.
To prevent the programmer from having to call complete() manually and to guarantee proper task
completion even in the presence of exceptions, acquire() does not directly return a task object of type
T. Instead, it returns a std::optional containing an instance of an auxiliary class named
acquired_task. This class acts as an RAII handle representing a task currently in progress. In addition to
the acquired value, it stores a vector of new tasks discovered during processing. When the object goes
out of scope, its destructor automatically invokes complete(), transferring the accumulated new tasks
to the queue and signaling the completion of the original task. Thus, the acquire/complete protocol
is enforced by design through RAII:
Module 2: statistics.directory
This module implements the parallel analysis of a directory tree. Given a root directory (root),
it produces an object of type directory_statistics containing:
- A
std::mapassociative container that maps each file extension encountered (.txt,.zip, and so on) to anextension_statisticsobject, which stores both the number of files with that extension present in the directory tree and their cumulative size. The use ofstd::mapensures that file extensions appear alphabetically in generated reports by default, improving readability. - The total number of subdirectories discovered.
- The total number of errors encountered during the analysis, whether due to (i) failure to open a directory, (ii) inability to retrieve information about a specific entry, or (iii) inability to continue iterating through a directory. These errors are accumulated in a single counter for simplicity of implementation, but they could easily be broken down into separate counters.
The run_directory_statistics() function is responsible for coordinating the parallel execution.
It initializes a dynamic_task_queue<filesystem::path> with the root directory, where each task
represents a directory pending analysis within the directory tree. The function then launches a configurable
number of worker threads, num_workers, each of which produces a directory_statistics object containing the
partial statistics gathered during its execution. Specifically, num_workers - 1 workers are launched
asynchronously using std::async3, while the main thread acts as an additional worker processing tasks
from the shared queue.
Each worker executes the process_directories() function, which repeatedly acquires directories from the queue
through acquire(), inspects their contents, and updates the worker’s local directory_statistics object
with the statistics of the files found, grouped by extension and cumulative size. Subdirectories discovered during
the traversal are not processed immediately; instead, they are registered as new tasks within the acquired_task
object associated with the current directory. Upon destruction, the object automatically transfers the
accumulated tasks to the queue and signals completion of the original task. As we previously mentioned, this
behavior is implemented using RAII.
dir is represented as a task stored in the
dynamic_task_queue<filesystem::path>, with std::filesystem::is_directory(dir) == true.
As workers discover new subdirectories, they are dynamically added to the queue as additional tasks, whereas regular
files are processed directly and never become tasks themselves.
The implementation of process_directories() is based on std::filesystem::directory_iterator4, which
iterates over the entries contained within a directory but does not visit its
subdirectories. The iteration order is unspecified by the standard, except that each directory entry
is visited exactly once.
directory_statistics object, accumulating statistics only for the directories it processes.
Once the traversal has completed, the partial results are merged by merge_statistics(), which
aggregates the file counts and cumulative sizes for each extension, together with the total number of
subdirectories visited.
Auxiliary modules
Before turning to the main program, we will define three simple auxiliary modules. Two of them provide thin wrappers around selected C library facilities, while the third offers formatting utilities for program output. We begin by examining the compatibility modules:
-
c_tools.exit_codes: Exposes the standard termination valuesEXIT_SUCCESSandEXIT_FAILUREasconstexprvariables within thec_toolsnamespace. Its purpose is to facilitate the use of these values from modular code without relying directly on the macros defined in<cstdlib>. -
c_tools.standard_streams: Providesnoexceptfunctions that returnstd::FILE*pointers to the standardstdin,stdout, andstderrstreams defined in<cstdio>.
The remaining auxiliary module is:
format_tools: Extendsstd::formatthrough customstd::formatterspecializations, allowing binary sizes to be displayed using more readable units (KiB, MiB, and GiB) and integral values to be formatted with thousands separators:
Benchmarks
The following main() function benchmarks the various modules developed throughout
this article. It performs the statistical analysis of a directory tree specified as a command-line
argument, using an increasing number of worker threads and measuring the execution time in each case.
Based on these measurements, several performance metrics are computed and displayed in the terminal, including the speedup relative to the sequential execution (i.e., a single worker thread) and the percentage improvement achieved when increasing the number of workers:
The number of worker threads num_workers defaults to
hardware_concurrency()5, which
provides an estimate of the concurrency level available on the system, typically matching the number
of hardware threads. However, this value should be interpreted as an indicative upper bound and not
necessarily as the optimal number of workers. In practice, performance may saturate at a lower
thread count due to factors such as contention on the shared queue, the underlying storage device,
or limitations imposed by the file system itself. In particular, different storage technologies may
scale quite differently: modern NVMe SSDs are designed to handle significantly higher degrees of
concurrent access than mechanical drives.
As an example, on a system equipped with an 11th Gen Intel Core i5-1135G7 processor running at 2.40 GHz, 8 hardware threads, and an SK hynix HFM256GD3HX015N SSD, the following results were obtained when analyzing a test directory tree:
Here, the “vs. 1 worker” column expresses the percentage reduction in execution time relative to the sequential version.
The associative container within the final directory_statistics result can be used, among other things,
to generate breakdowns like the one shown in the introduction or to retrieve statistics for a specific
file extension. For instance, to determine the total number of .txt files and their cumulative size in
a root directory, we would write:
Note: This post is an English translation of my earlier post originally published in Spanish on Blogger as Programación Concurrente IX.
Bibliography
-
cppreference.com – std::mutex ↩
-
cppreference.com – std::condition_variable ↩
-
cppreference.com – std::async ↩
-
cppreference.com – std::filesystem::directory_iterator ↩
-
cppreference.com – std::thread::hardware_concurrency ↩