blob: 2387c478780390fe2ab49e113fc207e07c49771f [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.commons.lang3.concurrent;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
/**
* AtomicSafeInitializer.get() spins in a while-loop without Thread.yield() or LockSupport.parkNanos() when the CAS fails (another thread is initializing).
*
* <p>
* Concurrent callers who lose the CAS busy-wait for the duration of initialize(), burning CPU proportional to init latency * thread count. A slow initializer
* combined with many concurrent callers use more CPU than it can.
* </p>
*
* <p>
* This test measures CPU time spent in spinning threads during a 100 ms init. Pre-patch: spinning threads consume significant CPU. Post-patch: spinning threads
* yield, keeping CPU near zero while waiting.
* </p>
*/
class AtomicSafeInitializerInitTest {
/** Slow initializer: sleeps 100 ms to widen the spin window. */
private static final int INIT_MS = 100;
private static final int SPINNER_THREADS = 8;
private static long threadCpuTimeNanos() {
final ThreadMXBean mx = ManagementFactory.getThreadMXBean();
return mx.isCurrentThreadCpuTimeSupported() ? mx.getCurrentThreadCpuTime() : 0;
}
@Test
void testSpinningThreadsYieldDuringSlowInit() throws Exception {
final CountDownLatch startLatch = new CountDownLatch(1);
final AtomicLong totalCpuNanos = new AtomicLong();
final AtomicSafeInitializer<String> initializer = AtomicSafeInitializer.<String>builder().setInitializer(() -> {
Thread.sleep(INIT_MS);
return "done";
}).get();
final ExecutorService exec = Executors.newFixedThreadPool(SPINNER_THREADS + 1);
try {
final List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < SPINNER_THREADS; i++) {
futures.add(exec.submit(() -> {
try {
startLatch.await();
final long cpuBeforeNanos = threadCpuTimeNanos();
initializer.get();
totalCpuNanos.addAndGet(threadCpuTimeNanos() - cpuBeforeNanos);
} catch (final Exception e) {
Thread.currentThread().interrupt();
}
}));
}
startLatch.countDown();
for (final Future<?> f : futures) {
f.get();
}
} finally {
exec.shutdown();
}
assertNotNull(initializer.get());
// Post-patch: CPU consumed by spinner threads during 100 ms init must be
// significantly less than INIT_MS per thread. We allow 50 ms total CPU
// across all spinner threads (vs. ~800 ms if spinning at 100%).
// This threshold is conservative, a yielding implementation uses ~0 ms.
final long cpuMs = totalCpuNanos.get() / 1_000_000;
// Re-express as a blocking assertion: if cpuMs > threshold, fail.
assertTimeout(Duration.ofMillis(INIT_MS * SPINNER_THREADS / 4), () -> assertFalse(cpuMs > INIT_MS * SPINNER_THREADS / 4,
() -> "Spinner threads consumed " + cpuMs + " ms CPU during " + INIT_MS + " ms init — missing Thread.yield() in get() spin loop"));
}
}