Переупорядочение заданий и добавление забора

Следующий код Java выглядит немного странным, потому что я упростил его до простых вещей. Я думаю, что у кода есть проблема с упорядочением. Я смотрю на первую таблицу в JSR-133 Cookbook, и кажется, что нормальный магазин можно переупорядочить с помощью энергозависимого хранилища в change().

Может ли присвоение m_normal в change() двигаться впереди назначения m_volatile? Другими словами, может get() вернуть null?

Каков наилучший способ решить эту проблему?

private          Object m_normal   = new Object();
private volatile Object m_volatile;

public void change() {
    Object normal;

    normal = m_normal;      // Must capture value to avoid double-read

    if (normal == null) {
        return;
    }

    m_volatile = normal;
    m_normal   = null;
}

public Object get() {
    Object normal;

    normal = m_normal;      // Must capture value to avoid double-read

    if (normal != null) {
        return normal;
    }

    return m_volatile;
}

Примечание. Я не контролирую код, в котором объявлен m_normal.

Примечание. Я запускаюсь на Java 8.

Ответ 1

TL; DR: Друзья не позволяют друзьям тратить время на то, чтобы выяснить, работает ли расистский доступ до экстремальных Concurrency желаний оптимиста. Используйте volatile и спать счастливо.

Я смотрю на первую таблицу в кулинарной книге JSR-133

Обратите внимание, что полное название "JMM Cookbook For Compiler Writers". Который задает вопрос: являются ли мы компилятором здесь или просто пользователями, пытающимися выяснить наш код? Я думаю, что последнее, поэтому мы должны действительно закрыть JKM Cookbook и открыть сам JLS. См. "Миф: JSR 133 Cookbook - это JMM Synopsis" и раздел после этого.

Другими словами, может() возвращать null?

Да, тривиально get(), соблюдая значения по умолчанию для полей, не наблюдая ничего, что change().:)

Но я предполагаю, что вопрос: разрешено ли видеть старое значение в m_volatile после завершения change() (Caveat: для некоторого понятия "завершено", потому что это означает время, а логическое время задается JMM).

Вопрос в основном, есть ли допустимое выполнение, включающее read(m_normal):null --po/hb--> read(m_volatile):null, с чтением m_normal, наблюдающим запись null в m_normal? Да, вот он: write(m_volatile, X) --po/hb--> write(m_normal, null) ... read(m_normal):null --po/hb--> read(m_volatile):null.

Чтение и запись в m_normal не упорядочены, поэтому структурных ограничений, запрещающих выполнение, которое читает оба значения нуля, не существует. Но "неустойчивый", вы бы сказали! Да, это связано с некоторыми ограничениями, но в неправильном порядке w.r.t. энергонезависимые операции, см. "Pitfall: получение и освобождение в неправильном порядке" (посмотрите на этот пример, он очень похож на то, что вы спрашивают).

Верно, что операции над m_volatile сами предоставляют некоторую семантику памяти: запись в m_volatile является "выпуском", которая "публикует" все, что произошло до нее, а чтение из m_volatile - это "приобретать" "что" все "публикуется. Если вы точно делаете вывод, как в этом посте, появляется шаблон: вы можете тривиально перемещать операции над программой" release" вверх (в любом случае это было здорово!), И вы можете тривиально перемещать операции над программой "приобретать" -друг (это было так же здорово!).

Эта интерпретация часто называется "roach motel semantics" и дает интуитивный ответ: "Могут ли эти два утверждения переупорядочиваться?"

m_volatile = value; // release
m_normal   = null;  // some other store

Ответ под семантикой roach motel "да".

Каков наилучший способ решить эту проблему?

Лучший способ решить - избегать ярких операций для начала и, таким образом, избежать всего беспорядка. Просто сделайте m_normal volatile, и все вы настроены: операции над m_normal и m_volatile будут последовательно согласованы.

Добавил бы value = m_volatile; после m_volatile = значение; предотвратить назначение m_normal до назначения m_volatile?

Итак, вопрос в том, поможет ли это:

m_volatile = value; // "release"
value = m_volatile; // poison "acquire" read 
m_normal   = null;  // some other store

В наивном мире только семантики мохнатого мохната, это могло бы помочь: казалось бы, что яд приобретает, нарушает движение кода. Но, поскольку значение этого чтения ненаблюдается, оно эквивалентно исполнению без явного чтения, и хорошие оптимизаторы будут использовать это. См. "Желательное мышление: ненаблюдаемые летучие имеют эффекты памяти" . Важно понимать, что летучие не всегда означают барьеры, даже если консервативная реализация, изложенная в JMM Cookbook для компиляторов, имеет их.

Кроме того, существует альтернатива VarHandle.fullFence(), которая может использоваться в примере, подобном этому, но она ограничена очень мощными пользователями, потому что рассуждение с барьерами становится пограничным безумным. См. "Миф: Барьеры - это разумная ментальная модель" и "Миф: переупорядочение И Commit to Memory" .

Просто сделайте m_normal volatile, и все будут спать лучше.

Ответ 2

// Must capture value to avoid double-read

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

Вставка изменчивого чтения между этими двумя:

m_volatile = normal;
tmp = m_volatile; // "poison read"
m_normal   = null;

неверно по другой причине, чем то, что Алексей Шипилев заявил в своем ответе: JMM имеет нулевые заявления об изменении порядка операций; устранение ненаблюдаемого "ядовитого чтения" никогда не изменяет порядок (никогда не устраняет барьеров) любых операций. Фактическая проблема с "poison read" находится в get().

Предположим, что m_normal читает в get() наблюдает null. Какая m_volatile запись m_volatile читать в get() не разрешена не synchronize-with? Проблема здесь в том, что разрешено появляться в общем порядке действий синхронизации до m_volatile писать в change() (получает упорядоченное с m_normal чтение в get()), поэтому наблюдайте начальную null в m_volatile, а не synchronize-with записать в m_volatile в change(). Вам понадобится "полный барьер" перед тем, как m_volatile читать в get() - энергозависимый магазин. Который вы не хотите.

Кроме того, использование VarHandle.fullFence() только в change() не решит проблему по той же причине: гонка в get() не устранена этим.


PS. Объяснение, приведенное Алексеем на https://shipilev.net/blog/2016/close-encounters-of-jmm-kind/#wishful-unobserved-volatiles, не верно. Там нет никаких исчезающих барьеров, допускаются только частичные заказы, где доступ к GREAT_BARRIER_REEF отображается как первый и последний действия синхронизации соответственно.


Вы должны начать с предположения, что get() разрешено возвращать null. Тогда конструктивно докажите, что это недопустимо. Пока у вас не будет такого доказательства, вы должны предположить, что это может произойти.

Пример, где вы можете доказать конструктивно, что null не разрешено:

volatile boolean m_v;
volatile Object m_volatile;
Object m_normal = new Object();

public void change() {
  Object normal;

  normal = m_normal;

  if (normal == null) {
    return;
  }

  m_volatile = normal; // W2
  boolean v = m_v;     // R2
  m_normal   = null;
}

public Object get() {
  Object normal;

  normal = m_normal;

  if (normal != null) {
    return normal;
  }

  m_v = true;        // W1
  return m_volatile; // R1
}

Теперь начните с предположения, что get() может вернуться null. Чтобы это произошло, get() должен наблюдать null в m_normal и m_volatile. Он может наблюдать null в m_volatile только тогда, когда R1 появляется перед W2 в общем порядке действий синхронизации. Но это означает, что R2 обязательно после W1 в этом порядке, поэтому synchronizes-with it. Это устанавливает happens-before между m_normal, считанным в get() и m_normal, записывается в change(), поэтому чтение m_normal не позволяет наблюдать, что запись null (не может наблюдать записи, которые происходят после читать) - противоречие. Поэтому исходное предположение о том, что как m_normal, так и m_volatile читает наблюдение null, неверно: по крайней мере одно из них будет наблюдать непустое значение, и метод вернет это.

Если у вас нет W1 в get(), в change() нет ничего, что могло бы заставить ребро happens-before между m_normal read и m_normal write - поэтому, заметив, что запись в get() не противоречит JMM.