Привет, сегодня поговорим о тонкостях реализации холостых циклов (холостого ожидания) в Java. Эта задача встречается нечасто: за девять с небольшим лет работы я столкнулся с ней лишь пару раз. Тем не менее, тема видится интересной и по ней есть что сказать, так что добро пожаловать! Исходный код примеров доступен здесь.
Начнём с определения. На мой вкус русское "холостой цикл" (или "холостое ожидание") интуитивно понятнее и точнее передаёт суть явления, чем английское "busy waiting" или "busy loop" — всё-таки в холостых циклах мы ничего не делаем в смысле логики приложения и относительно текущего потока исполнения. Определения "spinning" или "spin loop" подходят лучше, правда, и они несколько размыты.
Смысл холостого цикла состоит в ожидании события, до наступления которого текущий поток не может двигаться дальше. Основное отличие от блокировки (основанной на synchronized
иди *Lock
-ах) заключается в том, что ожидающий поток не переходит в состояние Thread.State.BLOCKED
, а скорее нарезает круги подобно самолёту, ожидающему разрешения на посадку.
Обратите внимание: сегодня с распространением реактивщины, корутин, легковесных потоков и прочих CompletableFuture
область явно реализованных ожиданий постепенно сужается (даже в тестах православно использовать awaitility), но не исчезает окончательно. Так в JDK (не считая тестов) метод Thread.onSpinWait()
(его и не только мы рассмотрим в этой статье) используется в ForkJoinPool
, Phaser
, StampedLock
, AbstractQueued*Synchronizer
, т. е. в примитивах многопоточности. Об этом немного поговорим ниже.
Азы
Простейшая реализация холостого ожидания (что называется "в лоб"):
volatile boolean wait = true; // значение выставляется из другого потока
while (wait) {
}
Запустив этот код мы очень быстро услышим недовольный гул крыльчатки охлаждения процессора: такой цикл будет пожирать мощность как не в себя.
Очевидно, это не то, что мы хотим видеть в своих приложениях (особенно если разработка ведётся для мобильных устройств), поэтому наиболее распространённая в силу своего почтенного возраста реализация холостого цикла выглядит так:
long delay = 1L;
volatile boolean wait = true;
while (wait) {
Thread.sleep(delay);
}
Это классический подход, наверняка вы хоть пару раз видели что-нибудь похожее. У этого подхода есть два изъяна. Теперь поупражняйтесь и попробуйте догадаться, что же это за недостатки.
1) очень прост, для его понимания достаточно взглянуть на объявление метода
public static native void sleep(long millis) throws InterruptedException;
Верно, аргумент метода называется millis
и с его помощью мы не можем "усыпить" поток менее чем на 1 миллисекунду (для некоторых задач это много). На мой взгляд конечного пользователя JDK этот недостаток является основным.
2) чуть сложнее, для его понимания необходимо иметь представление о планировщике и системном времени
public static native void sleep(long millis) throws InterruptedException;
Метод объявлен native
, иными словами его реализация целиком и полностью зависит от платформы, на неё может влиять "железо", ось и даже версия Java, что будет показано ниже.
Ах да, исключение InterruptedException
является проверяемым, так что извольте его обработать.
Проиллюстрируем недостатки:
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ThreadSleepPlainBenchmark {
@Benchmark
public void sleep() throws Exception {
Thread.sleep(1);
}
@Benchmark
public void sleepNanos() throws Exception {
Thread.sleep(0, 1000);
}
}
С первым всё понятно: засыпаем на 1 мс. Во втором пробуем обмануть судьбу и заснуть лишь на 1000 нс.
Получается так
Java 11 Linux
Benchmark Mode Cnt Score Error Units
ThreadSleepPlainBenchmark.sleep avgt 50 1,091 ± 0,005 ms/op
ThreadSleepPlainBenchmark.sleepNanos avgt 50 1,110 ± 0,005 ms/op
Java 11 MacOS
Benchmark Mode Cnt Score Error Units
ThreadSleepPlainBenchmark.sleep avgt 50 1,312 ± 0,042 ms/op
ThreadSleepPlainBenchmark.sleepNanos avgt 50 1,325 ± 0,029 ms/op
Java 17 Linux
Benchmark Mode Cnt Score Error Units
ThreadSleepPlainBenchmark.sleep avgt 40 1,112 ± 0,003 ms/op
ThreadSleepPlainBenchmark.sleepNanos avgt 40 1,117 ± 0,001 ms/op
Java 17 MacOS
Benchmark Mode Cnt Score Error Units
ThreadSleepPlainBenchmark.sleep avgt 40 1,363 ± 0,008 ms/op
ThreadSleepPlainBenchmark.sleepNanos avgt 40 1,364 ± 0,010 ms/op
Теперь совсем радикально:
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class ThreadSleepPlainBenchmark {
@Benchmark
public void sleepZero() throws Exception {
Thread.sleep(0);
}
}
Выходит:
Java 11 Linux
Benchmark Mode Cnt Score Error Units
ThreadSleepPlainBenchmark.sleepZero avgt 50 484,261 ± 0,578 ns/op
Java 11 MacOS
Benchmark Mode Cnt Score Error Units
ThreadSleepPlainBenchmark.sleepZero avgt 50 501,271 ± 90,137 ns/op
Java 17 Linux
Benchmark Mode Cnt Score Error Units
ThreadSleepPlainBenchmark.sleepZero avgt 50 493,227 ± 4,881 ns/op
Java 17 MacOS
Benchmark Mode Cnt Score Error Units
ThreadSleepPlainBenchmark.sleepZero avgt 40 371,817 ± 6,669 ns/op
Не торопитесь радоваться столь короткой задержке и просто запомните эти цифры, через несколько абзацев мы к ним вернёмся.
Итак, накладные расходы на "усыпление" потока в зависимости от оси составляют 0,1-0,3 мс, что составляет 10-30% (!) от требуемой задержки. Иными словами поток приостанавливается на время существенно бОльшее ожидаемого, что особенно заметно в циклах.
Возникает закономерный вопрос
Зачем вообще было делать перегруженный метод с наносекундами, если одна стоимость вызова измеряется десятками, а то и сотнями микросекунд?
Обойти это ограничение можно с помощью существующего ещё с Java 5 LockSupport.parkNanos(long)
, как и Thread.sleep()
созданного для перевода текущего потока в состояние Thread.State.TIMED_WAITING
. Его преимуществом помимо более тонкой настройки ожидания является уникальная возможность "разбудить" поток с помощью метода LockSupport.unpark(Thread)
, а также отсутствие необходимости обрабатывать исключение.
Но и тут всё не так просто:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class ParkNanosBenchmark {
@Param({"10", "100", "1000", "10000", "1000000"})
long delay;
@Benchmark
public void parkNanos() {
LockSupport.parkNanos(delay);
}
}
Получается так
Java 11 Linux
Benchmark (delay) Mode Cnt Score Error Units
ParkNanosBenchmark.parkNanos 10 avgt 40 57713,345 ± 165,827 ns/op
ParkNanosBenchmark.parkNanos 100 avgt 40 57672,554 ± 444,892 ns/op
ParkNanosBenchmark.parkNanos 1000 avgt 40 58646,654 ± 120,040 ns/op
ParkNanosBenchmark.parkNanos 10000 avgt 40 68222,650 ± 115,751 ns/op
ParkNanosBenchmark.parkNanos 1000000 avgt 40 1103499,893 ± 7793,704 ns/op
Java 11 MacOS
Benchmark (delay) Mode Cnt Score Error Units
ParkNanosBenchmark.parkNanos 10 avgt 40 11395,046 ± 250,512 ns/op
ParkNanosBenchmark.parkNanos 100 avgt 40 9171,332 ± 1117,377 ns/op
ParkNanosBenchmark.parkNanos 1000 avgt 40 4781,591 ± 82,799 ns/op
ParkNanosBenchmark.parkNanos 10000 avgt 40 17750,419 ± 256,214 ns/op
ParkNanosBenchmark.parkNanos 1000000 avgt 40 1403117,216 ± 42218,338 ns/op
Java 17 Linux
Benchmark (delay) Mode Cnt Score Error Units
ParkNanosBenchmark.parkNanos 10 avgt 40 57331,896 ± 606,789 ns/op
ParkNanosBenchmark.parkNanos 100 avgt 40 57701,988 ± 151,346 ns/op
ParkNanosBenchmark.parkNanos 1000 avgt 40 58585,929 ± 119,138 ns/op
ParkNanosBenchmark.parkNanos 10000 avgt 40 67984,687 ± 186,378 ns/op
ParkNanosBenchmark.parkNanos 1000000 avgt 40 1093718,818 ± 7665,960 ns/op
Java 17 MacOS
Benchmark (delay) Mode Cnt Score Error Units
ParkNanosBenchmark.parkNanos 10 avgt 40 11446,015 ± 243,118 ns/op
ParkNanosBenchmark.parkNanos 100 avgt 40 10283,453 ± 148,363 ns/op
ParkNanosBenchmark.parkNanos 1000 avgt 40 5085,643 ± 31,031 ns/op
ParkNanosBenchmark.parkNanos 10000 avgt 40 17582,777 ± 159,145 ns/op
ParkNanosBenchmark.parkNanos 1000000 avgt 40 1369327,409 ± 9468,778 ns/op
Добиться ожидаемой задержки получается не всегда: планировщик Линукса по умолчанию не позволяет приостановить поток менее чем на 50 мкс. Он (планировщик) опирается на заданное на уровне оси значение наименьшей задержки, поэтому действительное время "засыпания" всегда кратно этому значению. Подробнее об этом, а также об уменьшении минимального времени на уровне оси вплоть до отдельных потоков хорошо написано в блоге Хейзелкаст. Таким образом, выражение Thread.sleep(0)
бесполезно: оно не переводит поток в состояние Thread.State.TIMED_WAITING
и не высвобождает вычислительные мощности.
С существенным и неочевидным разбросом задержки на MacOS я не разобрался, на СО тоже ничего толком не подсказали. Подозреваю, что в приостановке потока тоже есть определённая гранулярность, при этом задержки короче 1 мкс обрабатываются каким-то особенным образом (подозреваю, что для случаев delay = 100
и delay = 1000
поток не приостанавливался вовсе).
Практические выводы из этой части:
при использовании
Thread.sleep()
достижимы задержки не короче 1,1-1,3 мс;для более коротких пауз используйте
LockSupport.parkNanos()
;задержки кратны величине, заданной на уровне ОС;
на Линуксе продолжительность ожидания тонко настраивается вплоть до отдельных потоков.
Использование
В самом начале был приведён основной шаблон задержки:
long delay = 1L;
volatile boolean wait = true; // значение выставляется из другого потока
while (wait) {
Thread.sleep(delay);
}
В дикой природе часто встречается его разновидность:
volatile boolean wait;
long sleepTime = 1L;
for (int i = 0; wait && i < 5; i++) {
try {
Thread.sleep(sleepTime);
} catch(InterruptedException e) {
return result;
}
// sleepTime *= 2; // иногда задержку увеличивают после каждого прохода
}
Логика такая: одна длительная пауза разбивается на несколько коротких с расчётом на то, что флаг может быть поднят до последнего прохода, что сбережёт время. Как вариант другим потокам с каждым разом даётся всё больше времени для выполнения своей работы.
Попробуем подход в деле: здесь поток многократно приостанавливается на короткое время в счётном цикле:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ThreadSleepInCountedLoopBenchmark {
private final ExecutorService executor = Executors.newFixedThreadPool(1);
volatile boolean wait;
@Param({"1", "5", "10", "50", "100"})
long delay;
@Setup(Level.Invocation)
public void setUp() {
wait = true;
startThread();
}
@TearDown(Level.Trial)
public void tearDown() {
executor.shutdown();
}
@Benchmark
public int sleep() throws Exception {
for (int i = 0; wait && i < delay; i++) {
Thread.sleep(1);
}
return hashCode();
}
private void startThread() {
executor.submit(() -> {
try {
Thread.sleep(delay / 2); // просыпаемся примерно посередине
wait = false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
});
}
}
Основной недостаток данного подхода заключается в накоплении затрат на засыпание/пробуждение на платформах с бОльшими затратами (здесь и далее для краткости измерения буду делать для Java 11):
Java 11 Linux
Benchmark (delay) Mode Cnt Score Error Units
ThreadSleepInCountedLoopBenchmark.sleep 1 avgt 40 1,135 ± 0,006 ms/op
ThreadSleepInCountedLoopBenchmark.sleep 5 avgt 40 2,300 ± 0,016 ms/op
ThreadSleepInCountedLoopBenchmark.sleep 10 avgt 40 5,667 ± 0,035 ms/op
ThreadSleepInCountedLoopBenchmark.sleep 50 avgt 40 26,158 ± 0,038 ms/op
ThreadSleepInCountedLoopBenchmark.sleep 100 avgt 40 50,754 ± 0,124 ms/op
Java 11 MacOS
Benchmark (delay) Mode Cnt Score Error Units
ThreadSleepInCountedLoopBenchmark.sleep 1 avgt 40 1,313 ± 0,021 ms/op
ThreadSleepInCountedLoopBenchmark.sleep 5 avgt 40 2,956 ± 0,021 ms/op
ThreadSleepInCountedLoopBenchmark.sleep 10 avgt 40 6,662 ± 0,031 ms/op
ThreadSleepInCountedLoopBenchmark.sleep 50 avgt 40 29,373 ± 0,120 ms/op
ThreadSleepInCountedLoopBenchmark.sleep 100 avgt 40 54,278 ± 0,136 ms/op
А сейчас воспользуемся LockSupport.parkNanos()
:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ParkNanosInCountedLoopBenchmark {
private final ExecutorService executor = Executors.newFixedThreadPool(1);
volatile boolean wait;
@Param({"1", "5", "10", "50", "100"})
long delay;
@Setup(Level.Invocation)
public void setUp() {
wait = true;
startThread();
}
@TearDown(Level.Trial)
public void tearDown() {
executor.shutdown();
}
@Benchmark
public int sleep() {
for (int i = 0; wait && i < delay; i++) {
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
}
return hashCode();
}
private void startThread() {
executor.submit(() -> {
try {
Thread.sleep(delay / 2);
wait = false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
});
}
}
Java 11 Linux
Benchmark (delay) Mode Cnt Score Error Units
ParkNanosInCountedLoopBenchmark.sleep 1 avgt 20 1,079 ± 0,074 ms/op
ParkNanosInCountedLoopBenchmark.sleep 5 avgt 20 2,205 ± 0,038 ms/op
ParkNanosInCountedLoopBenchmark.sleep 10 avgt 20 5,404 ± 0,151 ms/op
ParkNanosInCountedLoopBenchmark.sleep 50 avgt 20 25,710 ± 0,024 ms/op
ParkNanosInCountedLoopBenchmark.sleep 100 avgt 20 50,546 ± 0,051 ms/op
Java 11 MacOS
Benchmark (delay) Mode Cnt Score Error Units
ParkNanosInCountedLoopBenchmark.sleep 1 avgt 40 1,303 ± 0,023 ms/op
ParkNanosInCountedLoopBenchmark.sleep 5 avgt 40 3,021 ± 0,020 ms/op
ParkNanosInCountedLoopBenchmark.sleep 10 avgt 40 6,728 ± 0,032 ms/op
ParkNanosInCountedLoopBenchmark.sleep 50 avgt 40 29,407 ± 0,118 ms/op
ParkNanosInCountedLoopBenchmark.sleep 100 avgt 40 54,322 ± 0,161 ms/op
Цифры в целом совпадают с предыдущим замером, однако мы помним, что минимальная действительная задержка LockSupport.parkNanos()
измеряется микросекундами. Получается, мы можем использовать более короткие паузы, чтобы быстрее выйти из цикла, но и накладные расходы будут выше просто в силу бОльшего количества вызовов. Поэтому видоизменим бенчмарк для измерения зависимости общей времени задержки от продолжительности отдельной паузы. Возьмём значения 100, 200 и 500 мкс:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ParkNanosInCountedLoopBenchmark {
private final ExecutorService executor = Executors.newFixedThreadPool(1);
volatile boolean wait;
@Param({"1", "5", "10", "50", "100"})
long delay;
@Param({"100", "200", "500"})
long pause;
@Setup(Level.Invocation)
public void setUp() {
wait = true;
startThread();
}
@TearDown(Level.Trial)
public void tearDown() {
executor.shutdown();
}
@Benchmark
public int sleep() {
for (int i = 0; wait && i < delay; i++) {
LockSupport.parkNanos(TimeUnit.MICROSECONDS.toNanos(pause));
}
return hashCode();
}
private void startThread() {
executor.submit(() -> {
try {
Thread.sleep(delay / 2);
wait = false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
});
}
}
Прогоняем
Java 11 Linux
Benchmark (delay) (pause) Mode Cnt Score Error Units
ParkNanosInCountedLoopBenchmark.sleep 1 100 avgt 10 0,160 ± 0,001 ms/op
ParkNanosInCountedLoopBenchmark.sleep 1 200 avgt 10 0,283 ± 0,004 ms/op
ParkNanosInCountedLoopBenchmark.sleep 1 500 avgt 10 0,583 ± 0,053 ms/op
ParkNanosInCountedLoopBenchmark.sleep 5 100 avgt 10 0,703 ± 0,003 ms/op
ParkNanosInCountedLoopBenchmark.sleep 5 200 avgt 10 1,059 ± 0,012 ms/op
ParkNanosInCountedLoopBenchmark.sleep 5 500 avgt 10 2,323 ± 0,214 ms/op
ParkNanosInCountedLoopBenchmark.sleep 10 100 avgt 10 1,338 ± 0,052 ms/op
ParkNanosInCountedLoopBenchmark.sleep 10 200 avgt 10 2,557 ± 0,008 ms/op
ParkNanosInCountedLoopBenchmark.sleep 10 500 avgt 10 5,277 ± 0,251 ms/op
ParkNanosInCountedLoopBenchmark.sleep 50 100 avgt 10 6,455 ± 0,204 ms/op
ParkNanosInCountedLoopBenchmark.sleep 50 200 avgt 10 12,569 ± 0,053 ms/op
ParkNanosInCountedLoopBenchmark.sleep 50 500 avgt 10 25,496 ± 0,082 ms/op
ParkNanosInCountedLoopBenchmark.sleep 100 100 avgt 10 12,853 ± 0,434 ms/op
ParkNanosInCountedLoopBenchmark.sleep 100 200 avgt 10 25,077 ± 0,158 ms/op
ParkNanosInCountedLoopBenchmark.sleep 100 500 avgt 10 50,485 ± 0,040 ms/op
Java 11 MacOS
Benchmark (delay) (pause) Mode Cnt Score Error Units
ParkNanosInCountedLoopBenchmark.sleep 1 100 avgt 40 0,132 ± 0,001 ms/op
ParkNanosInCountedLoopBenchmark.sleep 1 200 avgt 40 0,258 ± 0,001 ms/op
ParkNanosInCountedLoopBenchmark.sleep 1 500 avgt 40 0,695 ± 0,010 ms/op
ParkNanosInCountedLoopBenchmark.sleep 5 100 avgt 40 0,625 ± 0,009 ms/op
ParkNanosInCountedLoopBenchmark.sleep 5 200 avgt 40 1,172 ± 0,004 ms/op
ParkNanosInCountedLoopBenchmark.sleep 5 500 avgt 40 2,764 ± 0,021 ms/op
ParkNanosInCountedLoopBenchmark.sleep 10 100 avgt 40 1,262 ± 0,013 ms/op
ParkNanosInCountedLoopBenchmark.sleep 10 200 avgt 40 2,442 ± 0,097 ms/op
ParkNanosInCountedLoopBenchmark.sleep 10 500 avgt 40 5,927 ± 0,118 ms/op
ParkNanosInCountedLoopBenchmark.sleep 50 100 avgt 40 6,327 ± 0,058 ms/op
ParkNanosInCountedLoopBenchmark.sleep 50 200 avgt 40 11,848 ± 1,748 ms/op
ParkNanosInCountedLoopBenchmark.sleep 50 500 avgt 40 28,142 ± 0,272 ms/op
ParkNanosInCountedLoopBenchmark.sleep 100 100 avgt 40 12,580 ± 0,187 ms/op
ParkNanosInCountedLoopBenchmark.sleep 100 200 avgt 40 24,894 ± 1,016 ms/op
ParkNanosInCountedLoopBenchmark.sleep 100 500 avgt 40 53,438 ± 0,137 ms/op
Получается немного лучше, т. к. прерывания становятся более гранулярными. Обратите особое внимание на случаи, когда величина pause
составляет 100, 200 или 500 мкс. Накладные расходы в этом случае существенно ниже обычных. Для меня это осталось ещё одной загадкой, на СО также тишина. Если захотите перепроверить, то запускайте ThreadSleepVsParkNanosBenchmark
.
Задержки меньшие расчётных в данных случаях вызваны тем, что счётчик сбрасывается раньше взведения флага. Это не всегда желаемое поведение и может сломать программу, поэтому иногда для перестраховки можно использовать бесконечный цикл, чтобы двигаться дальше только после наступления события. Для этого мы меняем
for (int i = 0; wait && i < delay; i++) {
LockSupport.parkNanos(TimeUnit.MICROSECONDS.toNanos(pause));
}
на
while (wait) {
LockSupport.parkNanos(TimeUnit.MICROSECONDS.toNanos(pause));
}
И тут получается любопытная картинка
Java 11 Linux
Benchmark (delay) (pause) Mode Cnt Score Error Units
ParkNanosInWhileLoopBenchmark.sleep 1 100 avgt 10 0,161 ± 0,002 ms/op
ParkNanosInWhileLoopBenchmark.sleep 1 200 avgt 10 0,282 ± 0,012 ms/op
ParkNanosInWhileLoopBenchmark.sleep 1 500 avgt 10 0,603 ± 0,006 ms/op
ParkNanosInWhileLoopBenchmark.sleep 5 100 avgt 10 2,219 ± 0,009 ms/op
ParkNanosInWhileLoopBenchmark.sleep 5 200 avgt 10 2,251 ± 0,045 ms/op
ParkNanosInWhileLoopBenchmark.sleep 5 500 avgt 10 2,403 ± 0,029 ms/op
ParkNanosInWhileLoopBenchmark.sleep 10 100 avgt 10 5,242 ± 0,019 ms/op
ParkNanosInWhileLoopBenchmark.sleep 10 200 avgt 10 5,374 ± 0,031 ms/op
ParkNanosInWhileLoopBenchmark.sleep 10 500 avgt 10 5,416 ± 0,030 ms/op
ParkNanosInWhileLoopBenchmark.sleep 50 100 avgt 10 25,262 ± 0,068 ms/op
ParkNanosInWhileLoopBenchmark.sleep 50 200 avgt 10 25,338 ± 0,034 ms/op
ParkNanosInWhileLoopBenchmark.sleep 50 500 avgt 10 25,461 ± 0,067 ms/op
ParkNanosInWhileLoopBenchmark.sleep 100 100 avgt 10 50,242 ± 0,019 ms/op
ParkNanosInWhileLoopBenchmark.sleep 100 200 avgt 10 50,326 ± 0,018 ms/op
ParkNanosInWhileLoopBenchmark.sleep 100 500 avgt 10 50,449 ± 0,043 ms/op
Java 11 MacOS
Benchmark (delay) (pause) Mode Cnt Score Error Units
ParkNanosInWhileLoopBenchmark.sleep 1 100 avgt 40 0,134 ± 0,003 ms/op
ParkNanosInWhileLoopBenchmark.sleep 1 200 avgt 40 0,258 ± 0,001 ms/op
ParkNanosInWhileLoopBenchmark.sleep 1 500 avgt 40 0,694 ± 0,010 ms/op
ParkNanosInWhileLoopBenchmark.sleep 5 100 avgt 40 2,392 ± 0,015 ms/op
ParkNanosInWhileLoopBenchmark.sleep 5 200 avgt 40 2,521 ± 0,012 ms/op
ParkNanosInWhileLoopBenchmark.sleep 5 500 avgt 40 2,810 ± 0,011 ms/op
ParkNanosInWhileLoopBenchmark.sleep 10 100 avgt 40 5,739 ± 0,018 ms/op
ParkNanosInWhileLoopBenchmark.sleep 10 200 avgt 40 5,869 ± 0,182 ms/op
ParkNanosInWhileLoopBenchmark.sleep 10 500 avgt 40 6,517 ± 0,099 ms/op
ParkNanosInWhileLoopBenchmark.sleep 50 100 avgt 40 30,580 ± 0,979 ms/op
ParkNanosInWhileLoopBenchmark.sleep 50 200 avgt 40 27,898 ± 0,117 ms/op
ParkNanosInWhileLoopBenchmark.sleep 50 500 avgt 40 28,105 ± 0,101 ms/op
ParkNanosInWhileLoopBenchmark.sleep 100 100 avgt 40 52,973 ± 0,138 ms/op
ParkNanosInWhileLoopBenchmark.sleep 100 200 avgt 40 54,083 ± 0,829 ms/op
ParkNanosInWhileLoopBenchmark.sleep 100 500 avgt 40 56,220 ± 0,258 ms/op
Сценарий с задержкой в 1, 5 и 10 мс срабатывает быстрее, а сценарии с 50 и 100 мс — примерно так же, какThread.sleep()
.
Выводы из этой части:
использование более коротких пауз позволяет выйти из цикла раньше;
LockSupport.parkNanos()
в случае коротких задержек (100-200 мкс) имеет меньшие инфраструктурные затраты;мне не удалось обнаружить сценарий, в котором
Thread.sleep()
был бы предпочтительнееLockSupport.parkNanos()
.
Преобразования
В Java 9 появился метод Thread.onSpinWait()
, документация которого гласит:
Indicates that the caller is momentarily unable to progress, until the occurrence of one or more actions on the part of other activities. By invoking this method within each iteration of a spin-wait loop construct, the calling thread indicates to the runtime that it is busy-waiting. The runtime may take action to improve the performance of invoking spin-wait loop constructions.
The code above would remain correct even if the onSpinWait method was not called at all. However on some architectures the Java Virtual Machine may issue the processor instructions to address such code patterns in a more beneficial way.
В качестве примера там приведён такой код:
class EventHandler {
volatile boolean eventNotificationNotReceived;
void waitForEventAndHandleIt() {
while (eventNotificationNotReceived) {
Thread.onSpinWait();
}
readAndProcessEvent();
}
void readAndProcessEvent() {
// Read event from some source and process it
}
}
Иными словами этот метод можно использовать для замены Thread.sleep(1)
в бесконечных циклах. Давайте возьмём рассмотренный выше бенчмарк и перепишем его с использованием нового подхода:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ThreadOnSpinWaitBenchmark {
private final ExecutorService executor = Executors.newFixedThreadPool(1);
volatile boolean wait;
@Param({"5", "10", "50", "100"})
long delay;
@Setup(Level.Invocation)
public void setUp() {
wait = true;
startThread();
}
@TearDown(Level.Trial)
public void tearDown() {
executor.shutdown();
}
@Benchmark
public int onSpinWait() {
while (wait) {
Thread.onSpinWait(); // тут был Thread.sleep(1)
}
return hashCode();
}
private void startThread() {
executor.submit(() -> {
try {
Thread.sleep(delay / 2);
wait = false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
});
}
}
Цифры показывают преимущество нового подхода для коротких задержек:
Java 11 Linux
Benchmark (delay) Mode Cnt Score Error Units
ThreadOnSpinWaitBenchmark.onSpinWait 1 avgt 25 0,003 ± 0,001 ms/op
ThreadOnSpinWaitBenchmark.onSpinWait 5 avgt 25 2,074 ± 0,001 ms/op
ThreadOnSpinWaitBenchmark.onSpinWait 10 avgt 25 5,077 ± 0,001 ms/op
ThreadOnSpinWaitBenchmark.onSpinWait 50 avgt 25 25,083 ± 0,004 ms/op
ThreadOnSpinWaitBenchmark.onSpinWait 100 avgt 25 50,086 ± 0,004 ms/op
ThreadSleepBenchmark.sleep 1 avgt 25 1,117 ± 0,001 ms/op
ThreadSleepBenchmark.sleep 5 avgt 25 2,241 ± 0,005 ms/op
ThreadSleepBenchmark.sleep 10 avgt 25 5,588 ± 0,009 ms/op
ThreadSleepBenchmark.sleep 50 avgt 25 25,721 ± 0,034 ms/op
ThreadSleepBenchmark.sleep 100 avgt 25 50,537 ± 0,043 ms/op
Java 11 MacOS
Benchmark (delay) Mode Cnt Score Error Units
ThreadOnSpinWaitBenchmark.onSpinWait 1 avgt 25 0,003 ± 0,001 ms/op
ThreadOnSpinWaitBenchmark.onSpinWait 5 avgt 25 2,499 ± 0,004 ms/op
ThreadOnSpinWaitBenchmark.onSpinWait 10 avgt 25 6,037 ± 0,024 ms/op
ThreadOnSpinWaitBenchmark.onSpinWait 50 avgt 25 28,001 ± 0,140 ms/op
ThreadOnSpinWaitBenchmark.onSpinWait 100 avgt 25 53,054 ± 0,206 ms/op
ThreadSleepBenchmark.sleep 1 avgt 25 1,374 ± 0,008 ms/op
ThreadSleepBenchmark.sleep 5 avgt 25 2,902 ± 0,014 ms/op
ThreadSleepBenchmark.sleep 10 avgt 25 6,665 ± 0,029 ms/op
ThreadSleepBenchmark.sleep 50 avgt 25 28,760 ± 0,153 ms/op
ThreadSleepBenchmark.sleep 100 avgt 25 53,757 ± 0,229 ms/op
Получается, что Thread.onSpinWait()
по идее даёт наименьшую возможную задержку (не считая пустого while(true)
). Проверим:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class ThreadOnSpinWaitPlainBenchmark {
private final ExecutorService executor = Executors.newFixedThreadPool(1);
volatile boolean wait;
@Setup(Level.Invocation)
public void setUp() {
wait = true;
executor.submit(() -> {
wait = false;
});
}
@TearDown(Level.Trial)
public void tearDown() {
executor.shutdown();
}
@Benchmark
public int onSpinWait() {
while (wait) {
Thread.onSpinWait();
}
return hashCode();
}
}
Здесь мы выставляем флаг с наименьшей возможной задержкой:
Java 11 Linux
ThreadOnSpinWaitPlainBenchmark.onSpinWait avgt 40 2059,759 ± 9,187 ns/op
Java 17 Linux
ThreadOnSpinWaitPlainBenchmark.onSpinWait avgt 40 2018,547 ± 37,834 ns/op
Java 11 MacOS
ThreadOnSpinWaitPlainBenchmark.onSpinWait avgt 40 2478,869 ± 122,442 ns/op
Java 17 MacOS
ThreadOnSpinWaitPlainBenchmark.onSpinWait avgt 40 2315,390 ± 42,885 ns/op
Можем ли мы теперь повсюду внедрять этот шаблон? Оказывается, что нет. Применимость нового подхода очень сильно зависит от сценария. Если он строго соответствует виду
цикл-с-условием {
Thread.sleep(delay);
}
то вроде бы препятствий нет (на первый взгляд). Этот подход предпочтителен, т. к. выход из цикла произойдёт почти мгновенно после поднятия флага.
Именно поэтому он используется в перечисленных выше примитивах многопоточности. Суть "проворачивания" в том, что она одновременно значительно экономичнее тупого while(true)
и отзывчивее Thread.sleep(1)
. Ещё одним преимуществом является сохранение состояния потока (он не переходит в режим Thread.State.TIMED_WAITING
), что также сберегает время, а также предохраняет от нежелательного сценария, в котором заснувший поток просыпается на другом ядре/процессоре.
Но в сложных сценариях возможны нюансы :)
Возьмём, к примеру, класс java.nio.Bits
из JDK и найдём в нём Thread.sleep()
:
// A retry loop with exponential back-off delays.
// Sometimes it would suffice to give up once reference
// processing is complete. But if there are many threads
// competing for memory, this gives more opportunities for
// any given thread to make progress. In particular, this
// seems to be enough for a stress test like
// DirectBufferAllocTest to (usually) succeed, while
// without it that test likely fails. Since failure here
// ends in OOME, there's no need to hurry.
long sleepTime = 1;
int sleeps = 0;
while (true) {
if (tryReserveMemory(size, cap)) {
return;
}
if (sleeps >= MAX_SLEEPS) {
break;
}
try {
if (!jlra.waitForReferenceProcessing()) {
Thread.sleep(sleepTime);
sleepTime <<= 1;
sleeps++;
}
} catch (InterruptedException e) {
interrupted = true;
}
}
Комментарий к нему более чем говорящий, и если мы самонадеянно используем там Thread.onSpinWait()
, то OOME
в тесте DirectBufferAllocTest
не заставит себя долго ждать:
Важное уточнение
Тест оформлен в виде обычного класса с public static void main(String[] args)
, который нужно запустить с флагами ВМ
-XX:MaxDirectMemorySize=128m -XX:-ExplicitGCInvokesConcurrent
Этот тест я добавил в репозиторий, так что пробуйте :)
Причина проста: в то время как Thread.sleep()
усыпив текущий поток освобождает вычислительные мощности, Thread.onSpinWait()
молотит вхолостую не давая продыху другим потокам (в том числе собирающим мусор), таким образом потребление памяти опережает её освобождение. Эта же проблема наблюдается в тесте AttachWithStalePidFile
(этот тест отсутствует в репозитории, т. к. для его работы необходима инфраструктура тестирования JDK и его нельзя запустить как отдельное Java-приложение).
Как тестировать?
Для запуска вам потребуются исходники JDK. После их скачивания и первоначальной настройки переходим в корневую папку (в моём случае это ~/IdeaProjects/jdk
) и выполняем
$ make test TEST=serviceability/attach/AttachWithStalePidFile.java
На выходе получаем
==============================
Test summary
==============================
TEST TOTAL PASS FAIL ERROR
jtreg:test/hotspot/jtreg/serviceability/attach/AttachWithStalePidFile.java
1 1 0 0
==============================
TEST SUCCESS
В отличие от предыдущего примера этот участок вроде бы идеально подходит под Thread.onSpinWait()
, ведь мы просто проверяем существует ли файл:
private static void waitForAndResumeVM(int pid) throws Exception {
Path pauseFile = Paths.get("vm.paused." + pid);
int retries = 60;
while(!Files.exists(pauseFile) && --retries > 0) {
Thread.sleep(1000);
}
if(retries == 0) {
throw new RuntimeException("Timeout waiting for VM to start. " +
"vm.paused file not created within 60 seconds.");
}
Files.delete(pauseFile);
}
Проблема здесь та же: мы ожидаем выполнения условия, зависящего от других потоков, и поскольку Thread.onSpinWait()
подгребает все мощности под себя другие потоки не могут продвигаться. Таким образом, сохранение потоком состояния Thread.State.RUNNABLE
является благом в одних сценариях и злом в других.
Выводы:
механическая замена не всегда срабатывает даже в хрестоматийном коде. Всегда, всегда учитывайте контекст;
Thread.onSpinWait()
даёт наименьшую возможную задержку в холостых циклах, но он более прожорлив. Хорошенько взвесьте все за и против, особенно если ожидание длительное, и разрабатываемое приложение работает на мобильном устройстве.
Ещё варианты
Предположим, мы столкнулись с примером, подобным одному из двух описанных выше, т. е. Thread.onSpinWait()
нам не подходит, а дожидаться окончания Thread.sleep()
мы не можем. При таких раскладах палочкой-выручалочкой может стать Thread.yield()
, документация которого многообещающе гласит:
A hint to the scheduler that the current thread is willing to yield its current use of a processor.
Однако, тут же идёт и предостережение:
The scheduler is free to ignore this hint.
Yield is a heuristic attempt to improve relative progression between threads that would otherwise over-utilise a CPU. Its use should be combined with detailed profiling and benchmarking to ensure that it actually has the desired effect.It is rarely appropriate to use this method. It may be useful for debugging or testing purposes, where it may help to reproduce bugs due to race conditions. It may also be useful when designing concurrency control constructs such as the ones in the
java.util.concurrent.locks
package.
В отличие от Thread.onSpinWait()
использование Thread.yield()
в java.nio.Bits
позволяет избежать падения в тесте DirectBufferAllocTest
, но это вовсе не значит, то можно вот так с места в карьер использовать этот метод вместо Thread.sleep()
.
Например, использование Thread.yield()
вместо Thread.sleep(1000)
в упомянутом выше AttachWithStalePidFile
не помогает, тест будет падать.
Как правило Thread.yield()
отдаёт свой квант времени другому потоку, однако планировщик имеет полное право проигнорировать вызов, а продолжительность задержки никак не обозначена и может быть любой. Поэтому с ним нужно быть ещё более осторожным, чем с Thread.onSpinWait()
.
Если захотите поиграть со всеми основными способами одновременно, то смотрите ThreadPausingBenchmark
.
Выводы
Какого-то универсального решения для всех задач нет:
Thread.sleep()
освобождает вычислительные мощности, но он негибкий и дорогой;LockSupport.parkNanos()
гибче и дешевле, но и он не бесплатен;Thread.onSpinWait()
подходит для коротких задержек (например, в примитивах многопоточности) и довольно жаден;Thread.yield()
стрёмный и его использование требует большого искусства и глубокого понимания что, как и зачем делается.
На этом всё. До новых встреч!
Комментарии (10)
apangin
11.08.2022 00:59+2для более коротких пауз используйте LockSupport.parkNanos();
На самом деле, зависит от ОС. На Windows всё наоборот: минимальный интервал сна (1мс) достигается именно
Thread.sleep
, аLockSupport.parkNanos
при любом положительном аргументе засыпает минимум на 3.9мс (1/256 секунды). Такая вот особенность реализации.
gurux13
12.08.2022 01:16+1while(wait) Thread.sleep() лучше вообще не использовать, если ожидание обещает быть хоть сколько нибудь долгим - используйте семафоры, например.
А ожидание события с точностью до микросекунд - обычно занятие ядра, поэтому там этих спинлоков много.
И если поток не отдать, на однопроцессорной системе это внезапно получится бесконечный цикл.
tsypanov Автор
12.08.2022 09:00если поток не отдать, на однопроцессорной системе это внезапно получится бесконечный цикл
Планировщик не позволит одному потоку монополизировать весь процессор, ИМХО.
dyadyaSerezha
Полезная статья, спасибо.
Одно замечание по самим тестам. Во многих тестах метод SetUp вызывает StartThread, который уже вызывает executor. А метод TearDown сразу вызывает executor. Как-то некошерно с точки зрения дизайна.
tsypanov Автор
И вам спасибо )
Я думал об этом и в конце-концов решил исключительно для краткости не выносить одну строку с
executor.shutdown()
в отдельный метод.dyadyaSerezha
Или можно избавиться от StartThread.
tsypanov Автор
Скорее переименовать в
raiseFlagWithDelay()
или что-то в таком духе ибо он кроме запуска потока выполняет взведение флага.