2

I have a while loop in a bash script which should do something different at the beginning and at every 5 second interval. Any previous loop is allowed to complete. The 5s interval is indicated by the do_different global variable set by the heartbeat function. An additional complication is that a normal while loop completes in an unknown amount of time (simplified with RANDOM in below script). Using cron is not an option, neither is timing the random process.

I already unsuccessfully tried using a pipe as well as process substitution. The whole script may be re-factored.

#!/bin/bash

function heartbeat {
    do_different=true
    while sleep 5s
    do
        do_different=true
    done
}

heartbeat &

while true
do
    if $do_different
    then
        echo 'Something different'
        do_different=false
        i=0
    else
        # process of random duration; not important
        r=$(( 1 + RANDOM % 3 ))
        sleep "${r}s"
        i=$((i + r))
        echo "$i"
    fi
done
Serge Stroobandt
  • 2,314
  • 3
  • 32
  • 36
  • No `s` suffix is needed on the argument of `sleep` if the value is seconds. POSIX describes no such suffix feature: http://pubs.opengroup.org/onlinepubs/9699919799/utilities/sleep.html – Kaz Jul 09 '15 at 00:38

4 Answers4

2

First of all, in case it isn't obvious, the script in the question fails because heartbeat runs in a child process, and therefore cannot change shell variables in the parent shell's memory.

Here's an approach that's closer to the spirit of the OP's attempt:

#!/bin/bash

trap 'do_different=true' USR1

heartbeat() {
    while sleep 5
    do                  # If the parent process has gone away, the child should terminate.
        kill -USR1 "$$"  ||  exit
    done
}

heartbeat &
heartbeat_pid=$!
trap 'kill "$heartbeat_pid"' 0

do_different=true

while true
do
    if "$do_different"
    then
        echo 'Something different'
        do_different=false
        i=0
    else
        # process of random duration; not important
        r=$(( 1 + RANDOM % 3 ))
        sleep "$r"
        i=$((i + r))
        echo "$i"
    fi
done

The modified heartbeat sends SIGUSR1 signals to the main (parent) shell process.  This, and SIGUSR2, are reserved for user/application use (and should never be generated by the system).  The trap command allows a shell script to catch signals. The trap 'do_different=true' USR1 command tells the shell to catch the SIGUSR1 signal (which arrives every five seconds) and set the do_different flag when it occurs.

heartbeat_pid, obviously, is the process ID (PID) of the heartbeat child process.  The command trap 'kill "$heartbeat_pid"' 0 defines an action to occur upon "receipt" of the pseudo-signal 0, which refers to script exit.  Think of this as the shell putting a sticky note on the door saying "After you leave, remember to buy groceries on the way home."  This action will be invoked if the script reaches the end or executes an exit statement (neither of which can happen with this script, since it is an infinite loop), or if it is terminated by an interrupt signal (SIGINT, which is generated by Ctrl+C).  This is a safety net; the heartbeat process is already written to terminate when the parent process goes away.

Stéphane Chazelas
  • 522,931
  • 91
  • 1,010
  • 1,501
  • I saved your script under the name `test` and performed a `chmod +x` on it. Here is the error I get when trying to run it: `./test: 5: ./test: function: not found` – Serge Stroobandt Jul 08 '15 at 21:07
  • @SergeStroobandt: Thanks for catching that.  The `function heartbeat {` ... syntax that you used is a bash-ism, and apparently `/bin/sh` on your system isn't bash.  I changed my answer to use the POSIX syntax.  BTW, `test` is the name of a shell builtin command, and so is a bad name for any other command.  For example, if you write a program or script called `foo` and move it to a directory that's in your search path (e.g., `/bin`, `/usr/local/bin`, or `~/bin`), you can then run it by typing `foo` (rather than `./foo`).  But typing `test` will get you the builtin, and not any executable file. – Scott - Слава Україні Jul 09 '15 at 00:28
  • I saved under name `u`, turn `RANDOM` to `$RANDOM` and luach as `bash u`, this work OK. – Archemar Jul 09 '15 at 07:33
  • Note that you can use "EXIT" instead of "0" to make it more self-explanatory. Note that POSIXly it should be `kill -s USR1` though `kill -USR1` will work as well on most systems covered by U&L (and if the shell is bash) – Stéphane Chazelas Jul 23 '15 at 07:36
1

I would use the date utility to get the current time in seconds.

#!/bin/bash
lastTime=-5

while true
do
    currentTime=$(date +%s)
    elapsedTime=$((currentTime - lastTime))
    if [[ $elapsedTime -ge 5 ]]
    then
        echo 'Something different'
        lastTime=$currentTime
        i=0
    else
        # process of random duration; not important
        r=$(( 1 + RANDOM % 3 ))
        sleep ${r}s
        i=$((i + r))
        echo $i
    fi
done

Edit: Changed lastTime's initial value so that it also "does something different" at the beginning.

airfishey
  • 630
  • 4
  • 10
  • `lastTime=-5` is not necessary because `elapsedTime` will be the number of seconds since 1970-01-01 00:00:00 UTC and therefore greater than 5. Furthermore, `-gt` should read `-ge`. – Serge Stroobandt Jul 08 '15 at 18:38
  • Good catch on the `-gt` bug. I've edited the code with this fix. The reason to initialize `lastTime` to -5 is so that `currentTime - lastTime` > 5 at the start as per your requirement of "should do something different at the beginning". Assume seconds since the epoch at the start of this script is 0. Without initializing `lastTime` to -5, you would have `lastTime=0` and `currentTime=0` and "something different" would not happen at the beginning. – airfishey Jul 08 '15 at 18:47
  • At the beginning of the script `elapsedTime` equals `currentTime` if `lastTime` does not exist. Replacing `echo 'Something different'` by `echo $elapsedTime` returned 1436384016 over here. This is the number of seconds since January 1, 1970. – Serge Stroobandt Jul 08 '15 at 19:39
  • 1
    The correct way to initialise is `lastTime=$(date --date='5seconds ago' +%s)`. This then also allows to have `lastTime=$((currentTime - elapsedTime +5))`, which coincides with the original attempt. – Serge Stroobandt Jul 08 '15 at 20:58
  • Good suggestion for initializing lastTime to `$(date --date='5seconds ago' +%s)` instead of `-5`. Your suggestion is more elegant than my original `-5` implementation. As far as I know, the date utility does not return negative numbers, so both solutions should work. I may very well be wrong though – airfishey Jul 08 '15 at 21:41
  • 2
    You don't need the Bash/Korn extension of `[[ ... ]]` if to test whether a variable is >= a constant. Just the POSIX syntax `if [ $elapsedTime -ge 5 ]`. – Kaz Jul 09 '15 at 00:44
1

Here is the code I am running now, based on airfishey's answer with some corrections.

#!/bin/bash

t_lastdue=$(date --date='5seconds ago' +%s)

while true
do
    t_now=$(date +%s)
    t_elapsed=$((t_now - t_lastdue))
    if [ $t_elapsed -ge 5 ]
    then
        echo 'Something different'
        t_lastdue=$((t_lastdue + 5))
        i=0
    else
        # process of random duration; not important
        r=$(( 1 + RANDOM % 3 ))
        sleep "${r}s"
        i=$((i + r))
        echo "$i"
    fi
done
Serge Stroobandt
  • 2,314
  • 3
  • 32
  • 36
  • If being as close as possible to the 5 second interval is important you're better off using `t_lastdue=$t_now` in your if statement as `t_elapsed` could be 6 (depending on when this bash process gets serviced by the CPU scheduler). You would then be off by 1 second the next time `echo 'Something different'` runs – airfishey Jul 08 '15 at 21:37
  • 2
    @airfishey: Please re-check your logic.  The current code, `t_lastdue=$((t_now - t_elapsed + 5))`, is equivalent to `t_lastdue=$((t_lastdue + 5))` (see below), which guarantees that the “different” thing happens once every five seconds (or, at least, that it averages _n_ occurrences every 5 × _n_ seconds over the long run).  By contrast, if `t_elapsed` is 6, that would mean that `t_now` = `t_lastdue` + 6, so setting `t_lastdue=$t_now` would cause precisely the schedule slippage that you’re trying to avoid. – Scott - Слава Україні Jul 09 '15 at 01:15
  • 1
    SergeStroobandt: Looks good.  (1) Since `t_elapsed=$((t_now - t_lastdue))`, you can reduce `t_lastdue=$((t_now - t_elapsed + 5))` to `$((t_now - (t_now - t_lastdue) + 5))` and hence to `$((t_lastdue + 5))`.  Setting `t_lastdue=$((t_lastdue + 5))`, arguably, makes it clearer that this solution tries very hard to do the “different” thing every five seconds.  (By contrast, `sleep 5s` is not guaranteed to return in exactly five seconds, so my answer (and your original attempt) can fall behind schedule (out of sync) over time.)  … (Cont’d) – Scott - Слава Україні Jul 09 '15 at 01:21
  • 1
    (Cont’d) …  (2) You should always quote shell variables (even `${r}` and `$i`; see my revised answer) unless you have a good reason not to, and you’re *sure* you know what you’re doing.  See [Security implications of forgetting to quote a variable in bash/POSIX shells](http://unix.stackexchange.com/q/171346/23408) for a very long explanation. – Scott - Слава Україні Jul 09 '15 at 01:23
  • @Scott: You are absolutely correct. I was thinking about it from the standpoint of we want "Something different" to happen as close as possible to 5 seconds after the last "Something different" happened. So if the second "Something different" happens at time 6 (i.e. one second late) then the next one would happen at time 11. You're solution is closer to the OP intent. You're intent is to keep it on schedule even if the previous "Something different" was late. Thanks for clearing that up and good observation. :-) – airfishey Jul 09 '15 at 13:36
-1

Since you're using bash anyway, you should be using $SECONDS:

#!/bin/bash
while [ "$SECONDS" -lt 5 ] || {
      SECONDS=$((i=0))
      echo Something different
};do  sleep "$((r=(1+RANDOM)%3))"
      echo  "$((i+=r))"
done

From man bash:

$SECONDS

  • This variable expands to the number of seconds since the shell was started. Assignment to this variable resets the count to the value assigned, and the expanded value becomes the value assigned plus the number of seconds since the assignment.

And here's how you can do it and handle drift:

S=SECONDS
while   [ "$(($S<5||(i=0*($S-=5))))" -ne 0 ] ||
        echo Something different
do      sleep "$((r=(1+RANDOM)%3))"
        echo  "$((i+=r))"
done
mikeserv
  • 57,448
  • 9
  • 113
  • 229
  • Nice code, but it does not take care of schedule slippage (see above comment by Scott). – Serge Stroobandt Jul 09 '15 at 07:55
  • @SergeStroobandt - Have you actually witnessed this problem he describes? If you want to handle it, you should stage a wider window. Say... every 300 iterations you compare your elapsed time to `date` or something *(of course, on a POSIX system, that's no good either because POSIX does not handle leap seconds)*, but it's better than nothing. – mikeserv Jul 09 '15 at 08:05
  • Yes, I have. In my real-world example the heartbeat is every 1800s (half an hour) and the random process is an iteration of about 10 times 3 minutes. I created a more breve example because I did not want respondents to lose their time with this. – Serge Stroobandt Jul 09 '15 at 08:16
  • @SergeStroobandt - well if it's every half an hour, just schedule it with `at`. You can even ask it to send you a signal. `at now + 30m\nkill -URGENT "$$"`. You can also use `touch` on some file and sync up your alarm against the fs clock. That'll get you microseconds. – mikeserv Jul 09 '15 at 08:20
  • @SergeStroobandt - i implemented a means of handling *drift*. – mikeserv Jul 09 '15 at 09:12