Skip to content

The Concurrency Model

Hardwood is multi-threaded at its core: a single reader, driven by a single consumer thread, keeps a pool of worker threads busy decoding ahead of where the consumer is reading.

Why concurrency is built in

The Parquet layout makes parallelism natural: a column chunk is a sequence of independently compressed pages (see How a Parquet File Is Laid Out). Decompressing and decoding a page is CPU-bound work that doesn't depend on its neighbors, so pages can be worked on concurrently. Hardwood exploits this without asking the caller to manage threads: you write an ordinary single-threaded read loop, and the parallelism happens underneath it.

The shared pool: Hardwood and HardwoodContext

Worker threads live in a pool owned by a context, not by individual readers. ParquetFileReader.open(InputFile) creates and owns a context sized to the available processors, and closing the reader shuts it down. An explicitly created HardwoodContext outlives any one reader and is shared across all readers opened against it; the caller closes it. See Read Multiple Files as One Dataset for creating and passing one.

Share one context across many reads. The pool is the expensive, reusable resource; readers are cheap and short-lived. This matters most when reading many files, as described below.

The assembly pipeline

For a single read, the reader runs an assembly pipeline ahead of the consumer. While your loop is processing the current batch (or row), pool threads are already decompressing and decoding the pages that feed the next batches, queuing the results. The consumer thread generally pulls ready-to-use decoded data instead of blocking on decode.

Frequent or long BatchWait JFR events mean decode (or I/O) isn't keeping up with consumption.

Cross-file prefetching

When a reader spans multiple files (via Hardwood.openAll(...)), prefetching crosses file boundaries: as the pages of file N run low, pages of file N+1 are already being fetched and decoded. This is the main reason to open a multi-file reader rather than looping over single-file readers yourself, and the reason the shared pool matters, since all the files draw on the same workers. See Read Multiple Files as One Dataset.

What this means for your code

  • Drive one reader from one thread. A RowReader / ColumnReader / ColumnReaders instance is a stateful cursor meant to be advanced by a single consumer thread. The concurrency is internal: do not call next() / nextBatch() on the same reader from multiple threads. The batch arrays a ColumnReader returns may be handed to other threads while the consumer moves on (see Column-Oriented Reading).
  • For your own parallelism, read in parallel at the file or row-group grain. To use more than one consumer thread, give each its own reader over a different file, or partition one file across readers by byte range with split-aware reading. Have them share a single HardwoodContext so they draw worker threads from one pool rather than each spinning up its own.
  • Size the pool to the workload. HardwoodContext.create(n) sets the number of threads that decompress and decode pages, which is the bulk of the CPU, so n is the main throughput and CPU dial. The default (available processors) suits a process whose main job is reading Parquet; lower it when Hardwood shares the machine with other heavy work. Two lighter stages, fetching page bytes on demand and assembling decoded values into batches, run on the JVM's virtual-thread carriers rather than this pool. Speculative fetches (the next file's footer, the next row group's fetch plans and first chunk, the chunk after the one being read) run on the common ForkJoinPool. n bounds neither. For strict CPU isolation on a shared machine, also cap the carriers with -Djdk.virtualThreadScheduler.parallelism and the common pool with -Djava.util.concurrent.ForkJoinPool.common.parallelism at a value of 2 or more. Below 2, each speculative fetch runs on a new thread of its own.

Further reading