Sleeping without a subprocess in Bash, and how to sleep forever

No subprocess, no sleep command, no coproc, no nothing? Yes.

Sleeping in bash script is traditionally done with the sleep (1) command, which is external to bash, the command /bin/sleep. However, if you have a bunch of scripts running that all sleep this way, the output of ps looks like a mess, pstree looks like a bigger mess, and every OCD sensor in my brain goes off.

Sample output of pstree:

$ sudo pstree -ps 2828
systemd(1)───urxvt(2826)───bash(2828)───bash(14252)───sleep(14255)

Here, my terminal (urxvt) runs a shell (bash, 2828), that runs a test script (bash, 14252), that runs sleep (14255).

Several bad ideas

This post on Stack Exchange contains plenty of horrible proposed solutions, but does also point out that several distributions of Linux ship a package with loadable bash modules. Among them is an internal sleep command. I didn’t want to rely on that, however.

Stack Overflow has a post on how to sleep forever. Again there are several horrendous ideas, but the answer by Tino is rather clever:

bash -c 'coproc { exec >&-; read; }; eval exec "${COPROC[0]}<&-"; wait'

coproc is a relatively new feature, however, and it uses eval, which, as wooledge.org points out, is a common misspelling of “evil”. We can do better.

Finally asleep

Waiting to read a file descriptor that will never output anything is a clever solution, but we can achieve that without using coproc. instead opting for good old fashioned process substitution.

So I wrote the following function:

snore()
{
    local IFS
    [[ -n "${_snore_fd:-}" ]] || { exec {_snore_fd}<> <(:) && read -r -t 0 -u $_snore_fd; } 2>/dev/null ||
    {
# workaround for MacOS and similar systems local fifo
fifo=$(mktemp -u) mkfifo -m 700 "$fifo" exec {_snore_fd}<>"$fifo" rm "$fifo" } read ${1:+-t "$1"} -u $_snore_fd || : }

So what does that do? Well, this:

local IFS Reset IFS in case it’s set to something weird.
[[ -n “${_snore_fd:-}” ]] Checks if the $_snore_fd variable has already been declared. If so, we are good to go. The :- is there to subtitute with an empty string in case you’re using “set -eu”, which would exit with an error if the variable wasn’t set already.
exec {_snore_fd}<> Assigns the next available file descriptor to the “_snore_fd” variable. “_snore_fd” will be a number signifying the assigned file descriptor after this.
<(:) Process substitution: reading from a subshell that simply runs “:”, or “true” if you will, and then exits
read Attempts to read input, though it won’t get any
${1:+-t “$1”} Parameter expansion: If the snore() function was provided a parameter, it will pass it along to read as an argument for -t (timeout).
If no parameters were provided, -t will not be specified, and read will hang forever.
-u $_snore_fd Specifies that read should use the value of $_snore_fd as its input file descriptor
|| : Making sure read returns 0, for coding with -e set. This will run : if read fails, and : always returns 0.

Let’s test it!

Here’s a short script to compare the efficiency of snore() to that of /bin/sleep. It runs each operation 1000 times, for a total of what should be 10 seconds for each.

#!/usr/bin/env bash
set -u
set -e

snore()
{
    local IFS
    [[ -n "${_snore_fd:-}" ]] || exec {_snore_fd}<> <(:)
    read ${1:+-t "$1"} -u $_snore_fd || :
}

time for ((i=0; i<1000; i++)); do snore 0.01; done
time for ((i=0; i<1000; i++)); do sleep 0.01; done

The snore() function runs faster than /bin/sleep, at least on my system. That’s not to say it sleeps too quickly – one second is still one second – but if called in quick succession, one can see that the snoring loop is faster than the sleeping one:

$ /tmp/test </dev/null

real	0m10.226s
user	0m0.144s
sys	0m0.036s

real	0m11.674s
user	0m0.060s
sys	0m0.232s

As you can see, calling snore() 1000 times has a combined overhead of 0.226 seconds, while /bin/sleep measured 1.674 seconds. This is of course utterly insignificant in real world applications, but it’s interesting none the less.

No more sleep processes

Aside from the completely insignificant performance differences, my OCD was satisfied, as a script running snore() has no child process to wait for, and the subshell we spawn (once) disappears immediately. Here’s pstree while I run a script that snores:

$ sudo pstree -ps 2828
systemd(1)───urxvt(2826)───bash(2828)───bash(19247)

So my terminal runs a shell, and that shell runs the script, but there’s no sleep call, and no other subprocess. There’s simply the interactive shell waiting for the script. Excellent.

As an added bonus, there will no longer be any of the usual issues of various sleep processes hanging around after killing processes, or preventing them from being killed in the first place.

Halt and Catch Fire

Going back to the question on stack overflow, you may have noticed the parameter processing of snore() allowing for no parameters to be passed. This means that if you don’t pass any parameters to snore(), -t (timeout) will not be specified for the call to read, and read will hang forever. I don’t know why you’d want this, but now you can.

Update (June 12, 2019)

Added a workaround for MacOS and similar systems, using a short-lived FIFO to read from (only created on the first call to snore()).

Update (July 23, 2023)

Added a workaround for Bash 5.2 (thank you, anonymous commenter, for letting me know about the issue). Bash 5.2 would let the subprocess spawned by <(:) linger until the first read call completed. By using read -r -t 0 on it initially, this subprocess should go away immediately and not clutter up process lists, as intended.

21 Comments

  • Scott says:

    Really cool. Thanks for posting this.

  • Scott says:

    Just tried a test script with only the snore function and a ‘snore 2’ call to it, but got a syntax error: unexpected redirection error on the starting with [[.

  • Mark says:

    read -t 3 < >(:)

    • bolt says:

      That is exactly what’s being done here, except if you don’t save the file descriptor, you’re creating a new subprocess each time.

  • Very cool!

    Just what I need for my collectd exec plugin script.

  • ADoyle says:

    It does not work in MacOS.
    It shows “/dev/fd/62: Permission denied” when execute `exec {_snore_fd} <(:)`.
    My bash version: 4.4.12(1)-release (x86_64-apple-darwin15.6.0)

    • bolt says:

      It is indeed not fully portable.
      I unfortunately don’t have a MacOS system available to develop a workaround.
      If you figure something out, please let me know.

      I got access to a MacOS machine and added a quick workaround for it. It’s not pretty, but it does seem to do the job, on Bash4 and upwards.

      • ADoyle says:

        That is cool. I have tested it and found that `mktemp -u` may be slow relative to snore 0.1s. It costs 0.012~0.016s. So the precision of snore function may be 0.1s. And `snore 0.01` will be inaccurate.
        0.1s precision is good enough to me. Thanks!

        • bolt says:

          Yes, the first call to snore() will have some overhead with that workaround. Every subsequent call in the same script, as long as the _snore_fd variable is preserved, should be as accurate as the other methods, however.

  • Ehsan says:

    Hi, what is the purpose of :- in ${_snore_fd:-} ?
    If i’m not mistaken, this is a parameter substitution and there is supposed to be a word after the dash to be substituted for _snore_fd, but there isn’t anything here.

    • bolt says:

      It’s for people writing scripts with “set -eu”, which would exit with an error if the variable wasn’t set. It makes no difference if you don’t use those settings.

  • wow says:

    wow this is black magic, thanks man

  • bash_5.2 says:

    Noticed lately it creates subprosess of itself.

    $ rngbd &
    [1] 3207

    $ pstree -ps 3207
    runit(1)───tabbed(1006)───st(1176)───bash(1177)───rngbd(3207)───rngbd(3209)

    it did not use to do this, maybe changes in bash?

    • bolt says:

      Thank you for bringing this to my attention. Bash 5.2 has apparently changed some of what made this hack work. I’ve updated the function to do a zero timeout read when initially creating the file descriptor, which gets rid of the issue.

  • onion says:

    First, thank you for your work.

    I think you misspelled $_sf_sleep_fd and should be $_sleep_fd
    Also I suggest read -r ${1:+-t “$1”} -u $_snore_fd || :

    • onion says:

      I misspelled my comment also :)))

      I mean $_sf_sleep_fd should be $_snore_fd

      Correct me if I’m wrong.

      • bolt says:

        Hey. Sorry about that. Copy/paste error 😀
        The function is called something else internally.

        Thank you for the correction.

  • Bob the street cat says:

    It’s faster and more accurate ;). Sweet. I don’t think the local IFS is really needed, or how would this ever be needed?

    • bolt says:

      You’re right it likely doesn’t do much in this case. The read command can behave in very weird ways if that variable is off though.

  • Richard says:

    This is brilliant – thank you.

    using a modified version of the function that overcomes the bash 5.2 issue, but doesn’t require the Mac workaround :

    [[ -n “${_snore_fd:-}” ]] || { exec {_snore_fd} <(:) && read -r -t 0 -u $_snore_fd; }
    read ${1:+-t "$1"} -u $_snore_fd || :

    time for ((i=0; i<1000; i++)); do snore 0.01; done

    The test script runs the 1st loop then exits with strange timings. The curly braces look balanced – so I can't see why.

    real 0m0.000s
    user 0m0.000s
    sys 0m0.000s

    I'm running bash v5.1.16

3 Trackbacks

Leave a Reply

Your email address will not be published. Required fields are marked *