Как применять happens-before на практике и в чем основные преимущества этой концепции

Как применять happens-before на практике и в чем основные преимущества этой концепции
18.03.2025
#новости Как применять happens-before на практике и в чем основные преимущества этой концепции
Как применять happens-before на практике и в чем основные преимущества этой концепции

История вопроса


Вкратце вспомним процесс программирования. Код, который мы пишем на Java, превращается в байт-код. Тот попадает в виртуальную машину Java, а она, в свою очередь, производит машинный код, который выполняется на процессоре. В 2005 году на архитектуре x86 впервые появилась многоядерность. Возникла задача — найти оптимальный путь к выполнению кода. Для этого были придуманы различные переупорядочивания, которые можно разделить на три типа:

Sequential Consistency — запрещены все переупорядочивания (по сути — как написали, так и работает).
Relaxed Consistency — разрешены некоторые переупорядочивания.
Weak Consistency — разрешены все переупорядочивания.

В этом материале мы рассмотрим первый вариант (тут и далее приведены примеры на псевдокоде, до степени смешения похожем на Java). Возьмем такой пример:

private int a =1;
private int b =2;
private int r1 =a; // всегда 1
private int r2 =b; // всегда 2

Можно ли в этой последовательности совершить перестановку? Да, безусловно:

private int a =1;
private int r1 =a; // всегда 1
private int b =2;
private int r2 =b; // всегда 2

Можно переставить и по-другому, например:

private int b =2;
private int a =1;
private int r2 =b; // всегда 2
private int r1 =a; // всегда 1

Есть ли у нас какие-то гарантии, что результат выполнения программы при этом будет идентичным? Да, но существуют свои ограничения:

Принцип as-if-serial: означает, что результат выполнения программы неотличим от порядка выполнения «как написано». Но только в одном потоке.
Процессор не меняет итоговый результат выполнения — вторая гарантия. Но она актуальна только в рамках одного ядра.
Принцип cache coherence — означает, что все изменения в кэше ядра видны всем остальным ядрам процессора. Но эти изменения с кэшем происходят через некоторое время. Причин много: от организации доставки изменений до ограничения скорости света (да, это совсем короткий промежуток времени, но и им мы не можем пренебрегать).

Посмотрим на этот фрагмент кода — идиому Dekker lock:

private int x;
private int y;
public int actor1() {
x = 1;
return y;
}
public int actor1() {
y = 1;
return x;
}

Здесь есть две переменные: x и y. В первом методе мы в x записываем 1 и читаем y, а во втором в y записываем 1 и читаем x. Важно, что записи происходят до чтений и перед любым чтением есть запись. Что произойдет, если мы это будем запускать в параллели, в достаточном объеме и в достаточной интенсивности? Мы можем получить на выходе две 1, разные варианты сочетаний 0 и 1 или, чисто теоретически, два 0. Потому что x и y по умолчанию равны 0, и может случиться перестановка операторов, то есть изменение хода выполнения программы.

Через jcstress, фреймворк для нагрузочного тестирования Java-приложения, мы несколько раз запустили по 10 пачек автотестов. В итоге мы получили примерно 16-19% случаев, когда в результате выполнения кода вышло два 0. И менее 1% случаев, когда получились две 1.

Таким образом, этот процесс рандомный. Очевидно, что происходят некие перестановки и правила as-if-serial недостаточно для многопоточности.

Также замечу, что мы получили от 16 до 19 процентов абсолютно непредсказуемых результатов. Признайтесь, хоть кто-то ожидал увидеть два нуля? А они там случаются, и это не единичные случаи.

Конечно, мы можем добавить строгие требования и гарантии: работаем на такой-то архитектуре, запускаемся только так и никак иначе… Но тогда возникают сомнения в ключевом свойстве Java — «write once, run anywhere».

Подробнее по ссылке.