A Weird Imagination

Limit processor usage of multiple processes

Posted in

The problem#

In last week's post, I discussed using cpulimit on multiple processes in the special case of web browsers, but I wanted a more general solution.

The solution#

cpulimit-all.sh is a wrapper around cpulimit which will call cpulimit many times to cover multiple processes of the same name and subprocesses.

Using that script, the follow is the equivalent of the script from last week to limit all browser processes to 10% CPU:

cpulimit-all.sh --limit=10 --max-depth=1 \
    -e firefox -e firefox-esr -e chromium -e chrome

But also, we can add a couple options to include any grandchild processes and check for new processes to limit every minute:

cpulimit-all.sh --limit=10 --max-depth=2 \
    -e firefox -e firefox-esr -e chromium -e chrome \
    --watch-interval=1m

The details#

Script summary#

The script is mostly argument parsing and bookkeeping. It collects a set of targets which may be any combination of PIDs, program names, program paths, and a program to execute, with syntax matching cpulimit except for allowing multiple to be specified. Additionally, it accepts many of cpulimit's arguments to be passed on to the instances of cpulimit that it runs.

In order to keep track of what processes it is limiting, it keeps $watched_pids which is a list where each line is of the format pid:depth:cpulimit_pid where pid is the PID of the process being limited, depth is the depth in the process tree from one of the targets listed on the command line (0 for the explicit targets), and cpulimit_pid is the PID of the cpulimit process limiting that process. The limit_pid() function is where cpulimit is actually run and that list is updated.

To start, the PIDs specified on the command line are limited. Then the rest of the time is spent in a loop that checks for any new processes of the executable names or paths given on the command line or any subprocesses of the currently limited processes. If the --watch-interval option is not given on the command line, then once this loop stops finding new processes to limit, it will just wait. Otherwise, that option gives the interval at which new processes are searched for. In practice, that interval should be fairly long because with enough processes being limited, searching for all of their subprocesses starts taking a non-trivial amount of processor time, possibly cancelling any gains from doing the limiting.

Watching for processes#

Optimally, the script would subscribe to notifications about new processes and determine if they should be limited instead of having to constantly poll for processes. Unfortunately, no such API exists. In fact, cpulimit internally polls for processes with a 2 second wait when there is no process running matching the target specified, which suggests there really isn't a better way to do it.

Repeated arguments in getopt#

I originally followed these instructions to use GNU getopt for option handling... but I could not figure out a way to make it accept multiple copies of the same option (e.g. -e firefox -e chrome), which I want to allow to specify multiple programs to limit. My solution was to handle my own argument parsing and therefore lose out on some of getopt's features. Most noticeably, I did not implement handling multiple short arguments after a single dash (e.g., -vkr).

Checking if an argument is a number#

Part of argument parsing is verifying that the argument values are valid. To check if the numeric argument are actually numbers, I used this snippet, which does not depend on bash.

Accidental subshell in loop body#

The basic structure of the code is that there's a loop over the processes to limit and inside the loop body is a call to a function which starts cpulimit and records the processes as being watched by writing its PID into a variable along with some other bookkeeping information. But outside the loop, that variable was always empty.

At first I thought it had to do with variable scoping, but the shell semantics on scoping are that a variable is global unless explicitly defined as local using the local keyword. Trying to figure out what was going on, I made another variable and set it in various places which caused ShellCheck (which I've written about before) to show me the following warning:

var was modified in a subshell. That change might be lost. [SC2031]

which led me to this StackOverflow answer which linked to this page of various solutions. As I generally try to avoid bashisms, I went with the heredoc solution: instead of

echo "$foo" | while read -r line; do ... done

use

while read -r line; do ... done <<EOF
$foo
EOF

Comments

Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.

There are no comments yet.