diff --git a/README.md b/README.md index 9f3344c..51e2e88 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ For demonstration purposes, this project also has a `testbench` [module ![](./update-monitor.png) ### Add as dependency -Since this library only has 4 small classes, you could just +Since this library only has 3 small classes, you could just copy & paste them into your own project. If you prefer proper dependency management, you can use Jitpack: @@ -71,7 +71,7 @@ repositories { ... dependencies { ... - implementation 'com.github.knokko.update-loop:implementation:v1.0.0' + implementation 'com.github.knokko.update-loop:implementation:v1.0.1' } ``` @@ -89,6 +89,6 @@ dependencies { com.github.knokko.update-loop implementation - v1.0.0 + v1.0.1 ``` \ No newline at end of file diff --git a/implementation/src/main/java/com/github/knokko/update/Reference.java b/implementation/src/main/java/com/github/knokko/update/Reference.java new file mode 100644 index 0000000..0b17621 --- /dev/null +++ b/implementation/src/main/java/com/github/knokko/update/Reference.java @@ -0,0 +1,7 @@ +package com.github.knokko.update; + +class Reference { + + long time = System.nanoTime(); + long counter; +} diff --git a/implementation/src/main/java/com/github/knokko/update/SlidingEntry.java b/implementation/src/main/java/com/github/knokko/update/SlidingEntry.java deleted file mode 100644 index acb038e..0000000 --- a/implementation/src/main/java/com/github/knokko/update/SlidingEntry.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.github.knokko.update; - -class SlidingEntry { - - final int age; - final long value; - - SlidingEntry(int age, long value) { - this.age = age; - this.value = value; - } - - @Override - public boolean equals(Object other) { - if (other instanceof SlidingEntry) { - SlidingEntry entry = (SlidingEntry) other; - return this.age == entry.age && this.value == entry.value; - } else return false; - } - - @Override - public int hashCode() { - return 3 * age - 31 * (int) value; - } - - @Override - public String toString() { - return "(age=" + age + ",value=" + value + ")"; - } -} diff --git a/implementation/src/main/java/com/github/knokko/update/SlidingWindow.java b/implementation/src/main/java/com/github/knokko/update/SlidingWindow.java deleted file mode 100644 index c05d6df..0000000 --- a/implementation/src/main/java/com/github/knokko/update/SlidingWindow.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.github.knokko.update; - -class SlidingWindow { - - final long[] values; - - private int writeIndex = 0; - private int numReadableElements = 0; - - SlidingWindow(int capacity) { - if (capacity <= 0) throw new IllegalArgumentException("Capacity (" + capacity + ") must be positive"); - this.values = new long[capacity]; - } - - synchronized void insert(long value) { - values[writeIndex] = value; - writeIndex = (writeIndex + 1) % values.length; - if (numReadableElements < values.length) numReadableElements += 1; - } - - synchronized SlidingEntry oldest() { - if (numReadableElements == 0) return null; - - int valueIndex = (values.length + writeIndex - numReadableElements) % values.length; - return new SlidingEntry(numReadableElements - 1, values[valueIndex]); - } - - synchronized void forget() { - numReadableElements = 0; - } -} diff --git a/implementation/src/main/java/com/github/knokko/update/UpdateLoop.java b/implementation/src/main/java/com/github/knokko/update/UpdateLoop.java index 29e323c..bd80249 100644 --- a/implementation/src/main/java/com/github/knokko/update/UpdateLoop.java +++ b/implementation/src/main/java/com/github/knokko/update/UpdateLoop.java @@ -2,68 +2,70 @@ import java.util.function.Consumer; -import static java.lang.Math.max; - /** * An `UpdateLoop` tries to ensure that a given (update) function is executed periodically with a given period. */ public class UpdateLoop implements Runnable { - static long determineSleepTime(SlidingWindow window, long currentTime, long period) { - SlidingEntry oldest = window.oldest(); - if (oldest == null) return 0L; - - long updateAt = oldest.value + (oldest.age + 1) * period; - long sleepNanoTime = updateAt - currentTime; - - return sleepNanoTime / 1_000_000; - } - private final Consumer updateFunction; - private final SlidingWindow slidingWindow; private volatile long period; + private volatile long maximumBacklog; + private volatile Reference reference; private volatile boolean shouldContinue = true; private volatile boolean didStart = false; /** * Constructs an UpdateLoop that attempts to execute {@code updateFunction} every {@code initialPeriod} - * nanoseconds. It will keep track of how many updates occurred the last {@code initialPeriod * windowSize} - * nanoseconds and use this information to maintain a stable update rate. + * nanoseconds. It will allow a maximum backlog of {@code initialMaximumBacklog} nanoseconds. Any additional + * backlog will be discarded. The period and maximum backlog can be changed at any time. * @param updateFunction The function that should be called periodically. The parameter will always be this * UpdateLoop, which is convenient for e.g. stopping it. * @param initialPeriod The initial period of the update function, in nanoseconds. - * @param windowSize The number of past updates from which their timestamps will be remembered. This information - * is needed to achieve a robust update rate. + * @param initialMaximumBacklog The initial maximum backlog of the update function, in nanoseconds. */ - public UpdateLoop(Consumer updateFunction, long initialPeriod, int windowSize) { + public UpdateLoop(Consumer updateFunction, long initialPeriod, long initialMaximumBacklog) { + if (updateFunction == null || initialPeriod < 0 || initialMaximumBacklog < 0) { + throw new IllegalArgumentException(); + } this.updateFunction = updateFunction; - this.slidingWindow = new SlidingWindow(windowSize); this.period = initialPeriod; + this.maximumBacklog = initialMaximumBacklog; } /** * Constructs an UpdateLoop that attempts to execute {@code updateFunction} every {@code initialPeriod} - * nanoseconds. It will use a default sliding window of 1 second. + * nanoseconds. It will use a default maximum backlog of 500 milliseconds. * @param updateFunction The function that should be called periodically * @param initialPeriod The initial period of the update function, in nanoseconds */ public UpdateLoop(Consumer updateFunction, long initialPeriod) { - this(updateFunction, initialPeriod, max(4, (int) (1_000_000_000L / initialPeriod))); + this(updateFunction, initialPeriod, 500_000_000L); } /** - * Changes the period of this update loop to {@code newPeriod} nanoseconds. Note that the performance of the - * update loop may degrade if the sliding window is too small. This would for instance happen if the sliding - * window size was determined automatically, and the new period is much smaller than the original period.
+ * Changes the period of this update loop to {@code newPeriod} nanoseconds. Even though this class allows any + * non-negative period, periods below approximately 100 nanoseconds can't be achieved because the + * update loop overhead will become larger than the update period...
* Thread safety: this method can be called from any thread at any time. * @param newPeriod The new period, in nanoseconds */ public void setPeriod(long newPeriod) { - slidingWindow.forget(); + if (newPeriod < 0) throw new IllegalArgumentException(); + reference = new Reference(); period = newPeriod; } + /** + * Changes the maximum backlog of this update loop to {@code newBacklog} nanoseconds.
+ * Thread safety: this method can be called from any thread at any time. + * @param newBacklog The new maximum backlog, in nanoseconds + */ + public void setMaximumBacklog(long newBacklog) { + if (newBacklog < 0) throw new IllegalArgumentException(); + maximumBacklog = newBacklog; + } + /** * Thread safety: this method can be called from any thread at any time. * @return The current period of this update loop, in nanoseconds. @@ -72,6 +74,14 @@ public long getPeriod() { return period; } + /** + * Thread safety: this method can be called from any thread at any time. + * @return The current maximum backlog of this update loop, in nanoseconds. + */ + public long getMaximumBacklog() { + return maximumBacklog; + } + /** * Starts this update loop on a new thread. This function must be called at most once. */ @@ -80,7 +90,8 @@ public void start() { } /** - * Stops this update loop. After invoking this method, the update function will be called at most once. Calling + * Stops this update loop. After invoking this method, the update function will be called at most once. + * If this method is called during the update function, the update function won't be invoked again. Calling * this method more than once has the same effect as calling it once. * Thread safety: this method can be called from any thread at any time. */ @@ -88,6 +99,15 @@ public void stop() { shouldContinue = false; } + private long determineSleepTime(long currentTime) { + long nextUpdateAt = reference.time + reference.counter * period; + long nextSleepTime = nextUpdateAt - currentTime; + + if (-nextSleepTime > maximumBacklog) reference.time += -nextSleepTime - maximumBacklog; + + return nextSleepTime / 1000_000L; + } + /** * Runs this update loop on the current thread. It won't return until the update loop is finished. * This method must be called at most once. @@ -96,12 +116,13 @@ public void stop() { public void run() { if (didStart) throw new IllegalStateException("This update loop has already started"); didStart = true; + reference = new Reference(); outerLoop: while (shouldContinue) { long sleepTime; do { - sleepTime = determineSleepTime(slidingWindow, System.nanoTime(), period); + sleepTime = determineSleepTime(System.nanoTime()); if (sleepTime > 0L) { try { //noinspection BusyWait @@ -112,8 +133,10 @@ public void run() { } } while (sleepTime > 0L); - slidingWindow.insert(System.nanoTime()); - if (shouldContinue) updateFunction.accept(this); + if (shouldContinue) { + updateFunction.accept(this); + reference.counter += 1; + } } } } diff --git a/implementation/src/test/java/com/github/knokko/update/TestSlidingWindow.java b/implementation/src/test/java/com/github/knokko/update/TestSlidingWindow.java deleted file mode 100644 index e8199c9..0000000 --- a/implementation/src/test/java/com/github/knokko/update/TestSlidingWindow.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.github.knokko.update; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -public class TestSlidingWindow { - - @Test - public void testSingleElementWindow() { - SlidingWindow window = new SlidingWindow(1); - assertNull(window.oldest()); - window.insert(5L); - assertEquals(new SlidingEntry(0, 5L), window.oldest()); - window.insert(3L); - assertEquals(new SlidingEntry(0, 3L), window.oldest()); - window.forget(); - assertNull(window.oldest()); - window.insert(8L); - assertEquals(new SlidingEntry(0, 8L), window.oldest()); - window.insert(7L); - assertEquals(new SlidingEntry(0, 7L), window.oldest()); - } - - @Test - public void testTwoElementWindow() { - SlidingWindow window = new SlidingWindow(2); - assertNull(window.oldest()); - window.insert(3L); - assertEquals(new SlidingEntry(0, 3L), window.oldest()); - window.insert(2L); - assertEquals(new SlidingEntry(1, 3L), window.oldest()); - window.insert(5L); - assertEquals(new SlidingEntry(1, 2L), window.oldest()); - - window.forget(); - assertNull(window.oldest()); - window.insert(10L); - assertEquals(new SlidingEntry(0, 10L), window.oldest()); - window.insert(8L); - assertEquals(new SlidingEntry(1, 10L), window.oldest()); - window.insert(6L); - assertEquals(new SlidingEntry(1, 8L), window.oldest()); - } - - @Test - public void testLargerSlidingWindow() { - SlidingWindow window = new SlidingWindow(4); - window.insert(6L); - window.insert(7L); - window.insert(8L); - assertEquals(new SlidingEntry(2, 6L), window.oldest()); - window.insert(9L); - assertEquals(new SlidingEntry(3, 6L), window.oldest()); - window.insert(10L); - assertEquals(new SlidingEntry(3, 7L), window.oldest()); - - window.forget(); - assertNull(window.oldest()); - window.insert(15L); - assertEquals(new SlidingEntry(0, 15L), window.oldest()); - } -} diff --git a/implementation/src/test/java/com/github/knokko/update/TestUpdateLoop.java b/implementation/src/test/java/com/github/knokko/update/TestUpdateLoop.java index 90d8410..4d23da9 100644 --- a/implementation/src/test/java/com/github/knokko/update/TestUpdateLoop.java +++ b/implementation/src/test/java/com/github/knokko/update/TestUpdateLoop.java @@ -4,52 +4,11 @@ import java.util.concurrent.atomic.AtomicInteger; -import static com.github.knokko.update.UpdateLoop.determineSleepTime; import static java.lang.Math.abs; import static org.junit.jupiter.api.Assertions.*; public class TestUpdateLoop { - @Test - public void testDetermineSleepTime() { - assertEquals(0L, determineSleepTime(new SlidingWindow(5), 1234, 10)); - - long m = 1_000_000L; - long period = 10 * m; - SlidingWindow window = new SlidingWindow(4); - - // Oldest entry is (0, 2500) - window.insert(2500 * m); - assertEquals(7, determineSleepTime(window, 2503 * m, period)); - assertEquals(6, determineSleepTime(window, 1 + 2503 * m, period)); - - // Oldest entry is (1, 2500) - window.insert(2510 * m); - assertEquals(17, determineSleepTime(window, 2503 * m, period)); - assertEquals(7, determineSleepTime(window, 2513 * m, period)); - assertTrue(determineSleepTime(window, 2523 * m, period) <= 0); - - // Oldest entry is (2, 2500) - window.insert(2600 * m); - assertEquals(7, determineSleepTime(window, 2523 * m, period)); - assertTrue(determineSleepTime(window, 2550 * m, period) <= 0); - - // Oldest entry is (3, 2500) - window.insert(2601 * m); - assertEquals(37, determineSleepTime(window, 2503 * m, period)); - assertTrue(determineSleepTime(window, 2540 * m, period) <= 0); - - // Oldest entry becomes 2510 - window.insert(2602 * m); - assertEquals(4L, determineSleepTime(window, 2546 * m, period)); - assertTrue(determineSleepTime(window, 1 + 2549 * m, period) <= 0); - assertTrue(determineSleepTime(window, 2615 * m, period) <= 0); - - // Oldest entry becomes 2600 - window.insert(2610 * m); - assertEquals(8L, determineSleepTime(window, 2632 * m, period)); - } - private void testConstantPeriod(long period, long sleepTime) throws InterruptedException { AtomicInteger counter = new AtomicInteger(0); @@ -102,6 +61,26 @@ public void testWithDynamicPeriod() throws InterruptedException { if (abs(finalValue - expectedValue) > 3) assertEquals(expectedValue, finalValue); } + @Test + public void testWithDynamicMaximumBacklog() throws InterruptedException { + AtomicInteger counter = new AtomicInteger(0); + UpdateLoop updater = new UpdateLoop(loop -> counter.incrementAndGet(), 1_000_000L); + + updater.start(); + Thread.sleep(500); + + updater.setMaximumBacklog(1_000_000_000L); + int midValue = counter.get(); + + Thread.sleep(500); + updater.stop(); + + int finalValue = counter.get(); + int expectedValue = midValue + 500; + + if (abs(finalValue - expectedValue) > 150) assertEquals(expectedValue, finalValue); + } + @Test public void testCannotStartTwice() throws InterruptedException { UpdateLoop updateLoop = new UpdateLoop(loop -> {}, 100_000_000L); diff --git a/testbench/src/main/java/com/github/knokko/update/UpdateMonitor.java b/testbench/src/main/java/com/github/knokko/update/UpdateMonitor.java index b2f3a72..e29cf6a 100644 --- a/testbench/src/main/java/com/github/knokko/update/UpdateMonitor.java +++ b/testbench/src/main/java/com/github/knokko/update/UpdateMonitor.java @@ -6,6 +6,7 @@ import java.awt.event.KeyListener; import java.util.Random; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import static java.awt.event.KeyEvent.*; import static java.lang.Math.sqrt; @@ -14,15 +15,13 @@ public class UpdateMonitor extends JFrame implements KeyListener { public static void main(String[] args) { - int windowSize = 100_000; - UpdateMonitor monitor = new UpdateMonitor(); monitor.setDefaultCloseOperation(DISPOSE_ON_CLOSE); - monitor.setSize(1400, 600); + monitor.setSize(1400, 800); monitor.setVisible(true); monitor.addKeyListener(monitor); - UpdateLoop updateLoop = new UpdateLoop(monitor::updateFunction, 20_000_000L, windowSize); + UpdateLoop updateLoop = new UpdateLoop(monitor::updateFunction, 20_000_000L); updateLoop.start(); new UpdateLoop(paintLoop -> { @@ -42,9 +41,9 @@ public static void main(String[] args) { private UpdateLoop updateLoop; - private final int[] startCounterHistory = new int[300]; private final int[] finishCounterHistory = new int[startCounterHistory.length]; + private final int[] updateDelayHistory = new int[startCounterHistory.length]; private volatile int executionTime = 3; @@ -54,8 +53,14 @@ public static void main(String[] args) { private final Random rng = new Random(); + private long lastStartUpdateTime = System.nanoTime(); + private final AtomicLong lastUpdateDelay = new AtomicLong(0); private void updateFunction(UpdateLoop updater) { + long startUpdateTime = System.nanoTime(); + lastUpdateDelay.set(startUpdateTime - lastStartUpdateTime); + lastStartUpdateTime = startUpdateTime; + startUpdateCounter.incrementAndGet(); //noinspection StatementWithEmptyBody while (spaceDown) ; @@ -88,13 +93,15 @@ public void paint(Graphics g) { int finishValue = finishUpdateCounter.getAndSet(0); System.arraycopy(startCounterHistory, 0, startCounterHistory, 1, length - 1); System.arraycopy(finishCounterHistory, 0, finishCounterHistory, 1, length - 1); + System.arraycopy(updateDelayHistory, 0, updateDelayHistory, 1, length - 1); startCounterHistory[0] = startValue; finishCounterHistory[0] = finishValue; + updateDelayHistory[0] = (int) (5 * lastUpdateDelay.getAndSet(0) / 1000_000L); int baseWidth = 4; int baseHeight = 40; int offsetX = 10 + baseWidth; - int offsetY = height - 200; + int offsetY = height - 400; g.setColor(new Color(0, 150, 255)); for (int index = 0; index < length; index++) { @@ -112,6 +119,15 @@ public void paint(Graphics g) { } } + offsetY += 350; + g.setColor(new Color(250, 150, 0)); + for (int index = 0; index < length; index++) { + int barHeight = updateDelayHistory[index]; + if (barHeight > 0) { + g.fillRect(width - offsetX - baseWidth * index, offsetY - barHeight, baseWidth, barHeight); + } + } + g.setColor(Color.BLACK); g.setFont(new Font("TimesRoman", Font.PLAIN, 20)); g.drawString("Updates per second = " + updateCounter.getValue(), 15, 80); @@ -125,14 +141,17 @@ public void paint(Graphics g) { g.drawString("Execution time = " + executionTime + "ms", 15, 200); g.drawString("Spike time = " + spikeTime + "ms", 15, 230); g.drawString("Spike probability = " + spikeProbability + "%", 15, 260); + g.drawString("Maximum backlog = " + updateLoop.getMaximumBacklog() + "ns", 15, 290); g.drawString("Use left/right arrow key to change the period", 600, 80); g.drawString("Use the a/d keys to change the execution time", 600, 110); g.drawString("Use shift + left/right arrow key to change the spike time", 600, 140); g.drawString("Use shift + a/d keys to change the spike chance", 600, 170); g.drawString("Hold the space bar to block the current or next update", 600, 200); - g.drawString("The blue staves indicate how many updates started between each frame", 600, 230); - g.drawString("The pink staves indicate how many updates finished between each frame", 600, 260); + g.drawString("Use the e/q keys to change the maximum backlog", 600, 230); + g.drawString("The blue staves indicate how many updates started between each frame", 600, 260); + g.drawString("The pink staves indicate how many updates finished between each frame", 600, 290); + g.drawString("The orange staves indicate the difference between the start times of updates", 600, 320); Toolkit.getDefaultToolkit().sync(); } @@ -145,16 +164,19 @@ public void keyTyped(KeyEvent keyEvent) {} public void keyPressed(KeyEvent keyEvent) { int code = keyEvent.getKeyCode(); long period = updateLoop.getPeriod(); + long maximumBacklog = updateLoop.getMaximumBacklog(); if (keyEvent.isShiftDown()) { if (code == VK_LEFT && spikeTime > 0) spikeTime -= 10; if (code == VK_RIGHT) spikeTime += 10; if (code == VK_A && spikeProbability > 0) spikeProbability -= 1; if (code == VK_D && spikeProbability < 100) spikeProbability += 1; } else { - if (code == VK_LEFT && period > 100_000) updateLoop.setPeriod(period / 2); + if (code == VK_LEFT && period > 10) updateLoop.setPeriod(period / 2); if (code == VK_RIGHT && period < 100_000_000_000L) updateLoop.setPeriod(period * 2); if (code == VK_A && executionTime > 0) executionTime -= 1; if (code == VK_D) executionTime += 1; + if (code == VK_Q && maximumBacklog > 100) updateLoop.setMaximumBacklog(maximumBacklog / 2); + if (code == VK_E && maximumBacklog < 100_000_000_000L) updateLoop.setMaximumBacklog(maximumBacklog * 2); } if (code == VK_SPACE) spaceDown = true; } diff --git a/update-loops-overview.md b/update-loops-overview.md index 30b936a..d872d9d 100644 --- a/update-loops-overview.md +++ b/update-loops-overview.md @@ -115,7 +115,7 @@ the update function to be called 100 times per second for 2 minutes long (rather than the desired update frequency of 20 times). -## Sliding window +## Limit maximum backlog Whether this behavior is desired, depends on the application. For games, it probably isn't (imagine all enemies suddenly running 5 times as fast @@ -126,25 +126,16 @@ you should use an approach that is more robust than the naive sleep approach, but is not as desperate as the smarter sleeping approach. -For instance, instead of counting the number of updates -since the start of the application, you could count the -number of updates the past second. When a single update -invocation takes too long, the system will simply cancel -or shorten the `sleepTime` of the next iteration (and -possibly affect some more iterations). When the execution -time of the update function is consistently larger than -the desired update period, the system won't sleep -because the number of updates per second is always smaller -than desired. When the execution time of the update -function suddenly drops, the system will try to compensate -for the *missed updates during the last second* rather -than all missed updates during the peak hours. Thus the -system will continue normal operation after at most 1 -second. +The lagg problem of the *smarter sleeping* approach can +be solved by limiting the maximum backlog: when the +backlog is larger than some constant, any additional +backlog should be discarded. This can be implemented by +increasing the `startTime` by the additional backlog. ## The `UpdateLoop` class -The `UpdateLoop` class of this library uses a sliding -window to track the number of updates that happened -during the past N updates. Both the update period and the -length of the sliding window are configurable. Together, -they determine how much time it 'looks back' \ No newline at end of file +The `UpdateLoop` class of this library implements the +*smarter sleeping* approach with a maximum backlog. +It is slightly more complicated because it also allows +the period and maximum backlog to be changed at any time. +Instead of trying to implement all this logic yourself, +you can just use `UpdateLoop`. \ No newline at end of file diff --git a/update-monitor.png b/update-monitor.png index 0d91cff..e4570b4 100644 Binary files a/update-monitor.png and b/update-monitor.png differ