Привет, сегодня поговорим о тонкостях реализации холостых циклов (холостого ожидания) в 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)


  1. dyadyaSerezha
    10.08.2022 12:38

    Полезная статья, спасибо.

    Одно замечание по самим тестам. Во многих тестах метод SetUp вызывает StartThread, который уже вызывает executor. А метод TearDown сразу вызывает executor. Как-то некошерно с точки зрения дизайна.


    1. tsypanov Автор
      10.08.2022 12:45

      И вам спасибо )

      Во многих тестах метод SetUp вызывает StartThread, который уже вызывает executor. А метод TearDown сразу вызывает executor.

      Я думал об этом и в конце-концов решил исключительно для краткости не выносить одну строку с executor.shutdown() в отдельный метод.


      1. dyadyaSerezha
        10.08.2022 20:44

        Или можно избавиться от StartThread.


        1. tsypanov Автор
          10.08.2022 21:36

          Скорее переименовать в raiseFlagWithDelay() или что-то в таком духе ибо он кроме запуска потока выполняет взведение флага.


  1. svischuk
    10.08.2022 21:36

    Использовать нечетные задержки и использовать целочисленное деление- ну такое


    1. tsypanov Автор
      10.08.2022 22:58

      В данном случае это не столь важно )


  1. apangin
    11.08.2022 00:59
    +2

    для более коротких пауз используйте LockSupport.parkNanos();

    На самом деле, зависит от ОС. На Windows всё наоборот: минимальный интервал сна (1мс) достигается именно Thread.sleep, а LockSupport.parkNanos при любом положительном аргументе засыпает минимум на 3.9мс (1/256 секунды). Такая вот особенность реализации.


    1. tsypanov Автор
      11.08.2022 09:50

      Что называется "Внезапно!". Спасибо за уточнение!


  1. gurux13
    12.08.2022 01:16
    +1

    while(wait) Thread.sleep() лучше вообще не использовать, если ожидание обещает быть хоть сколько нибудь долгим - используйте семафоры, например.

    А ожидание события с точностью до микросекунд - обычно занятие ядра, поэтому там этих спинлоков много.

    И если поток не отдать, на однопроцессорной системе это внезапно получится бесконечный цикл.


    1. tsypanov Автор
      12.08.2022 09:00

      если поток не отдать, на однопроцессорной системе это внезапно получится бесконечный цикл

      Планировщик не позволит одному потоку монополизировать весь процессор, ИМХО.