At 3 a.m., every login attempt on the box returned fork: Resource temporarily unavailable. Memory was fine. CPU was flat. Disks were quiet. The host had run out of tasks: one worker had been spawning children in a retry loop until the kernel's process table filled, and then nothing else on the machine could start a process either — including the shell you needed to fix it.
We are careful about bounding bytes (memory.max), milliseconds (cpu.max), and IOPS (io.max). We almost never bound how many things exist. That is the pids controller, and systemd already wires it to a knob most people have never set: TasksMax=.
Two limits with the same name and different scopes
The reflex is ulimit -u (RLIMIT_NPROC). For services it is usually the wrong tool, and getrlimit(2) says exactly why:
This is a limit on the number of extant process (or, more precisely on Linux, threads) for the real user ID of the calling process. [...] The RLIMIT_NPROC limit is not enforced for processes that have either the CAP_SYS_ADMIN or the CAP_SYS_RESOURCE capability, or run with real user ID 0.
That last sentence is the whole problem. A service running as root can fork without limit no matter what you put in limits.conf. Even for non-root services, the limit is per user ID, not per service: two units running as the same user share one budget, and a login shell counts against it too.
| RLIMIT_NPROC | cgroup pids.max
|
|
|---|---|---|
| Enforced against | real UID | cgroup membership |
| Counts | threads of that UID, everywhere | tasks in the cgroup and its descendants |
| Exempt | root, CAP_SYS_ADMIN, CAP_SYS_RESOURCE | nothing |
| Scope follows | the process's credentials | where the process was placed |
| Configured via |
limits.conf, LimitNPROC=
|
TasksMax= |
The cgroup limit does not care who you are. It cares where you live.
The counter counts threads, not processes
ps aux | wc -l is the wrong denominator. Every thread is a task, and every task counts. A JVM with two processes and forty pool threads is forty-two tasks. This is the most common reason a limit that "looks generous" kills a Java or .NET service at startup with unable to create native thread — an error everyone blames on ulimit.
Measure the real number before you pick one:
# threads in one process
grep Threads /proc/$(pgrep -f my-service | head -1)/status
# all tasks belonging to a unit, threads included
systemctl show -p TasksCurrent -p TasksMax my-service.service
If the second command prints TasksCurrent=[not set], you are asking the wrong manager. User units live in the user instance:
systemctl --user show -p TasksCurrent -p TasksMax my-user-service.service
A child can lower the limit, never raise it
cgroup v2 limits are hierarchical. The effective limit of any cgroup is the tightest pids.max on the path from the root down to it. A nested cgroup can write a bigger number into its own pids.max, but that grants nothing: the ancestor's limit still applies to every task in the subtree. There is also no pids.max on the root cgroup at all — the control files exist on non-root cgroups, which is why you cannot "limit the whole machine" this way.
systemd follows the same rule. From systemd.resource-control(5): when TasksMax= is set for a unit, the pids controller is enabled for that unit and for its parents and siblings, so accounting is consistent up the tree.
What systemd already does for you
Two defaults matter:
-
DefaultTasksAccounting=yes, so task counts are always kept. -
DefaultTasksMax=defaults to 15% of the minimum ofkernel.pid_max,kernel.threads-max, and the root cgroup'spids.max. The man page's example is 4915 on a host with the traditional 32768pid_max.
On the Debian 13 host I tested (systemd 257.13, kernel 6.12):
$ cat /proc/sys/kernel/pid_max
4194304
$ cat /proc/sys/kernel/threads-max
62898
$ systemctl show -p TasksCurrent -p TasksMax -p EffectiveTasksMax systemd-journald.service
TasksCurrent=1
TasksMax=9434
EffectiveTasksMax=9434
9434 is 15% of 62898, truncated. So every unit on that host is already capped, whether or not its author thought about it.
logind adds a second layer: on systemd 257, each user slice is capped at 33% of the same system maximum (UserTasksMax= in logind.conf(5)), which is why user-1000.slice reports TasksMax=20756 here. That is the layer that saves you when a logged-in user runs something unfortunate: the host still has two thirds of its task table for sshd and PID 1.
One honest caveat: these are per-unit caps, not a global budget. Ten units at 15% each can still sum past 100%, so a coordinated fork storm can exhaust the table. What TasksMax= buys you is blast-radius control: the noisy unit hits its own wall and stops, and everything else keeps running.
The files that make it visible
Inside any non-root cgroup:
$ ls /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/ \
| grep pids
pids.current
pids.events
pids.events.local
pids.max
pids.peak
-
pids.current— tasks right now, in this cgroup and its descendants. -
pids.max— the limit, ormaxfor none. -
pids.peak— the high-water mark since the cgroup was created. -
pids.events—max N: how many forks were denied, including in descendants. -
pids.events.local— the same counter for this cgroup only.
pids.events is the one to graph. A limit that is never hit leaves it at max 0, and that is your evidence that the number is doing something rather than just sitting in a unit file.
Lab: make fork fail on purpose
Do not paste the classic fork bomb. A bounded loop produces exactly the same EAGAIN and cannot take your session down with it.
# /tmp/pidlab.py
import os, time
cg = "/sys/fs/cgroup" + [
line.split("::")[1].strip()
for line in open("/proc/self/cgroup")
if line.startswith("0::")
][0]
kids, err = [], None
for i in range(40):
try:
p = os.fork()
except OSError as e:
err = (i, e.errno, os.strerror(e.errno))
break
if p == 0:
time.sleep(1.2)
os._exit(0)
kids.append(p)
def rd(name):
try:
return open(os.path.join(cg, name)).read().strip()
except OSError as e:
return "ERR:" + str(e)
print("cgroup ", cg)
print("fork_failed_at", err)
print("pids.current ", rd("pids.current"))
print("pids.max ", rd("pids.max"))
print("pids.peak ", rd("pids.peak"))
print("pids.events ", rd("pids.events").replace("\n", "; "))
for p in kids:
try:
os.waitpid(p, 0)
except OSError:
pass
Run it in a scope with a deliberately small limit:
systemd-run --user --scope -q -p TasksMax=20 --unit=pidlab -- python3 /tmp/pidlab.py
cgroup /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/pidlab.scope
fork_failed_at (19, 11, 'Resource temporarily unavailable')
pids.current 20
pids.max 20
pids.peak 20
pids.events max 1
Read that carefully, because it contains the whole model:
-
Twenty tasks means the parent plus nineteen children. The scope's own process is inside its own limit; the twentieth
fork()is the one that fails. -
It failed at index 19 with errno 11, EAGAIN. Not
ENOMEM. The kernel is not out of memory; it is enforcing a policy. Applications frequently log this as a memory problem and send you chasing the wrong subsystem for a day. -
pids.eventsismax 1. Exactly one denial was recorded, and only the fork that hit the wall was affected — nothing was killed.
A shell shows the same event in its own dialect:
/usr/bin/bash: fork: retry: Resource temporarily unavailable
bash retries the fork a few times before giving up, which is why a fork-bombing script produces a burst of these lines rather than one clean error.
The percentage trap
TasksMax= accepts a percentage, and it is not a percentage of the default:
systemd-run --user --scope -q -p TasksMax=50% -- \
bash -c 'cg=/sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup); cat $cg/pids.max'
# 31449
31449 is 50% of 62898, the system maximum from threads-max — not 50% of the 9434 default, and certainly not 50% of current usage. Percentages are relative to the same system-wide ceiling that DefaultTasksMax= uses. If you want a number that means the same thing on every host, write an absolute one.
Choosing a number that does not break the app
-
Measure idle and peak.
TasksCurrentat rest is your floor. Run a load test, then readpids.peak. - Count the things that spawn threads, not just processes:
-
JVM: pool threads plus GC and JIT threads.
-XX:ActiveProcessorCountchanges some of them. -
Go:
GOMAXPROCSis a floor, not a ceiling; the runtime adds sysmon and GC workers. -
nginx: one process per
worker_processesvalue, plus cache manager and loader. -
PostgreSQL: one process per connection, plus autovacuum workers, walsenders, and background workers. A
TasksMax=100on a database configured formax_connections=200is a time bomb that goes off under load. -
PHP-FPM:
pm.max_childrenis the process count you must leave room for.
-
Add headroom for shell-outs,
xargs -P, and short-lived helpers. -
Deploy, then watch
pids.events. If it climbs, the limit is too tight — not the app.
Recipes
Cap one noisy service with a drop-in:
sudo mkdir -p /etc/systemd/system/scraper.service.d
sudo tee /etc/systemd/system/scraper.service.d/20-tasks.conf >/dev/null <<'EOF'
[Service]
TasksMax=64
EOF
sudo systemctl daemon-reload
sudo systemctl restart scraper.service
Cap a whole class of work — every CI job in a slice shares one ceiling:
sudo mkdir -p /etc/systemd/system/build.slice.d
sudo tee /etc/systemd/system/build.slice.d/50-tasks.conf >/dev/null <<'EOF'
[Slice]
TasksMax=2048
EOF
sudo systemctl daemon-reload
Cap a user without touching logind defaults:
sudo mkdir -p /etc/systemd/system/user-1000.slice.d
sudo tee /etc/systemd/system/user-1000.slice.d/50-tasks.conf >/dev/null <<'EOF'
[Slice]
TasksMax=4096
EOF
sudo systemctl daemon-reload
Try a limit without writing a unit, useful for reproducing a customer report:
systemd-run --user --scope -p TasksMax=32 -- ./run-tests.sh
Containers get the same controller through the runtime: podman run --pids-limit N and docker run --pids-limit N write the container cgroup's pids.max. Kubernetes can cap pods at the node level with kubelet's --pod-max-pids. Without those flags the limit is max, which is how a container takes down its host's process table from the inside.
Failure modes
| Symptom | Real cause | Check |
|---|---|---|
Resource temporarily unavailable on fork |
pids.max reached |
pids.events |
JVM: unable to create native thread
|
limit below thread count |
pids.peak vs TasksMax
|
| Works until traffic spikes | fork-per-request or connection-per-process |
TasksCurrent under load |
| Limit "ignored" for a root service | you set LimitNPROC=
|
use TasksMax=
|
| Numbers differ from the docs | percentage vs absolute |
pids.max in the cgroup |
Lowering a limit at runtime does not kill anything. Existing tasks keep running; new forks are refused until the count drops below the new ceiling. That makes tightening a live system safe, and it also means a too-low limit can look fine until the next restart.
Verify, then roll back
systemctl show -p TasksMax -p EffectiveTasksMax -p TasksCurrent my-service.service
systemd-cgtop -t -b -n 1 # order control groups by task count
cat /sys/fs/cgroup/system.slice/my-service.service/pids.events
If the limit was a mistake, remove the drop-in and revert:
sudo rm /etc/systemd/system/my-service.service.d/20-tasks.conf
sudo systemctl daemon-reload
sudo systemctl restart my-service.service
# or, to drop every local override at once:
sudo systemctl revert my-service.service
The limit nobody graphs
A host that cannot fork is down in a way that looks nothing like being out of memory or CPU: the monitoring is green, the logs are quiet, and nothing you type works. It costs one line in a unit file to make sure the next runaway loop fails inside its own cgroup instead of taking the machine with it.
Start with your noisiest units — scrapers, build jobs, anything that shells out — and give them a number based on measurement rather than optimism. Then watch pids.events for a week. If it stays at zero, you have headroom. If it moves, you just found the service that would have been your 3 a.m. page.
References
- Linux kernel documentation, Control Group v2 — the pids controller and its interface files: https://docs.kernel.org/admin-guide/cgroup-v2.html
-
cgroups(7)— pids controller since Linux 4.3,CONFIG_CGROUP_PIDS: https://man7.org/linux/man-pages/man7/cgroups.7.html -
systemd.resource-control(5)—TasksAccounting=,TasksMax=,EffectiveTasksMax= -
systemd-system.conf(5)—DefaultTasksMax=(15% of the minimum ofpid_max,threads-max, and rootpids.max) -
logind.conf(5)—UserTasksMax= -
fork(2),getrlimit(2)— EAGAIN conditions and RLIMIT_NPROC exemptions -
systemd-run(1),systemctl(1),systemd-cgtop(1) -
podman-run(1),docker-run(1)—--pids-limit
Tested on Debian 13, kernel 6.12.113, systemd 257.13. Your numbers will differ; the semantics will not.
Top comments (0)