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