3 * Wait loop that reaches a condition or times out.
5 * Copyright (C) 2016 Aaron Schulz <aschulz@wikimedia.org>
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
23 * @author Aaron Schulz
29 * Wait loop that reaches a condition or times out
31 class WaitConditionLoop {
34 /** @var callable[] */
35 private $busyCallbacks = [];
36 /** @var float Seconds */
38 /** @var float Seconds */
39 private $lastWaitTime;
40 /** @var integer|null */
43 const CONDITION_REACHED = 1;
44 const CONDITION_CONTINUE = 0; // evaluates as falsey
45 const CONDITION_FAILED = -1;
46 const CONDITION_TIMED_OUT = -2;
47 const CONDITION_ABORTED = -3;
50 * @param callable $condition Callback that returns a WaitConditionLoop::CONDITION_ constant
51 * @param float $timeout Timeout in seconds
52 * @param array &$busyCallbacks List of callbacks to do useful work (by reference)
54 public function __construct( callable $condition, $timeout = 5.0, &$busyCallbacks = [] ) {
55 $this->condition = $condition;
56 $this->timeout = $timeout;
57 $this->busyCallbacks =& $busyCallbacks;
59 if ( defined( 'HHVM_VERSION' ) && PHP_OS === 'Linux' ) {
60 $this->rusageMode = 2; // RUSAGE_THREAD
61 } elseif ( function_exists( 'getrusage' ) ) {
62 $this->rusageMode = 0; // RUSAGE_SELF
67 * Invoke the loop and continue until either:
68 * - a) The condition callback returns neither CONDITION_CONTINUE nor false
69 * - b) The timeout is reached
70 * This a condition callback can return true (stop) or false (continue) for convenience.
71 * In such cases, the halting result of "true" will be converted to CONDITION_REACHED.
73 * If $timeout is 0, then only the condition callback will be called (no busy callbacks),
74 * and this will immediately return CONDITION_FAILED if the condition was not met.
76 * Exceptions in callbacks will be caught and the callback will be swapped with
77 * one that simply rethrows that exception back to the caller when invoked.
79 * @return integer WaitConditionLoop::CONDITION_* constant
80 * @throws \Exception Any error from the condition callback
82 public function invoke() {
83 $elapsed = 0.0; // seconds
84 $sleepUs = 0; // microseconds to sleep each time
86 $finalResult = self::CONDITION_TIMED_OUT;
88 $checkStartTime = $this->getWallTime();
89 // Check if the condition is met yet
90 $realStart = $this->getWallTime();
91 $cpuStart = $this->getCpuTime();
92 $checkResult = call_user_func( $this->condition );
93 $cpu = $this->getCpuTime() - $cpuStart;
94 $real = $this->getWallTime() - $realStart;
95 // Exit if the condition is reached, an error occurs, or this is non-blocking
96 if ( $this->timeout <= 0 ) {
97 $finalResult = $checkResult ? self::CONDITION_REACHED : self::CONDITION_FAILED;
99 } elseif ( (int)$checkResult !== self::CONDITION_CONTINUE ) {
100 if ( is_int( $checkResult ) ) {
101 $finalResult = $checkResult;
103 $finalResult = self::CONDITION_REACHED;
106 } elseif ( $lastCheck ) {
107 break; // timeout reached
109 // Detect if condition callback seems to block or if justs burns CPU
110 $conditionUsesInterrupts = ( $real > 0.100 && $cpu <= $real * .03 );
111 if ( !$this->popAndRunBusyCallback() && !$conditionUsesInterrupts ) {
112 // 10 queries = 10(10+100)/2 ms = 550ms, 14 queries = 1050ms
113 $sleepUs = min( $sleepUs + 10 * 1e3, 1e6 ); // stop incrementing at ~1s
114 $this->usleep( $sleepUs );
116 $checkEndTime = $this->getWallTime();
117 // The max() protects against the clock getting set back
118 $elapsed += max( $checkEndTime - $checkStartTime, 0.010 );
119 // Do not let slow callbacks timeout without checking the condition one more time
120 $lastCheck = ( $elapsed >= $this->timeout );
123 $this->lastWaitTime = $elapsed;
129 * @return float Seconds
131 public function getLastWaitTime() {
132 return $this->lastWaitTime;
136 * @param integer $microseconds
138 protected function usleep( $microseconds ) {
139 usleep( $microseconds );
145 protected function getWallTime() {
146 return microtime( true );
150 * @return float Returns 0.0 if not supported (Windows on PHP < 7)
152 protected function getCpuTime() {
153 if ( $this->rusageMode === null ) {
154 return microtime( true ); // assume worst case (all time is CPU)
157 $ru = getrusage( $this->rusageMode );
158 $time = $ru['ru_utime.tv_sec'] + $ru['ru_utime.tv_usec'] / 1e6;
159 $time += $ru['ru_stime.tv_sec'] + $ru['ru_stime.tv_usec'] / 1e6;
165 * Run one of the callbacks that does work ahead of time for another caller
167 * @return bool Whether a callback was executed
169 private function popAndRunBusyCallback() {
170 if ( $this->busyCallbacks ) {
171 reset( $this->busyCallbacks );
172 $key = key( $this->busyCallbacks );
173 /** @var callable $workCallback */
174 $workCallback =& $this->busyCallbacks[$key];
177 } catch ( \Exception $e ) {
178 $workCallback = function () use ( $e ) {
182 unset( $this->busyCallbacks[$key] ); // consume