This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new e5f7f1b56 Add optional FIFO fairness to TimedSemaphore (LANG-1790)
(#1840)
e5f7f1b56 is described below
commit e5f7f1b56776fb2f6a77fc3a64bc47f7e849a425
Author: Gary Gregory <[email protected]>
AuthorDate: Sun Oct 25 13:57:36 2026 -0400
Add optional FIFO fairness to TimedSemaphore (LANG-1790) (#1840)
Add Builder.setFair(boolean) and queue blocking acquisitions in FIFO
order
when fairness is enabled. Preserve the default non-fair behavior and the
existing tryAcquire() API.
Keep permit accounting synchronized, remove queued acquisitions on
interruption or shutdown, and allow new unlimited acquisitions to
proceed
without waiting. Share the wait-and-shutdown check between acquisition
paths.
Document fairness guarantees and exceptions, and add regression coverage
for ordering, cancellation, limit changes, and builder reuse.
---
src/changes/changes.xml | 1 +
.../commons/lang3/concurrent/TimedSemaphore.java | 73 +++-
.../lang3/concurrent/TimedSemaphoreTest.java | 367 +++++++++++++++++++++
3 files changed, 433 insertions(+), 8 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index c2079f682..a0c460ef9 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -78,6 +78,7 @@ The <action> type attribute can be add,update,fix,remove.
<action type="fix" dev="ggregory" due-to="alhuda, Gary
Gregory">Restrict LocaleUtils.toLocale to ASCII language, country, and numeric
area codes; previously accepted non-ASCII codes now cause
IllegalArgumentException (#1838).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">Deprecate ExceptionUtils.removeCommonFrames(), which is now used only
by tests. Root-cause stack trace generation compares StackTraceElement objects
directly.</action>
<!-- ADD -->
+ <action issue="LANG-1790" type="add" dev="ggregory">Add optional FIFO
ordering for TimedSemaphore blocking acquisitions.</action>
<!-- UPDATE -->
<action type="update" dev="ggregory" due-to="Gary Gregory">Bump
org.apache.commons:commons-parent from 105 to 106.</action>
</release>
diff --git
a/src/main/java/org/apache/commons/lang3/concurrent/TimedSemaphore.java
b/src/main/java/org/apache/commons/lang3/concurrent/TimedSemaphore.java
index 6567cf35f..232be52ff 100644
--- a/src/main/java/org/apache/commons/lang3/concurrent/TimedSemaphore.java
+++ b/src/main/java/org/apache/commons/lang3/concurrent/TimedSemaphore.java
@@ -17,6 +17,8 @@
package org.apache.commons.lang3.concurrent;
+import java.util.ArrayDeque;
+import java.util.Deque;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
@@ -123,6 +125,7 @@ public static class Builder implements
Supplier<TimedSemaphore> {
private long period;
private TimeUnit timeUnit;
private int limit;
+ private boolean fair;
/**
* Constructs a new Builder.
@@ -136,6 +139,24 @@ public TimedSemaphore get() {
return new TimedSemaphore(this);
}
+ /**
+ * Sets whether blocking acquisitions use FIFO ordering.
+ *
+ * <p>
+ * Fairness is disabled by default. When enabled, calls to {@link
TimedSemaphore#acquire()} are ordered by entry into the internal waiting queue,
+ * not by thread start time or method return order. New calls made
with the limit disabled and calls to {@link TimedSemaphore#tryAcquire()} may
+ * bypass waiting callers.
+ * </p>
+ *
+ * @param fair Whether to enable FIFO ordering.
+ * @return {@code this} instance.
+ * @since 3.22.0
+ */
+ public Builder setFair(final boolean fair) {
+ this.fair = fair;
+ return this;
+ }
+
/**
* Sets the limit.
*
@@ -235,8 +256,15 @@ public static Builder builder() {
/** A flag whether shutdown() was called. */
private boolean shutdown; // @GuardedBy("this")
+ /** Whether blocking acquisitions use FIFO ordering. */
+ private final boolean fair;
+
+ /** Tokens for fair acquisitions, guarded by this object's monitor. */
+ private final Deque<Object> waiters = new ArrayDeque<>();
+
private TimedSemaphore(final Builder builder) {
Validate.inclusiveBetween(1, Long.MAX_VALUE, builder.period, "Time
period must be greater than 0.");
+ fair = builder.fair;
period = builder.period;
unit = builder.timeUnit;
if (builder.service != null) {
@@ -287,21 +315,45 @@ public TimedSemaphore(final ScheduledExecutorService
service, final long timePer
* already been invoked, calling this method will cause an exception. The
very first call of this method starts the timer task which monitors the time
* period set for this {@link TimedSemaphore}. From now on the semaphore
is active.
*
+ * <p>
+ * When fairness is enabled, waiting callers acquire permits in the order
they enter the internal queue. This does not guarantee thread start or
+ * method return order. New calls made with the limit disabled do not wait
for queued callers. Calls to {@link #tryAcquire()} may also bypass
+ * queued callers.
+ * </p>
+ *
* @throws InterruptedException Thrown if the thread gets interrupted.
* @throws IllegalStateException Thrown if this semaphore is already shut
down.
*/
public synchronized void acquire() throws InterruptedException {
prepareAcquire();
- boolean canPass;
- do {
- canPass = acquirePermit();
- if (!canPass) {
- wait();
- if (shutdown) {
- throw new IllegalStateException("TimedSemaphore is shut
down.");
+ if (fair && getLimit() > NO_LIMIT) {
+ // A private token avoids invoking an overridden Thread.equals()
during cancellation.
+ final Object waiter = new Object();
+ waiters.addLast(waiter);
+ try {
+ while (waiters.peekFirst() != waiter || !acquirePermit()) {
+ wait();
+ if (shutdown) {
+ throw new IllegalStateException("TimedSemaphore is
shut down.");
+ }
}
+ } finally {
+ waiters.removeFirstOccurrence(waiter);
+ // Let the next caller use any permits remaining in this
period, including after interruption.
+ notifyAll();
}
- } while (!canPass);
+ } else {
+ boolean canPass;
+ do {
+ canPass = acquirePermit();
+ if (!canPass) {
+ wait();
+ if (shutdown) {
+ throw new IllegalStateException("TimedSemaphore is
shut down.");
+ }
+ }
+ } while (!canPass);
+ }
}
/**
@@ -475,6 +527,11 @@ protected ScheduledFuture<?> startTimer() {
* Tries to acquire a permit from this semaphore. If the limit of this
semaphore has not yet been reached, a permit is acquired, and this method
returns
* <strong>true</strong>. Otherwise, this method returns immediately with
the result <strong>false</strong>.
*
+ * <p>
+ * This method does not honor the fairness setting: it may acquire a
permit ahead of waiting callers. It does not test or clear the calling thread's
+ * interrupted status.
+ * </p>
+ *
* @return <strong>true</strong> if a permit could be acquired;
<strong>false</strong> otherwise.
* @throws IllegalStateException Thrown if this semaphore is already shut
down.
* @since 3.5
diff --git
a/src/test/java/org/apache/commons/lang3/concurrent/TimedSemaphoreTest.java
b/src/test/java/org/apache/commons/lang3/concurrent/TimedSemaphoreTest.java
index aab586fd7..88516ab91 100644
--- a/src/test/java/org/apache/commons/lang3/concurrent/TimedSemaphoreTest.java
+++ b/src/test/java/org/apache/commons/lang3/concurrent/TimedSemaphoreTest.java
@@ -19,13 +19,18 @@
import static
org.apache.commons.lang3.LangAssertions.assertIllegalArgumentException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
+import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
@@ -41,6 +46,46 @@
*/
class TimedSemaphoreTest extends AbstractLangTest {
+ private static class FairAcquirer extends Thread {
+ private final TimedSemaphore semaphore;
+ private final BlockingQueue<Integer> order;
+ private final int id;
+ private volatile Exception failure;
+
+ FairAcquirer(final TimedSemaphore semaphore, final
BlockingQueue<Integer> order, final int id) {
+ this.semaphore = semaphore;
+ this.order = order;
+ this.id = id;
+ setDaemon(true);
+ }
+
+ @Override
+ public void run() {
+ try {
+ semaphore.acquire();
+ order.add(id);
+ } catch (final InterruptedException | IllegalStateException ex) {
+ failure = ex;
+ }
+ }
+ }
+
+ private static final class EqualFairAcquirer extends FairAcquirer {
+ EqualFairAcquirer(final TimedSemaphore semaphore, final
BlockingQueue<Integer> order, final int id) {
+ super(semaphore, order, id);
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ return other instanceof EqualFairAcquirer;
+ }
+
+ @Override
+ public int hashCode() {
+ return 1;
+ }
+ }
+
/**
* A test thread class that will be used by tests for triggering the
* semaphore. The thread calls the semaphore a configurable number of
times.
@@ -196,6 +241,54 @@ public void run() {
/** Constant for the default limit. */
private static final int LIMIT = 10;
+ private static void assertAcquireOrder(final TimedSemaphore semaphore,
final boolean fair) throws Exception {
+ final BlockingQueue<Integer> order = new LinkedBlockingQueue<>();
+ final FairAcquirer first = new FairAcquirer(semaphore, order, 1);
+ final FutureTask<Void> newcomerTask = new FutureTask<>(() -> {
+ synchronized (semaphore) {
+ semaphore.endOfPeriod();
+ // Retain the monitor so the older caller cannot run before
this acquisition attempt.
+ semaphore.acquire();
+ order.add(2);
+ }
+ return null;
+ });
+ final Thread newcomer = new Thread(newcomerTask);
+ newcomer.setDaemon(true);
+ try {
+ semaphore.acquire();
+ first.start();
+ awaitWaiting(first);
+ newcomer.start();
+ assertEquals(Integer.valueOf(fair ? 1 : 2), order.poll(5,
TimeUnit.SECONDS));
+ assertEquals(1, semaphore.getAcquireCount());
+ semaphore.endOfPeriod();
+ newcomerTask.get(5, TimeUnit.SECONDS);
+ assertEquals(Integer.valueOf(fair ? 2 : 1), order.poll(5,
TimeUnit.SECONDS));
+ assertNull(first.failure);
+ } finally {
+ newcomer.interrupt();
+ try {
+ stopAcquirers(semaphore, first);
+ } finally {
+ newcomer.join(5000);
+ assertFalse(newcomer.isAlive());
+ }
+ }
+ }
+
+ private static void awaitWaiting(final Thread thread) throws
InterruptedException {
+ final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (thread.getState() != Thread.State.WAITING && thread.isAlive()
&& System.nanoTime() < deadline) {
+ Thread.sleep(1);
+ }
+ assertEquals(Thread.State.WAITING, thread.getState(), "Caller must be
queued before the test proceeds");
+ }
+
+ private static TimedSemaphore newFairSemaphore(final int limit) {
+ return
TimedSemaphore.builder().setPeriod(1).setTimeUnit(TimeUnit.DAYS).setLimit(limit).setFair(true).get();
+ }
+
/**
* Prepares an executor service mock to expect the start of the timer.
*
@@ -208,6 +301,15 @@ private void prepareStartTimer(final
ScheduledExecutorService service,
EasyMock.expectLastCall().andReturn(future);
}
+ private static void stopAcquirers(final TimedSemaphore semaphore, final
FairAcquirer... threads) throws InterruptedException {
+ semaphore.shutdown();
+ for (final FairAcquirer thread : threads) {
+ thread.interrupt();
+ thread.join(5000);
+ assertFalse(thread.isAlive(), "Acquirer did not terminate");
+ }
+ }
+
/**
* Tests the acquire() method if a limit is set.
*
@@ -315,6 +417,264 @@ void testAcquireNoLimit() throws InterruptedException {
EasyMock.verify(service, future);
}
+ @Test
+ void testBuilderReusePreservesFairness() throws Exception {
+ final TimedSemaphore.Builder builder =
TimedSemaphore.builder().setPeriod(1).setTimeUnit(TimeUnit.DAYS).setLimit(1).setFair(true);
+ final TimedSemaphore fairSemaphore = builder.get();
+ final TimedSemaphore nonFairSemaphore = builder.setFair(false).get();
+ try {
+ // Check each instance while the builder has the opposite setting.
+ assertAcquireOrder(fairSemaphore, true);
+ builder.setFair(true);
+ assertAcquireOrder(nonFairSemaphore, false);
+ } finally {
+ fairSemaphore.shutdown();
+ nonFairSemaphore.shutdown();
+ }
+ }
+
+ @Test
+ void testFairAcquireDisabledLimitDoesNotQueueNewCallers() throws
InterruptedException {
+ final TimedSemaphore semaphore = newFairSemaphore(1);
+ final BlockingQueue<Integer> order = new LinkedBlockingQueue<>();
+ final FairAcquirer waiting = new FairAcquirer(semaphore, order, 1);
+ final FairAcquirer newcomer = new FairAcquirer(semaphore, order, 2);
+ try {
+ semaphore.acquire();
+ waiting.start();
+ awaitWaiting(waiting);
+ semaphore.setLimit(0);
+ newcomer.start();
+ newcomer.join(5000);
+ assertFalse(newcomer.isAlive(), "Unlimited acquisitions must not
wait for the next period");
+ assertNull(newcomer.failure);
+ assertTrue(order.contains(2));
+ } finally {
+ stopAcquirers(semaphore, waiting, newcomer);
+ }
+ }
+
+ @Test
+ void testFairAcquireDisablingLimitReleasesWaitersAtRollover() throws
InterruptedException {
+ final TimedSemaphore semaphore = newFairSemaphore(1);
+ final BlockingQueue<Integer> order = new LinkedBlockingQueue<>();
+ final FairAcquirer first = new FairAcquirer(semaphore, order, 1);
+ final FairAcquirer second = new FairAcquirer(semaphore, order, 2);
+ try {
+ semaphore.acquire();
+ first.start();
+ awaitWaiting(first);
+ second.start();
+ awaitWaiting(second);
+ semaphore.setLimit(0);
+ semaphore.endOfPeriod();
+ assertNotNull(order.poll(5, TimeUnit.SECONDS));
+ assertNotNull(order.poll(5, TimeUnit.SECONDS));
+ assertNull(first.failure);
+ assertNull(second.failure);
+ assertEquals(2, semaphore.getAcquireCount());
+ } finally {
+ stopAcquirers(semaphore, first, second);
+ }
+ }
+
+ @Test
+ void testFairAcquireInterruptedHeadReleasesNextWaiter() throws
InterruptedException {
+ final TimedSemaphore semaphore = newFairSemaphore(1);
+ final BlockingQueue<Integer> order = new LinkedBlockingQueue<>();
+ final FairAcquirer first = new FairAcquirer(semaphore, order, 1);
+ final FairAcquirer second = new FairAcquirer(semaphore, order, 2);
+ try {
+ semaphore.acquire();
+ first.start();
+ awaitWaiting(first);
+ second.start();
+ awaitWaiting(second);
+ synchronized (semaphore) {
+ semaphore.setLimit(2);
+ first.interrupt();
+ }
+ first.join(5000);
+ assertFalse(first.isAlive());
+ assertInstanceOf(InterruptedException.class, first.failure);
+ assertEquals(Integer.valueOf(2), order.poll(5, TimeUnit.SECONDS));
+ assertNull(second.failure);
+ assertEquals(2, semaphore.getAcquireCount());
+ } finally {
+ stopAcquirers(semaphore, first, second);
+ }
+ }
+
+ @Test
+ void testFairAcquireInterruptedMiddlePreservesQueue() throws
InterruptedException {
+ final TimedSemaphore semaphore = newFairSemaphore(1);
+ final BlockingQueue<Integer> order = new LinkedBlockingQueue<>();
+ // Distinct callers must remain distinct even if their Thread
subclasses compare equal.
+ final FairAcquirer first = new EqualFairAcquirer(semaphore, order, 1);
+ final FairAcquirer second = new EqualFairAcquirer(semaphore, order, 2);
+ final FairAcquirer third = new EqualFairAcquirer(semaphore, order, 3);
+ try {
+ semaphore.acquire();
+ first.start();
+ awaitWaiting(first);
+ second.start();
+ awaitWaiting(second);
+ third.start();
+ awaitWaiting(third);
+ second.interrupt();
+ second.join(5000);
+ assertFalse(second.isAlive());
+ assertInstanceOf(InterruptedException.class, second.failure);
+ semaphore.endOfPeriod();
+ assertEquals(Integer.valueOf(1), order.poll(5, TimeUnit.SECONDS));
+ semaphore.endOfPeriod();
+ assertEquals(Integer.valueOf(3), order.poll(5, TimeUnit.SECONDS));
+ assertNull(first.failure);
+ assertNull(third.failure);
+ assertEquals(1, semaphore.getAcquireCount());
+ } finally {
+ stopAcquirers(semaphore, first, second, third);
+ }
+ }
+
+ @Test
+ void testFairAcquireNewcomerCannotBypassQueuedCaller() throws Exception {
+ assertAcquireOrder(newFairSemaphore(1), true);
+ }
+
+ @Test
+ void testFairAcquireOrderAcrossPeriods() throws InterruptedException {
+ final TimedSemaphore semaphore = newFairSemaphore(1);
+ final BlockingQueue<Integer> order = new LinkedBlockingQueue<>();
+ final FairAcquirer first = new FairAcquirer(semaphore, order, 1);
+ final FairAcquirer second = new FairAcquirer(semaphore, order, 2);
+ final FairAcquirer third = new FairAcquirer(semaphore, order, 3);
+ try {
+ semaphore.acquire();
+ first.start();
+ awaitWaiting(first);
+ second.start();
+ awaitWaiting(second);
+ third.start();
+ awaitWaiting(third);
+ for (int expected = 1; expected <= 3; expected++) {
+ semaphore.endOfPeriod();
+ assertEquals(Integer.valueOf(expected), order.poll(5,
TimeUnit.SECONDS));
+ assertEquals(1, semaphore.getAcquireCount());
+ assertEquals(0, semaphore.getAvailablePermits());
+ }
+ assertNull(first.failure);
+ assertNull(second.failure);
+ assertNull(third.failure);
+ } finally {
+ stopAcquirers(semaphore, first, second, third);
+ }
+ }
+
+ @Test
+ void testFairAcquireReleasesMultipleWaiters() throws InterruptedException {
+ final TimedSemaphore semaphore = newFairSemaphore(1);
+ final BlockingQueue<Integer> order = new LinkedBlockingQueue<>();
+ final FairAcquirer first = new FairAcquirer(semaphore, order, 1);
+ final FairAcquirer second = new FairAcquirer(semaphore, order, 2);
+ try {
+ semaphore.acquire();
+ first.start();
+ awaitWaiting(first);
+ second.start();
+ awaitWaiting(second);
+ semaphore.setLimit(2);
+ semaphore.endOfPeriod();
+ assertNotNull(order.poll(5, TimeUnit.SECONDS));
+ assertNotNull(order.poll(5, TimeUnit.SECONDS));
+ assertEquals(2, semaphore.getAcquireCount());
+ assertNull(first.failure);
+ assertNull(second.failure);
+ } finally {
+ stopAcquirers(semaphore, first, second);
+ }
+ }
+
+ @Test
+ void testFairAcquireShutdownReleasesWaiters() throws InterruptedException {
+ final TimedSemaphore semaphore = newFairSemaphore(1);
+ final BlockingQueue<Integer> order = new LinkedBlockingQueue<>();
+ final FairAcquirer first = new FairAcquirer(semaphore, order, 1);
+ final FairAcquirer second = new FairAcquirer(semaphore, order, 2);
+ try {
+ semaphore.acquire();
+ first.start();
+ awaitWaiting(first);
+ second.start();
+ awaitWaiting(second);
+ semaphore.shutdown();
+ first.join(5000);
+ second.join(5000);
+ assertFalse(first.isAlive());
+ assertFalse(second.isAlive());
+ assertInstanceOf(IllegalStateException.class, first.failure);
+ assertInstanceOf(IllegalStateException.class, second.failure);
+ assertTrue(order.isEmpty());
+ } finally {
+ stopAcquirers(semaphore, first, second);
+ }
+ }
+
+ @Test
+ void testFairLimitChangesPreserveAccounting() throws InterruptedException {
+ final TimedSemaphore semaphore = newFairSemaphore(3);
+ try {
+ semaphore.acquire();
+ assertTrue(semaphore.tryAcquire());
+ semaphore.setLimit(1);
+ assertFalse(semaphore.tryAcquire());
+ semaphore.setLimit(3);
+ assertEquals(1, semaphore.getAvailablePermits());
+ assertTrue(semaphore.tryAcquire());
+ semaphore.setLimit(3);
+ assertFalse(semaphore.tryAcquire());
+ semaphore.endOfPeriod();
+ assertEquals(3, semaphore.getLastAcquiresPerPeriod());
+ assertEquals(3, semaphore.getAvailablePermits());
+ assertEquals(0, semaphore.getAcquireCount());
+ semaphore.setLimit(-1);
+ for (int i = 0; i < 10; i++) {
+ semaphore.acquire();
+ }
+ assertEquals(10, semaphore.getAcquireCount());
+ } finally {
+ semaphore.shutdown();
+ }
+ }
+
+ @Test
+ void testFairTryAcquireCanBypassWaitersWithoutClearingInterrupt() throws
InterruptedException {
+ final TimedSemaphore semaphore = newFairSemaphore(1);
+ final BlockingQueue<Integer> order = new LinkedBlockingQueue<>();
+ final FairAcquirer waiter = new FairAcquirer(semaphore, order, 1);
+ try {
+ semaphore.acquire();
+ waiter.start();
+ awaitWaiting(waiter);
+ synchronized (semaphore) {
+ semaphore.endOfPeriod();
+ Thread.currentThread().interrupt();
+ try {
+ assertTrue(semaphore.tryAcquire());
+ assertTrue(Thread.currentThread().isInterrupted());
+ assertFalse(semaphore.tryAcquire());
+ } finally {
+ Thread.interrupted();
+ }
+ }
+ semaphore.endOfPeriod();
+ assertEquals(Integer.valueOf(1), order.poll(5, TimeUnit.SECONDS));
+ assertNull(waiter.failure);
+ } finally {
+ stopAcquirers(semaphore, waiter);
+ }
+ }
+
/**
* Tests whether the available non-blocking calls can be queried.
*
@@ -399,6 +759,12 @@ void testInitInvalidPeriod() {
assertIllegalArgumentException(() -> new TimedSemaphore(0L, UNIT,
LIMIT));
}
+ @Test
+ void testNonFairAcquireNewcomerCanBypassQueuedCaller() throws Exception {
+ final TimedSemaphore semaphore =
TimedSemaphore.builder().setPeriod(1).setTimeUnit(TimeUnit.DAYS).setLimit(1).setFair(false).get();
+ assertAcquireOrder(semaphore, false);
+ }
+
/**
* Tries to call acquire() after shutdown(). This should cause an
exception.
*/
@@ -588,4 +954,5 @@ void testTryAcquireAfterShutdown() {
semaphore.shutdown();
assertThrows(IllegalStateException.class, semaphore::tryAcquire);
}
+
}