Привет, сегодня поговорим о тонкостях реализации холостых циклов (холостого ожидания) в Java. Эта задача встречается нечасто: за девять с небольшим лет работы я столкнулся с ней лишь пару раз. Тем не менее, тема видится интересной и по ней есть что сказать, так что добро пожаловать! Исходный код примеров доступен здесь. Начнём с определения. На мой вкус русское "холостой цикл" (или "холостое ожидание") интуитивно понятнее и точнее передаёт суть явления, чем английское "busy waiting" или "busy loop" — всё-таки в холостых циклах мы ничего не делаем в смысле логики приложения и относительно текущего потока исполнения. Определения "spinning" или "spin loop" подходят лучше, правда, и они несколько размыты. Смысл холостого цикла состоит в ожидании события, до наступления которого текущий поток не может двигаться дальше. Основное отличие от блокировки (основанной на synchronized иди *Lock-ах) заключается в том, что ожидающий поток не переходит в состояние Thread.State.BLOCKED, а скорее нарезает круги подобно самолёту, ожидающему разрешения на посадку. Обратите внимание: сегодня с распространением реактивщины, корутин, легковесных потоков и прочих CompletableFuture область явно реализованных ожиданий постепенно сужается (даже в тестах православно использовать awaitility), но не исчезает окончательно. Так в JDK (не считая тестов) метод Thread.onSpinWait() (его и не только мы рассмотрим в этой статье) используется в ForkJoinPool, Phaser, StampedLock, AbstractQueued*Synchronizer, т. е. в примитивах многопоточности. Об этом немного поговорим ниже. Простейшая реализация холостого ожидания (что называется "в лоб"): Запустив этот код мы очень быстро услышим недовольный гул крыльчатки охлаждения процессора: такой цикл будет пожирать мощность как не в себя. Очевидно, это не то, что мы хотим видеть в своих приложениях (особенно если разработка ведётся для мобильных устройств), поэтому наиболее распространённая в силу своего почтенного возраста реализация холостого цикла выглядит так: Это классический подход, наверняка вы хоть пару раз видели что-нибудь похожее. У этого подхода есть два изъяна. Теперь поупражняйтесь и попробуйте догадаться, что же это за недостатки. Верно, аргумент метода называется millis и с его помощью мы не можем "усыпить" поток менее чем на 1 миллисекунду (для некоторых задач это много). На мой взгляд конечного пользователя JDK этот недостаток является основным. Метод объявлен native, иными словами его реализация целиком и полностью зависит от платформы, на неё может влиять "железо", ось и даже версия Java, что будет показано ниже. Ах да, исключение InterruptedException является проверяемым, так что извольте его обработать. Проиллюстрируем недостатки: С первым всё понятно: засыпаем на 1 мс. Во втором пробуем обмануть судьбу и заснуть лишь на 1000 нс.  Теперь совсем радикально: Выходит: Не торопитесь радоваться столь короткой задержке и просто запомните эти цифры, через несколько абзацев мы к ним вернёмся. Итак, накладные расходы на "усыпление" потока в зависимости от оси составляют 0,1-0,3 мс, что составляет 10-30% (!) от требуемой задержки. Иными словами поток приостанавливается на время существенно бОльшее ожидаемого, что особенно заметно в циклах. Зачем вообще было делать перегруженный метод с наносекундами, если одна стоимость вызова измеряется десятками, а то и сотнями микросекунд? Обойти это ограничение можно с помощью существующего ещё с Java 5 LockSupport.parkNanos(long), как и Thread.sleep() созданного для перевода текущего потока в состояние Thread.State.TIMED_WAITING. Его преимуществом помимо более тонкой настройки ожидания является уникальная возможность "разбудить" поток с помощью метода LockSupport.unpark(Thread), а также отсутствие необходимости обрабатывать исключение. Но и тут всё не так просто:  Добиться ожидаемой задержки получается не всегда: планировщик Линукса по умолчанию не позволяет приостановить поток менее чем на 50 мкс. Он (планировщик) опирается на заданное на уровне оси значение наименьшей задержки, поэтому действительное время "засыпания" всегда кратно этому значению. Подробнее об этом, а также об уменьшении минимального времени на уровне оси вплоть до отдельных потоков хорошо написано в блоге Хейзелкаст. Таким образом, выражение Thread.sleep(0) бесполезно: оно не переводит поток в состояние Thread.State.TIMED_WAITING и не высвобождает вычислительные мощности. С существенным и неочевидным разбросом задержки на MacOS я не разобрался, на СО тоже ничего толком не подсказали. Подозреваю, что в приостановке потока тоже есть определённая гранулярность, при этом задержки короче 1 мкс обрабатываются каким-то особенным образом (подозреваю, что для случаев delay = 100 и delay = 1000 поток не приостанавливался вовсе). Практические выводы из этой части: при использовании Thread.sleep() достижимы задержки не короче 1,1-1,3 мс; для более коротких пауз используйте LockSupport.parkNanos(); задержки кратны величине, заданной на уровне ОС; на Линуксе продолжительность ожидания тонко настраивается вплоть до отдельных потоков. В самом начале был приведён основной шаблон задержки: В дикой природе часто встречается его разновидность: Логика такая: одна длительная пауза разбивается на несколько коротких с расчётом на то, что флаг может быть поднят до последнего прохода, что сбережёт время. Как вариант другим потокам с каждым разом даётся всё больше времени для выполнения своей работы. Попробуем подход в деле: здесь поток многократно приостанавливается на короткое время в счётном цикле: Основной недостаток данного подхода заключается в накоплении затрат на засыпание/пробуждение на платформах с бОльшими затратами (здесь и далее для краткости измерения буду делать для Java 11): А сейчас воспользуемся LockSupport.parkNanos():  Цифры в целом совпадают с предыдущим замером, однако мы помним, что минимальная действительная задержка LockSupport.parkNanos() измеряется микросекундами. Получается, мы можем использовать более короткие паузы, чтобы быстрее выйти из цикла, но и накладные расходы будут выше просто в силу бОльшего количества вызовов. Поэтому видоизменим бенчмарк для измерения зависимости общей времени задержки от продолжительности отдельной паузы. Возьмём значения 100, 200 и 500 мкс:  Получается немного лучше, т. к. прерывания становятся более гранулярными. Обратите особое внимание на случаи, когда величина pause составляет 100, 200 или 500 мкс. Накладные расходы в этом случае существенно ниже обычных. Для меня это осталось ещё одной загадкой, на СО также тишина. Если захотите перепроверить, то запускайте ThreadSleepVsParkNanosBenchmark. Задержки меньшие расчётных в данных случаях вызваны тем, что счётчик сбрасывается раньше взведения флага. Это не всегда желаемое поведение и может сломать программу, поэтому иногда для перестраховки можно использовать бесконечный цикл, чтобы двигаться дальше только после наступления события. Для этого мы меняем на  Сценарий с задержкой в 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. В качестве примера там приведён такой код: Иными словами этот метод можно использовать для замены Thread.sleep(1) в бесконечных циклах. Давайте возьмём рассмотренный выше бенчмарк и перепишем его с использованием нового подхода: Цифры показывают преимущество нового подхода для коротких задержек: Получается, что Thread.onSpinWait() по идее даёт наименьшую возможную задержку (не считая пустого while(true)). Проверим: Здесь мы выставляем флаг с наименьшей возможной задержкой: Можем ли мы теперь повсюду внедрять этот шаблон? Оказывается, что нет. Применимость нового подхода очень сильно зависит от сценария. Если он строго соответствует виду то вроде бы препятствий нет (на первый взгляд). Этот подход предпочтителен, т. к. выход из цикла произойдёт почти мгновенно после поднятия флага. Именно поэтому он используется в перечисленных выше примитивах многопоточности. Суть "проворачивания" в том, что она одновременно  значительно экономичнее тупого while(true) и отзывчивее Thread.sleep(1). Ещё одним преимуществом является сохранение состояния потока (он не переходит в режим Thread.State.TIMED_WAITING), что также сберегает время, а также предохраняет от нежелательного сценария, в котором заснувший поток просыпается на другом ядре/процессоре. Но в сложных сценариях возможны нюансы :) Возьмём, к примеру, класс java.nio.Bits из JDK и найдём в нём Thread.sleep(): Комментарий к нему более чем говорящий, и если мы самонадеянно используем там Thread.onSpinWait(), то OOME в тесте DirectBufferAllocTest не заставит себя долго ждать: Тест оформлен в виде обычного класса с public static void main(String[] args), который нужно запустить с флагами ВМ  Этот тест я добавил в репозиторий, так что пробуйте :) Причина проста: в то время как Thread.sleep() усыпив текущий поток освобождает вычислительные мощности, Thread.onSpinWait() молотит вхолостую не давая продыху другим потокам (в том числе собирающим мусор), таким образом потребление памяти опережает её освобождение. Эта же проблема наблюдается в тесте AttachWithStalePidFile(этот тест отсутствует в репозитории, т. к. для его работы необходима инфраструктура тестирования JDK и его нельзя запустить как отдельное Java-приложение). Для запуска вам потребуются исходники JDK. После их скачивания и первоначальной настройки переходим в корневую папку (в моём случае это ~/IdeaProjects/jdk) и выполняем На выходе получаем  В отличие от предыдущего примера этот участок вроде бы идеально подходит под Thread.onSpinWait(), ведь мы просто проверяем существует ли файл: Проблема здесь та же: мы ожидаем выполнения условия, зависящего от других потоков, и поскольку 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() стрёмный и его использование требует большого искусства и глубокого понимания что, как и зачем делается. На этом всё. До новых встреч!  Разработчик