Согласованность Java - кеш между последовательными параллельными потоками?
Рассмотрим следующий фрагмент кода (который не совсем то, что кажется на первый взгляд).
static class NumberContainer {
int value = 0;
void increment() {
value++;
}
int getValue() {
return value;
}
}
public static void main(String[] args) {
List<NumberContainer> list = new ArrayList<>();
int numElements = 100000;
for (int i = 0; i < numElements; i++) {
list.add(new NumberContainer());
}
int numIterations = 10000;
for (int j = 0; j < numIterations; j++) {
list.parallelStream().forEach(NumberContainer::increment);
}
list.forEach(container -> {
if (container.getValue() != numIterations) {
System.out.println("Problem!!!");
}
});
}
Мой вопрос: чтобы быть абсолютно уверенным, что "Проблема !!!" не будет напечатано, должна ли переменная "значение" в классе NumberContainer быть помечена?
Позвольте мне объяснить, как я это понимаю сейчас.
-
В первом параллельном потоке NumberContainer-123 (скажем) увеличивается на ForkJoinWorker-1 (скажем). Таким образом, у ForkJoinWorker-1 будет обновленный кеш-номер NumberContainer-123.value, который равен 1. (Другие работники fork-join, однако, будут иметь устаревшие кеши NumberContainer-123.value - они будут сохраните значение 0. В какой-то момент эти кэши других рабочих будут обновлены, но это не произойдет сразу.)
-
Первый параллельный поток завершается, но общие потоки рабочих пулов для вилки не убиваются. Затем запускается второй параллельный поток, используя одни и те же общие рабочие потоки пула соединений fork-join.
-
Предположим, что во втором параллельном потоке задача инкремента NumberContainer-123 назначается ForkJoinWorker-2 (скажем). ForkJoinWorker-2 будет иметь собственное кешированное значение NumberContainer-123.value. Если длительное время прошло между первым и вторым приращениями NumberContainer-123, то предположительно ForkJoinWorker-2 кэш-памяти NumberContainer-123.value будет актуальным, то есть значение 1 будет сохранено, и все будет хорошо. Но что, если время, прошедшее между первым и вторым приращениями, если NumberContainer-123 чрезвычайно короткое? Тогда, возможно, кэш ForkJoinWorker-2 для NumberContainer-123.value может быть устаревшим, сохраняя значение 0, вызывая сбой кода!
Является ли мое описание выше правильным? Если да, может кто-нибудь, пожалуйста, скажите мне, какая временная задержка между двумя приращающимися операциями требуется для обеспечения согласованности кеша между потоками? Или если мое понимание ошибочно, то может кто-нибудь, пожалуйста, скажите мне, какой механизм заставляет поточно-локальные кэши "промываться" между первым параллельным потоком и вторым параллельным потоком?
Ответы
Ответ 1
Это не должно задерживаться. Когда вы выйдете из ParallelStream
forEach
, все задачи будут завершены. Это устанавливает взаимосвязь между событиями-до и между приращением и концом forEach
. Все вызовы forEach
упорядочены путем вызова из одного потока, и проверка аналогичным образом происходит после всех вызовов forEach
.
int numIterations = 10000;
for (int j = 0; j < numIterations; j++) {
list.parallelStream().forEach(NumberContainer::increment);
// here, everything is "flushed", i.e. the ForkJoinTask is finished
}
Вернемся к вашему вопросу о потоках, трюк здесь, потоки не имеют значения. Модель памяти зависит от отношения "бывшее-до" и гарантирует выполнение задачи fork-join - до отношения между вызовом forEach
и телом операции, а также между телом операции и возвратом из forEach
(даже если возвращаемое значение равно Void
)
См. Также " Видимость памяти" в приложении "Вилка"
Как отмечает @erickson в комментариях,
Если вы не можете установить правильность через случившееся - перед отношениями, никакого количества времени "достаточно". Это не вопрос времени на стене; вам необходимо правильно применить модель памяти Java.
Более того, думать об этом с точки зрения "промывки" памяти неправильно, так как есть много других вещей, которые могут повлиять на вас. Промывка, например, тривиальна: я не проверял, но могу поспорить, что на завершение задачи есть только барьер памяти; но вы можете получить неправильные данные, потому что компилятор решил оптимизировать энергонезависимое считывание (переменная не является изменчивой и не изменяется в этом потоке, поэтому она не изменится, поэтому мы можем выделить ее в регистр, et voila), переупорядочить код любым способом, разрешенным отношением "происходить-до" и т.д.
Самое главное, что все эти оптимизации могут и со временем меняться, поэтому, даже если вы перешли к сгенерированной сборке (которая может меняться в зависимости от шаблона нагрузки) и проверили все барьеры памяти, это не гарантирует, что ваш код будет работать, если вы не может доказать, что ваши чтения происходят после ваших записей, и в этом случае модель Java Memory Model на вашей стороне (если в JVM нет ошибки).
Что касается большой боли, то это очень ForkJoinTask
задача ForkJoinTask
сделать синхронизацию тривиальной, так что наслаждайтесь. Это было сделано (по-видимому), отметив java.util.concurrent.ForkJoinTask#status
volatile, но эту деталь реализации вы не должны заботиться или полагаться.