Bulk Deletion Is Not a Threading Problem
The task is ordinary and the frustration is common. A directory accumulates temporary files, tens of thousands of them, sometimes a quarter million in a single directory, and the nightly cleanup that walks it with find and deletes anything older than thirty days takes far too long. The natural next thought, when a job is slow, is to make it run in parallel. For this particular job that thought is usually a detour, because the slowness almost never lives where threads would help. It lives in a single character.
What the classic command actually does
The command nearly everyone writes is slower than it looks, for a reason invisible on the page.
find /var/tmp/cache -type f -mtime +30 -exec rm -f {} \;
The \; at the end tells find to run rm once per matched file. For a directory of two hundred and fifty thousand old files, that is a quarter million separate rm processes, each one a full fork and exec that the kernel has to set up and tear down. The deletion itself is trivial; the process churn around it is the cost. Measured on twenty thousand files in one directory on a local filesystem, that per-file form took twenty-four seconds. The same deletion, done without forking a process per file, took a tenth of a second. That is not a threading gap. It is a hundred-and-fifty-times gap, and it is entirely about how many processes you spawned.
The fix is not threads
Two changes remove the per-file process, and both are still single-threaded.
The first is a one-character edit. Change \; to +, and find batches as many files as fit within the argument-length limit into each rm call, turning a quarter million process spawns into a handful.
find /var/tmp/cache -type f -mtime +30 -exec rm -f {} +
How many is a handful depends on ARG_MAX, the kernel's ceiling on the combined size of the argument vector and the environment for a single exec. On a typical Linux host that ceiling is measured in megabytes, and find packs each rm invocation up to it, so path length rather than file count sets the batch size. A directory of short names batches far more aggressively than a directory of long ones. You can read the limit with getconf ARG_MAX, though the practical takeaway is only that the number of spawns collapses from one-per-file to something in the single or double digits.
The second is better still, because it removes rm from the picture entirely. find's own -delete action calls the unlink system call directly on each match, with no external command to spawn at all.
find /var/tmp/cache -type f -mtime +30 -delete
On the same twenty thousand files, batched rm finished in about 0.17 seconds and -delete in about 0.11. Either one is the fix. Neither one is parallel.
One behavioral difference is worth knowing before you swap them, because it is silent. -delete implies -depth, which makes find process a directory's contents before the directory itself. That is required for -delete to work at all on directories, and it is usually what you want, but it changes traversal order, and any expression that assumed the default pre-order walk can behave differently once -delete is present. It also means -delete cannot be combined with -prune in the way people expect, since -prune operates on the pre-order visit that -depth has already skipped past.
Why the threads did not help
Running the same deletion through xargs -P with four and then eight parallel workers came out slightly slower than the single-threaded batched version, not faster. The general mechanics of parallel shell work, and the traps that come with it, live in their own piece.
The reason is the shape of the work. Deleting a file is a metadata change to the directory that holds the file, not computation that more cores can chew through faster, and on a local filesystem the kernel serializes changes to a single directory behind that directory's own lock. Point eight workers at one directory and they do not delete in parallel. They line up for the same lock and add coordination overhead on top. The work was never CPU-bound, so throwing CPUs at it buys nothing.
The one thing that decides whether parallelism helps
That result holds for a single directory on local disk. Change the storage underneath and the answer can flip, which is the part worth understanding rather than memorizing a rule.
On networked storage reached over NFS, each unlink becomes a request that travels to the server and waits for a reply, rather than a quick local metadata write, and that round-trip latency dominates instead of CPU or the local directory lock. A serial delete spends almost all of its time waiting for one reply before sending the next request. Here, and only here, running many deletions in flight at once genuinely helps, because while one request waits for its reply another is already on the wire. What parallelism buys is hidden waiting, not faster deletes. The same logic explains the other case where threads pay off: when the files are spread across many directories rather than piled into one, parallel workers hit separate directory locks instead of contending for a single one.
So the real question was always about the storage rather than about "parallel or serial." Latency-bound and networked, or many directories, and parallelism can win. Local, single directory, and it loses to a plain -delete. The honest move is to measure your own storage rather than trust a benchmark run somewhere else, because this is exactly the kind of result that does not transfer between filesystems.
A cleanup that does the right thing by default
The following does the correct thing without being asked and reaches for parallelism only when you tell it to, because deleting in bulk is destructive and the safe default matters more than the fast one. It validates its inputs, refuses to operate on the filesystem root, offers a dry run that deletes nothing, and uses direct unlink by default.
#!/usr/bin/env bash
set -o errexit -o nounset -o pipefail
usage() {
echo "usage: ${0##*/} --path DIR --age-days N [--dry-run] [--parallel WORKERS]" >&2
exit 2
}
target=""
age_days=""
dry_run=0
parallel=0
while [[ "$#" -gt 0 ]]; do
case "${1}" in
--path) target="${2:?--path needs a value}"; shift 2 ;;
--age-days) age_days="${2:?--age-days needs a value}"; shift 2 ;;
--parallel) parallel="${2:?--parallel needs a value}"; shift 2 ;;
--dry-run) dry_run=1; shift ;;
*) usage ;;
esac
done
[[ -n "${target}" && -n "${age_days}" ]] || usage
case "${age_days}" in ''|*[!0-9]*) echo "age-days must be a non-negative integer" >&2; exit 2 ;; esac
case "${parallel}" in ''|*[!0-9]*) echo "parallel must be a non-negative integer" >&2; exit 2 ;; esac
[[ -d "${target}" ]] || { echo "not a directory: ${target}" >&2; exit 1; }
resolved="$(readlink -f -- "${target}")"
case "${resolved}" in
/|"") echo "refusing to operate on the filesystem root" >&2; exit 1 ;;
esac
if (( dry_run )); then
count="$(find "${resolved}" -type f -mtime "+${age_days}" -print0 | tr -cd '\0' | wc -c)"
echo "[dry-run] would delete ${count} files older than ${age_days} days under ${resolved}"
exit 0
fi
if (( parallel > 0 )); then
# Parallel only helps on latency-bound storage or across many directories,
# so split the work by immediate subdirectory, giving each worker its own
# directory lock instead of contending for one. Measure before trusting it.
find "${resolved}" -mindepth 1 -maxdepth 1 -type d -print0 \
| xargs -0 -r -P "${parallel}" -I{} \
find {} -type f -mtime "+${age_days}" -delete
# sweep the files sitting directly under the target as well
find "${resolved}" -maxdepth 1 -type f -mtime "+${age_days}" -delete
else
# Default: one traversal, direct unlink, no per-file process.
find "${resolved}" -type f -mtime "+${age_days}" -delete
fi
Two details worth keeping in mind while using it. -mtime +30 means strictly more than thirty full twenty-four-hour periods old, so it is subtly not the same as "older than thirty calendar days," and if that boundary matters you should confirm it against your own timestamps. And -mtime is modification time, not access time; if what you actually mean is "not touched in thirty days," that is -atime, which many systems disable for performance and cannot answer reliably.
The dry-run branch is worth a second look, because it is doing something deliberate. It counts null bytes from -print0 rather than counting lines, which keeps the count correct when a filename contains a newline. That is a rare case on purpose-built cache directories and a routine one on user-facing storage, and the cost of handling it correctly here is one tr invocation.
The deeper cost this does not solve
One number in the benchmark stays quietly expensive no matter how you delete: the traversal. Before a single file is removed, find has to read the directory and stat every entry to check its age, and both of those scale with how many files are in the directory. Fixing the deletion leaves untouched the fact that a single directory holding a quarter million files is a strained data structure to walk in the first place.
That strain is not uniform across filesystems, which is why the ceiling arrives at different heights for different people. Filesystems that index directory entries in a tree, as ext4 does with its hashed directory index and XFS does with its B-tree, degrade far more gracefully at high entry counts than a filesystem doing a linear scan. But indexing helps lookup, and the cleanup does lookups nowhere in its work. It enumerates everything and calls stat on each result, which is the access pattern that indexing helps least. The remedy is upstream: do not let that many files accumulate in one directory, shard them across a tree instead. That decision usually sits with whoever writes the files, not with whoever cleans them up, and a cleanup script does not get a vote. Knowing where the ceiling is remains worth something, because it tells you which wall you hit next once the fork is gone.
The order of operations is the whole lesson. Stop forking a process per file first, always, because that is the fix that works on every filesystem. Then, and only then, measure whether your storage is latency-bound enough that running deletions in parallel would hide the waiting. On local disk against one directory, it will not be, and the plain -delete you already wrote is the finished answer.