How PHP-FPM workers and batch jobs stop fighting over cores
nice and ionice only lower the priority of background jobs, but do not prevent them from briefly occupying entire cores. CPU pinning goes a step further: it reserves specific cores exclusively for PHP-FPM workers, while reindexing and cron are only allowed to run on other cores.
Table of Contents
- 1. Why CPU pinning goes beyond nice
- 2. How the scheduler distributes processes without pinning
- 3. taskset: setting CPU affinity for individual processes
- 4. cgroups cpuset: reserving core groups permanently
- 5. Restricting PHP-FPM pools to dedicated cores
- 6. Configuring AllowedCPUs in systemd units
- 7. Planning CPU pinning and NUMA topology together
- 8. Common mistakes in CPU pinning
- 9. Isolation strategies compared
- 10. Summary
- 11. FAQ
1. Why CPU pinning goes beyond nice
nice and ionice lower the priority of background jobs, but guarantee no fixed separation: as soon as a core is free, even a low-priority process is allowed to occupy it completely for a moment. For many scenarios this is enough, but for very large reindex jobs or compute-intensive batch processing, even this brief full utilization of a core can cause noticeable latency spikes for concurrently running PHP-FPM workers, if the scheduler happens to move the worker process onto exactly that core in the meantime.
CPU pinning solves this problem structurally: instead of only lowering priority, a process or process group is assigned a fixed subset of the available CPU cores that it stays confined to. PHP-FPM workers then run exclusively on a reserved core group, while reindexing, backups, and other batch jobs use a completely separate core group. Both workloads can then run simultaneously at full load without affecting each other.
This article shows how CPU pinning is implemented with taskset for individual processes and with cgroups cpuset for permanent isolation, and how this isolation can be sensibly combined with a server's NUMA topology.
2. How the scheduler distributes processes without pinning
Without explicit configuration, the Completely Fair Scheduler (CFS) continuously decides on which core a process runs next, based on current utilization and cache locality. This flexibility is sensible for most workloads, because it automatically maintains balanced core utilization without requiring manual administrator intervention.
For very different workload types on the same server, this exact flexibility is sometimes a disadvantage: a compute-intensive batch job and a latency-sensitive PHP-FPM worker can repeatedly get scheduled onto the same core, leading to cache eviction, so-called cache thrashing, where constantly alternating processes overwrite the same CPU cache with different data. CPU pinning prevents this back-and-forth by giving the scheduler, from the outset, only a subset of cores to choose from for each workload type.
3. taskset: setting CPU affinity for individual processes
The taskset command sets or reads a process's CPU affinity mask, i.e. the set of cores the process is allowed to run on. A new process can be started directly with a core mask, an already-running process can be adjusted afterward by PID without restarting it. This form of CPU pinning is ideal for quick, targeted tests, for example to check whether a specific batch job actually benefits from a core restriction before setting up a permanent solution.
For production configurations, however, taskset is only a first step, because the binding does not automatically apply to newly started child processes and has to be manually repeated on every restart of the target process. For permanent, inheritable isolation, cgroups cpuset is a much better fit.
# Start a new process pinned to cores 4-7 only
taskset -c 4-7 php bin/magento indexer:reindex
# Pin an already-running process by PID to cores 0-3
taskset -cp 0-3 24817
# Check the current CPU affinity of a running process
taskset -cp 24817
4. cgroups cpuset: reserving core groups permanently
The cgroups cpuset controller allows an entire process group to be permanently confined to a fixed set of cores, including all child processes started later within that group. This makes cpuset the more robust choice for CPU pinning on production servers: one cgroup for PHP-FPM workers with cores 0 to 7, a separate cgroup for batch jobs with cores 8 to 15, and both groups persist regardless of individual process restarts.
On modern distributions with cgroups v2, these core groups can be managed directly through the filesystem under /sys/fs/cgroup/, via the cpuset.cpus file per group. Additionally, cpuset.mems analogously controls which NUMA memory nodes are available to a group, bringing CPU pinning and NUMA binding together in a single configuration.
#!/usr/bin/env bash
# setup-cpusets.sh — create dedicated cgroups for web vs. batch workloads
set -euo pipefail
mkdir -p /sys/fs/cgroup/web-workers
mkdir -p /sys/fs/cgroup/batch-jobs
# Web workers get cores 0-7, memory from NUMA node 0
echo "0-7" > /sys/fs/cgroup/web-workers/cpuset.cpus
echo "0" > /sys/fs/cgroup/web-workers/cpuset.mems
# Batch jobs get cores 8-15, memory from NUMA node 1
echo "8-15" > /sys/fs/cgroup/batch-jobs/cpuset.cpus
echo "1" > /sys/fs/cgroup/batch-jobs/cpuset.mems
5. Restricting PHP-FPM pools to dedicated cores
For PHP-FPM itself, CPU pinning can be configured via the systemd service wrapper or directly at the start of the master process, so that all worker child processes it spawns automatically inherit the binding. It is important that the binding is applied to the master process, not individually to every single worker, because PHP-FPM starts and stops workers dynamically, depending on the configured process manager mode.
With multiple PHP-FPM pools for different Magento websites on the same server, CPU pinning can additionally be used to separate critical pools from less critical ones, for example the pool for the main shop on more heavily prioritized cores, while a rarely used B2B pool runs on a smaller set of cores.
# /etc/systemd/system/php8.4-fpm.service.d/cpuset.conf
[Service]
# Restrict the PHP-FPM master and all its worker children to cores 0-7
AllowedCPUs=0-7
6. Configuring AllowedCPUs in systemd units
Since systemd version 244, AllowedCPUs= is available as a native directive that directly sets a cgroups cpuset restriction for a unit, without requiring manual management of files under /sys/fs/cgroup/. This directive can be applied to any service, slice, or scope unit and is automatically inherited by all processes that unit later spawns.
For clean, system-wide separation, it is recommended to create a dedicated systemd slice for web workloads and another for batch jobs, each with its own AllowedCPUs= value, and to assign all relevant services to that slice. This centralizes the CPU pinning configuration in one place, instead of scattering it across many individual unit files.
# /etc/systemd/system/web-workers.slice
[Slice]
AllowedCPUs=0-7
# /etc/systemd/system/batch-jobs.slice
[Slice]
AllowedCPUs=8-15
# Assign a service to a slice:
# systemctl set-property php8.4-fpm.service Slice=web-workers.slice
7. Planning CPU pinning and NUMA topology together
CPU pinning without regard for a server's NUMA topology can create new problems of its own: if PHP-FPM workers are pinned to cores on node 0 while the memory they use, for example OPcache shared memory, is allocated on node 1, additional remote memory accesses occur, partly undoing the isolation. The core groups for CPU pinning should therefore always be drawn along actual NUMA node boundaries, determined via numactl --hardware.
For a two-node server, this means in practice: pin PHP-FPM workers to the cores and memory of node 0, batch jobs to the cores and memory of node 1. This combination of CPU pinning and NUMA binding delivers the strongest isolation, because neither CPU time nor memory bandwidth needs to be shared between the two workload types.
8. Common mistakes in CPU pinning
The most common mistake is reserving too few cores for the web worker group, under the assumption that more isolation is always better. If the reserved core set is too tight, PHP-FPM can no longer overflow onto unused cores of the batch group during load spikes, even though they might be free at that moment, artificially limiting the server's overall capacity. Core allocation should therefore always be based on realistic load measurements, not a blanket rule of thumb.
A second mistake is setting up CPU pinning without checking whether the hypervisor on virtualized servers even respects the configuration. On heavily overcommitted cloud instances with shared vCPUs, a cpuset restriction set in the guest system can remain ineffective, because the host manages the actual core allocation independently anyway.
9. Isolation strategies compared
The following table compares the different approaches to CPU pinning and isolation on a Magento server.
| Approach | Persistence | Degree of isolation |
|---|---|---|
| nice / ionice | Per process start | Low, relative priority only |
| taskset | Manual after every restart | Medium, hard core restriction |
| cgroups cpuset (manual) | Permanent, inheritable | High, including child processes |
| systemd AllowedCPUs / Slice | Permanent, centrally managed | High, system-wide consistent |
For production Magento servers, the combination of systemd slices with AllowedCPUs= and NUMA-aware core allocation is the most robust solution. taskset remains useful for quick tests, but should not be the only measure for a permanent production configuration.
Mironsoft
Worker isolation and CPU pinning for Magento servers
Are batch jobs and PHP-FPM fighting over the same cores?
We set up NUMA-aware CPU pinning via systemd slices, separate web workers from batch jobs, and validate the isolation under real load.
Core allocation
Plan web and batch core groups based on real load measurements
systemd slices
Configure AllowedCPUs centrally and permanently
NUMA alignment
Align CPU and memory binding along node boundaries
10. Summary
CPU pinning goes beyond the relative prioritization of nice and ionice and creates a hard, structural separation between workload types. taskset is suitable for quick, targeted tests of individual processes, but offers no persistence across restarts. cgroups cpuset and the systemd directive AllowedCPUs= provide permanent, inheritable isolation that automatically applies to all child processes of a group.
CPU pinning has its greatest effect when planned together with a server's NUMA topology: core groups should be drawn along NUMA node boundaries, so that neither CPU time nor memory bandwidth needs to be shared between isolated workloads. For production Magento servers, combining systemd slices with NUMA-aware core allocation is the most robust and lowest-maintenance solution.
CPU Pinning with taskset and cpuset — Key Takeaways
Quick test
taskset -c for manual, temporary core restriction of individual processes.
Permanent isolation
cgroups cpuset.cpus or systemd AllowedCPUs= per slice.
Respect NUMA
Draw core groups along node boundaries from numactl --hardware.
Verify capacity
Base core allocation on real load measurements, not a rule of thumb.