Resource Pressure Handling in systemd

On Linux the Pressure Stall Information (PSI) Linux kernel interface provides a way to monitor resource pressure — situations where tasks are stalled waiting for a resource to become available. PSI covers three types of resources:

PSI makes available to userspace a poll()-able file descriptor that gets notifications whenever pressure latencies for the system or a control group grow beyond some configured level.

When the system is under memory pressure, userspace can proactively release memory it allocated but doesn’t immediately require back to the kernel. This includes allocation caches, and other forms of caches that are not required for normal operation to continue. Similarly, when CPU or IO pressure is detected, services can take appropriate action such as reducing parallelism, deferring background work, or shedding load.

The effects of resource pressure during runtime generally are growing latencies during operation: applications and services slow down until pressure is relieved. Hence, to ensure stable service latencies it is essential to detect pressure early and respond appropriately.

systemd itself makes use of PSI, and helps applications to do so too. Specifically:

Pressure Service Protocol

For each resource type, if pressure handling for a specific service is enabled via the corresponding *PressureWatch= setting (i.e. MemoryPressureWatch=, CPUPressureWatch=, or IOPressureWatch=), two environment variables are set by the service manager:

The protocol works the same for all three resource types. The remainder of this section uses memory pressure as the example, but the same logic applies to CPU and IO pressure with the corresponding environment variable names.

When a service initializes it hence should look for $MEMORY_PRESSURE_WATCH. If set, it should try to open the specified path. If it detects the path to refer to a regular file it should assume it refers to a PSI kernel file. If so, it should write the data from $MEMORY_PRESSURE_WRITE into the file descriptor (after Base64-decoding it, and only if the variable is set) and then watch for POLLPRI events on it. If it detects the path refers to a FIFO inode, it should open it, write the $MEMORY_PRESSURE_WRITE data into it (as above) and then watch for POLLIN events on it. Whenever POLLIN is seen it should read and discard any data queued in the FIFO. If the path refers to an AF_UNIX socket in the file system, the application should connect() a stream socket to it, write $MEMORY_PRESSURE_WRITE into it (as above) and watch for POLLIN, discarding any data it might receive.

To summarize:

(And in each case, immediately after opening/connecting to the path, write the decoded $MEMORY_PRESSURE_WRITE data into it.)

Whenever a POLLPRI/POLLIN event is seen the service is under pressure. It should use this as hint to release suitable redundant resources, for example:

Which actions precisely to take depends on the service in question and the type of pressure. Note that the notifications are delivered when resource latency already degraded beyond some point. Hence when discussing which resources to keep and which to discard, keep in mind it’s typically acceptable that latencies incurred recovering discarded resources at a later point are acceptable, given that latencies already are affected negatively.

In case the path supplied via $MEMORY_PRESSURE_WATCH points to a PSI kernel API file, or to an AF_UNIX socket, opening it multiple times is safe and reliable, and should deliver notifications to each of the opened file descriptors. This is specifically useful for services that consist of multiple processes, and where each of them shall be able to release resources on memory pressure.

The POLLPRI/POLLIN conditions will be triggered every time pressure is detected, but not continuously. It is thus safe to keep poll()-ing on the same file descriptor continuously, and executing resource release operations whenever the file descriptor triggers without having to expect overloading the process.

(Currently, the protocol defined here only allows configuration of a single “degree” of pressure per resource type, there’s no distinction made on how strong the pressure is. In future, if it becomes apparent that there’s clear need to extend this we might eventually add different degrees, most likely by adding additional environment variables such as $MEMORY_PRESSURE_WRITE_LOW and $MEMORY_PRESSURE_WRITE_HIGH or similar, which may contain different settings for lower or higher pressure thresholds.)

Service Manager Settings

The service manager provides two per-service settings for each resource type that control pressure handling:

The /etc/systemd/system.conf file provides two settings for each resource type that may be used to select the default values for the above settings. If the threshold isn’t configured via the per-service nor system-wide option, it defaults to 200ms.

When pressure monitoring is enabled for a service this primarily does three things:

Pressure Events in sd-event

The sd-event event loop library provides API calls that encapsulate the functionality described above:

When implementing a service using sd-event, for automatic memory pressure handling, it’s typically sufficient to add a line such as:

(void) sd_event_add_memory_pressure(event, NULL, NULL, NULL);

– right after allocating the event loop object event. For CPU and IO pressure, a custom handler should be provided to take appropriate action:

(void) sd_event_add_cpu_pressure(event, NULL, my_cpu_pressure_handler, userdata);
(void) sd_event_add_io_pressure(event, NULL, my_io_pressure_handler, userdata);

Other APIs

Other programming environments might have native APIs to watch memory pressure/low memory events. Most notable is probably GLib’s GMemoryMonitor. As of GLib 2.86.0, it uses the per-cgroup PSI kernel file to monitor for memory pressure, but does not yet read the environment variables recommended above.

In older versions, it used the per-system Linux PSI interface as the backend, but operated differently than the above: memory pressure events were picked up by a system service, which then propagated this through D-Bus to the applications. This was typically less than ideal, since this means each notification event had to traverse three processes before being handled. This traversal created additional latencies at a time where the system is already experiencing adverse latencies. Moreover, it focused on system-wide PSI events, even though service-local ones are generally the better approach.