Bash

__pgrepf() in Bash: pgrep -f Using Only /proc

By Nawawi JamiliPublished Updated 6 min read

pgrep -f pattern prints the PIDs of processes whose full command line matches a pattern. It is the standard way to ask "is my worker still running?" when the process name alone (php, python3, java) is not specific enough. On Linux, every process's command line is readable under /proc, so the same job can be done in pure Bash. I wrote __pgrepf() in 2009 for a minimal rescue system without procps. This is the corrected version.

The function

bash
# __pgrepf() -- pure-Bash "pgrep -f": print PIDs whose command line matches
# Usage: __pgrepf regex        (Linux only: reads /proc/PID/cmdline)
__pgrepf() {
    if [ $# -ne 1 ] || [ -z "$1" ]; then
        printf 'Usage: __pgrepf regex\n' >&2
        return 2
    fi
    [ -r /proc/self/cmdline ] || return 2
    local pattern="$1" f pid arg cmdline rc=1
    for f in /proc/[0-9]*/cmdline; do
        pid=${f#/proc/}
        pid=${pid%/cmdline}
        # skip this shell and, inside $( ), its subshell
        [ "$pid" = "$$" ] || [ "$pid" = "${BASHPID:-}" ] && continue
        cmdline=
        # arguments are NUL-terminated; join them with spaces
        while IFS= read -r -d '' arg; do
            cmdline+="$arg "
        done 2>/dev/null < "$f"
        cmdline=${cmdline% }
        [ -n "$cmdline" ] || continue        # kernel thread or gone
        if [[ $cmdline =~ $pattern ]]; then
            printf '%s\n' "$pid"
            rc=0
        fi
    done
    return "$rc"
}

How it works

Walking /proc

Each running process has a directory /proc/PID. The glob /proc/[0-9]*/cmdline picks only the numeric ones, so /proc/self, /proc/sys and similar entries are skipped without a regex test. Two parameter expansions, ${f#/proc/} and ${pid%/cmdline}, cut the PID out of the path. They cost almost nothing. The original used basename $(dirname $f), which started two processes for every entry in /proc.

NUL separators in cmdline

/proc/PID/cmdline is not a line of text. It holds the process's argv array, each argument terminated by a NUL byte, so arguments that contain spaces survive intact:

terminal
$ perl -e 'sleep 300' -- --marker "two words" &
$ od -c /proc/$!/cmdline
output
0000000   p   e   r   l  \0   -   e  \0   s   l   e   e   p       3   0
0000020   0  \0   -   -  \0   -   -   m   a   r   k   e   r  \0   t   w
0000040   o       w   o   r   d   s  \0
0000050

Bash variables cannot hold NUL bytes. read -r -d '' uses NUL as the line delimiter, so each pass of the while loop gets exactly one argument, and the function joins them with single spaces. That gives the same string pgrep -f matches against: perl -e sleep 300 -- --marker two words. IFS= stops read trimming leading and trailing spaces from an argument.

The 2>/dev/null sits before < "$f" on purpose. Redirections are applied left to right, and processes exit all the time. If one disappears between the glob and the read, opening its cmdline fails, and with stderr already redirected that failure is silent. An empty result is skipped, which also covers kernel threads, whose cmdline is empty.

Matching

[[ $cmdline =~ $pattern ]] treats the pattern as a POSIX extended regular expression, the same flavour pgrep uses, so ^, .* and alternation work. The pattern variable must stay unquoted on the right-hand side. Quoting it would make Bash match it as a literal string.

Excluding itself

pgrep never reports its own PID. A shell function runs inside your shell, so the function skips $$, the shell's PID. When the function is called inside $( ... ) it runs in a subshell with a different PID, available as $BASHPID (Bash 4 and later), so that is skipped too.

Usage

With the perl worker above still running in the background:

bash
__pgrepf 'marker two'; echo "rc=$?"
pgrep -f 'marker two'; echo "rc=$?"
__pgrepf '^perl -e .* --marker'; echo "rc=$?"
__pgrepf 'two  words'; echo "rc=$?"
__pgrepf 'no-such-process'; echo "rc=$?"
__pgrepf; echo "rc=$?"
pids=$(__pgrepf 'marker two'); echo "[$pids]"
output
152393
rc=0
152393
rc=0
152393
rc=0
rc=1
rc=1
Usage: __pgrepf regex
rc=2
[152393]

The function and pgrep -f agree, the anchored regex works, and two spaces do not match because the arguments are joined with one. The return codes follow pgrep: 0 when something matched, 1 when nothing did, 2 for a usage problem. PIDs come out one per line, like pgrep, so you can pass the output directly to while read -r pid or mapfile.

The classic use is a watchdog in cron:

bash
if ! __pgrepf 'queue-worker\.php' >/dev/null; then
    /usr/local/bin/start-queue-worker
fi

Note the escaped dot: in a regex an unescaped . matches any character.

Pitfalls

Linux only

The function depends on the Linux layout of /proc. macOS has no /proc at all, and the BSDs either do not mount it or lay it out differently. There the [ -r /proc/self/cmdline ] check fails and the function returns 2 rather than wrongly reporting "not running". Use the real pgrep, which ships with macOS and the BSDs.

Your own script can match

Only the current shell is skipped. If the pattern also appears on the command line of the script's parent, for example bash -c '... marker ...' or an ssh session running the command, that process matches. pgrep -f behaves the same way, and is in fact worse. When called from a script, it reports the script itself, because it only excludes its own PID. When I ran both from a script whose path matched the pattern, pgrep -f printed the script's PID and __pgrepf did not.

Kernel threads and zombies

These have an empty cmdline and are skipped. pgrep -f falls back to the process name for them, so pgrep -f kthreadd finds PID 2 and __pgrepf kthreadd does not. For watching your own services that never matters.

Permissions and hidepid

If /proc is mounted with hidepid=2, as on some hardened servers, non-root users only see their own processes. That limits pgrep equally.

Performance

The 2009 post said the function was "a little bit slow" compared with pgrep -f. I measured both versions, three runs each, on one machine (an 8-thread i7 laptop running Bash 4.4 with 365 processes):

CommandTime (real)
pgrep -f sshd0.005 to 0.006 s
__pgrepf sshd (this version)0.035 to 0.036 s
2009 version, with <= repaired0.62 to 0.64 s

The new function is about six times slower than pgrep, which is fine for a cron check. The old one was more than a hundred times slower because it forked for every PID: two nested command substitutions to run basename and dirname, plus a third for $(< file).

Alternatives

If procps is installed, use pgrep -f. Add -x for an exact match or -u user to restrict by owner. To inspect one process's command line by hand, tr '\0' ' ' < /proc/PID/cmdline is the shortest readable form. In Bash 4.4 and later, mapfile -d '' loads the arguments into an array, one element per argument:

bash
mapfile -d '' -t args < /proc/"$pid"/cmdline
printf '[%s]\n' "${args[@]}"
output
[perl]
[-e]
[sleep 300]
[--]
[--marker]
[two words]

The Bash manual entry for read documents -d '', and man 5 proc describes the cmdline file.

History of this snippet

As printed on the old blog, the 2009 code contained (( $_pid < = 9 )). The original was <=. The old WordPress setup inserted the space when it escaped the <. In that form, every PID triggers ((: 100 < = 9 : syntax error: operand expected. The line was meant to skip PIDs 1 to 9, which on a 2009 system meant init and early kernel threads. It is unnecessary now and I dropped it.

The bigger bug was _buf=$(< $_f). Command substitution cannot keep NUL bytes, so the arguments were joined with no separator at all: perl-esleep 300----markertwo words. To compensate, the function removed every space from the pattern, which caused false matches. The repaired 2009 version reports the worker for the pattern perl-e, which appears nowhere in its real command line. The new version correctly returns 1. Since Bash 4.4 the old line also prints warning: command substitution: ignored null byte in input for every process with arguments. On my test machine that was 163 warnings per call.