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
  • 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.

  • 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
  • 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
  • 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 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
  • 58,310
  • 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