Согласованность 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, но эту деталь реализации вы не должны заботиться или полагаться.