Как стать автором
Обновить
88
0
Дмитрий @blinky-z

Пользователь

Отправить сообщение

Рад стараться :)

Не работал с CHM, поэтому не знаю что там да как делается.

Если вам нужно просто читать безопасно (в плане видимости) мапу из множества потоков, не изменяя ее, то сойдет любая иммутабельная мапа + safe publication с помощью, static, final или volatile поля

Рад, что вам понравилось!

А я еще раз настаиваю: чтение и if condition - это две разные операции. Между чтением и сравниванием есть некоторый интервал времени, поэтому нет разницы, вынесете вы чтение в отдельную переменную или нет. Мы ведь не CAS используем здесь. Посмотрите приведенный выше disasm - там это очень наглядно видно.

И даже если представить, что вы правы, то нас интересует только валидность memory order, а не когда произойдет что-то.

Например, оба этих порядка полностью валидны (с точки зрения Sequential Consistency):

write(x, true) -> write(initialized, true) -> read(initialized):true -> read(x): 5

write(x, true) -> read(initialized):false -> write(initialized, true)

а) сначала прочитает значение переменной instance для использования в строке 90 return instance; В этот момент может прочитаться null

б) позднее еще раз прочитает значение переменной instance для использования в строке 87 if (instance == null), и в этот момент прочитается не-null

Так получается?

Именно так. Выглядит бредово, да) Но к сожалению, независимые чтения в гонке и даже для одной и той же переменной могут быть переставлены как угодно. Вот если бы мы поставили LoadLoad барьер после первого чтения и до второго, так такого бы не случилось. Например:

public Foo reader() {
    if (instance == null) {
        return Foo.mock; // return mock value if not initialized
    }
    VarHandle.loadLoadFence();
    return instance; // can return null
}

Кстати, вот еще у Алексея Шипилева есть пункт про этот момент, где мы читаем null на повторном чтении - https://shipilev.net/blog/2016/close-encounters-of-jmm-kind/#wishful-benign-is-resilient
Сильно советую прочитать эту статью целиком - рассказывает о многих интересных фактах о JMM, которые остались за рамками моей статьи.

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

Я уже ответил по поводу volatile ниже, в коде все верно)

Но разве

...strict consistency demands that operations be seen in the order in which they were actually issued.

Не говорит как раз это? Например, в моем примере инструкция read(y) "issued" после write(y), но со стороны программы memory order такой:

write(x,1) -> read(y):0 -> write(y, 1) -> read(x):1

Да, наверное момент с 0 мог запутать, но я специально хотел сделать акцент на том, что мы можем прочитать 0 только в одном из чтений. В любом случае, уже переписал понятнее.

Насчет LoadStore/StoreLoad - перечитал несколько раз, все равно не понимаю.

К примеру, если взять LoadStore reordering, я даю такое определение:

LoadStore: переупорядочивание чтений с записями, идущими позже в порядке программы. Например, действия r, w могут выполниться в порядке w, r

Затем привожу цитату из Intel Software Developer’s Manual (8.2.2 Memory Ordering in P6 and More Recent Processor Families):

- Writes are not reordered with older reads [запрещает LoadStore reordering]

Здесь говорится, что записи не будут переупорядочены с чтениями, которые идут ранее (older) в программе. Соответственно, это запрещает LoadStore переупорядочивание, определение которого я дал выше.

И наконец, определение LoadStore барьера:

2. LoadStore

- дает гарантию, что все load операции до барьера произойдут перед store операциями после барьера

Данный барьер запрещает переупорядочивание load операций с store операциями, которые идут позже в программе, а точнее после барьера. Соответственно, этот барьер тожеи запрещает LoadStore переупорядочивание.

По поводу объекта mock:

  1. он всегда консистентен, так как static поля инициализируются во время инициализации класса, как это сказано в JLS §12.4. Initialization of Classes and Interfaces. Имплементация инициализации такова, что static поля инициализируются внутри уникального для каждого класса лока, который и дает видимость объекта - см. JLS §12.4.2. Detailed Initialization Procedure

  2. он нужен нам только для того, чтобы вернуть что-то, если мы прочитали instance слишком рано (до вызова writer)

В данном тесте следует смотреть только на работу с переменной instance.

В п.2 чтения как раз таки связаны, и они не могут быть поменяны местами, т.к. это ломает консистентность в пределах одного потока. Точнее, сами чтения-то не связаны, а вот чтение + последующая return - очень даже связаны. Если в строке 87 instance == null выполняется, инструкция return instance; не может быть выполнена, ибо они в одном потоке.

А что такое консистентность в пределах одного потока, это happens-before для действий в потоке? Но если компилятор/процессор переупорядочит эти инструкции, разве нарушится happens-before? Ведь эти чтения никак не аффектят друг друга и между ними нет никакой записи (обычно говорится, что это independent reads). Другими словами, со стороны одного потока кажется, что эта переменная не изменяется, поэтому он может переупорядочить инструкции чтения.

Запомните: чтения в гонке могут быть переупорядочены как угодно и даже для одной и той же переменной.

Именно поэтому, обычно при работе с benign data race мы вычитываем только единожды переменную в локальную, чтобы далее больше не иметь гонки и работать с локальной переменной. Например, если бы мы переписали reader так:

public Foo cachingReader() {
    Foo local = instance;
    if (local == null) {
        return Foo.mock; // return mock value if not initialized
    }
    return local; // can not return null
}

То никогда не могли бы вернуть null.

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

Конечно, не надо верить мне на слово, поэтому посмотрите на этот тест - https://github.com/blinky-z/JmmArticleHabr/blob/main/jcstress/tests/object/JmmReorderingObjectSameReadNullTest.java. Он воспроизводится даже на x86 (где запрещены LoadLoad переупорядочивания) из-за оптимизаций компилятора.

Также советую прочитать статью Алексея Шипилева Safe Publication and Safe Initialization in Java, там этот момент тоже затрагивается.

Да, и здесь я должен исправиться. SC никак не связана с eventual visibility, и в частности эта цитата совсем не означает eventual visibility - выше ответил.

Поразмыслил еще раз и понял, что я действительно не прав. Спасибо за такое полезное замечание!

Для начала, укажу на свою ошибку: "immediately visible to every thread" надо понимать так, как вы и сказали. То есть, если запись становится видной хотя бы одному треду, то и все остальные треды увидят эту запись.

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

На уровне SC модели действительно нет никакого времени, а есть лишь порядок и видимость предыдущих действий. Разберем еще раз определение в спеке:

A set of actions is sequentially consistent if all actions occur in a total order (the execution order) that is consistent with program order, and furthermore, each read r of a variable v sees the value written by the write w to v such that:

- w comes before r in the execution order, and

- there is no other write w' such that w comes before w' and w' comes before r in the execution order.

Здесь говорится следующее:

  1. SC говорит, что программа будет выполнена в execution order (он же memory order), который консистентен с program order

  2. SC говорит, что если произошла запись ранее (по порядку) в memory order, то мы увидим эту запись. Но SC не говорит, когда будет видна запись

Sequential consistency можно имплементировать или исполняя все на одном ядре, или исполняя на многоядерном процессоре, но имея блокирующий "switch", который разрешает доступ к памяти только ядру (треду) за раз, причем в program order:

Теперь снова вернемся к Dekker алгоритму и рассмотрим его в рамках sequential consistency. Благодаря тому, что в Dekker алгоритме x = 1 PO r1 = y, y = 1 PO r2 = x (PO = program order), то SC соблюдет этот порядок и выполнит чтения только после одной из записей.

Но здесь действительно есть подвох: пропагация записи может занять и год, и два, и вообще не случиться. Со стороны модели главное то, что и чтение не будет выполнено, пока не завершится запись. Но нам то этого не достаточно - мы хотим увидеть эту запись когда-нибудь.

Таким образом, если мы не гарантируем eventual visibility для записей, то это не нарушит sequential consistency модель.

В итоге хочу сказать, что вы правы. Я поискал по всей спеке слова "visibility" и "eventual" и не нашел ни единого подтверждения этому.

Я это вижу так, что в JMM только некоторый набор операций может быть быть выполнен в SC memory order. Если есть другие data race, то для них не гарантируется никакого консистентного порядка, но для связанных happens-before гарантируется. Кажется, на этом делает акцент и спека:

A set of actions is sequentially consistent if all actions occur in a total order

Все-таки Sequential Consistency - это про обеспечение такого memory order, который консистентен с порядком действий всех тредов в программе без привязки к реальному времени выполнения. Вы правы насчет видимости в strict consistency - это действительная самая строгая гарантия, но совсем не нужная в Memory Model.

Как я это понимаю. Возьмем тот же Dekker алгоритм. Вот его program order тредов:

Thread 0 | Thread 1
-------------------
 x = 1   | y = 1
 r1 = y  | r2 = x

Для него, например, среди множества других валидны такие SC execution order (он же memory order):

x = 1
y = 1
r1 = y // 1
r2 = x // 1
------
x = 1
r1 = y // 0
y = 1
r2 = x // 1

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

Однако давайте все-таки заглянем на уровень процессорных инструкций. Возьмем для разбора второй memory order. Для него порядок выполнения инструкций мог быть таким:

CORE 0     : write(x, 1)        read(y):0
CORE 0 time: |--------->--------|------->
CORE 1     :               write(y, 1)           read(x):1
CORE 1 time: --------------|--------------->-----|--------->
time       : --------------------------------------------->

Как видите, порядок выполнения инструкций не совпадает с memory order. Например, read(y) был вызван после write(y), но все равно обнаруживает 0. Это вызвано тем, что write операции занимают некоторое время (что включает в себя и полную пропагацию записи на все локальные кэши процессора).

И наоборот, если бы в теории мы имели strict consistency модель, то операции записи должны были бы завершаться мгновенно. Другими словами, memory order должен быть полностью эквивалентен порядку выполнения инструкций:

CORE 0: write(x, 1) -> read(y):1
CORE 1:     write(y, 1) -> read(x):1

Но это не возможно просто физически. Об этом же говорится в wiki SC:

The sequential consistency is weaker than strict consistency, which requires a read from a location to return the value of the last write to that location; strict consistency demands that operations be seen in the order in which they were actually issued.

  1. Не обращайте внимания на mock - с ним все нормально. Это лишь заглушка, чтобы вернуть что-то из метода, если reader сработал раньше, чем writer. В тесте это @Outcome(id = "-1", expect = Expect.ACCEPTABLE, desc = "Object is not seen")

  2. Да, это очень тонкий момент. Суть в том, что может произойти LoadLoad reordering и второе чтение (которое мы возвращаем из метода) произойдет раньше, чем первое, так как они не связаны. К сожалению, у меня его не получилось воспроизвести этот reordering в этом тесте, поэтому я написал отдельный тест - https://github.com/blinky-z/JmmArticleHabr/blob/main/jcstress/tests/object/JmmReorderingObjectSameReadNullTest.java. В нем LoadLoad воспроизводится даже на x86 из-за переупорядочивания инструкций в компиляторе

Ну и последний кейс, который воспроизводится в этом тесте - это обнаружение неконсистентного состония объекта. Можно наивно предположить, что если мы увидели non-null ссылку на объект, то увидим и внутренние поля объекта, но это не так. Например, writer мог вызвать конструктор после записи адреса в ссылку. То есть, порядок инструкций мог быть такой после переупорядочивания:

|        Writer                                 |      Reader       |
|:---------------------------------------------:|:-----------------:|
| _instance = <new> /* memory allocation */     |                   |
| instance = _instance /* publish */            |                   |
|                                               | r1 = instance     |
|                                               | r2 = r1.x /* 0 */ |
| _instance.<init> /* constructor invocation */ |                   |

Отредактировал этот момент, надеюсь теперь стало более понятно)

В разделе "volatile", код с volatile неверный, так так если поток reader читает в r1, потом поток writer выполняет "initialized = true; /* W2 /", по проверка в reader "if (r1)" не пройдёт, хотя x уже гарантировано имеет значение 5. Уберите ненужное присваивание "boolean r1 = initialized; / R1 */" и сразу пишите "if (initialized)".

Вы не поверите, но это абсолютно одинаковый код :) Чтобы выполнить if-условие, необходимо сначала выполнить чтение. Нет разницы, вынесем мы это чтение в отдельную переменную или нет.

Чтобы подтвердить свои слова, приведу дизассемблер обоих вариантов:

    public static void reader1() {
        boolean r1 = initialized; /* R1 */
        if (r1) {
            int r2 = x; /* R2 */
        }

        /*
          x86:
              0x000002239b45b5de:   movsx  esi,BYTE PTR [rsi+0x74]      ;*getstatic initialized {reexecute=0 rethrow=0 return_oop=0}
                                                                        ; - jit_disassembly.JmmVolatileConditionRead::reader1@0 (line 14)
              0x000002239b45b5e2:   cmp    esi,0x0
              0x000002239b45b5e5:   movabs rsi,0x223b7c037e0            ;   {metadata(method data for {method} {0x00000223b7c032d8} 'reader1' '()V' in 'jit_disassembly/JmmVolatileConditionRead')}
              0x000002239b45b5ef:   movabs rdi,0x110
              0x000002239b45b5f9:   je     0x000002239b45b609
              0x000002239b45b5ff:   movabs rdi,0x120
              0x000002239b45b609:   mov    rbx,QWORD PTR [rsi+rdi*1]
              0x000002239b45b60d:   lea    rbx,[rbx+0x1]
              0x000002239b45b611:   mov    QWORD PTR [rsi+rdi*1],rbx
              0x000002239b45b615:   je     0x000002239b45b628           ;*ifeq {reexecute=0 rethrow=0 return_oop=0}
                                                                        ; - jit_disassembly.JmmVolatileConditionRead::reader1@5 (line 15)
              0x000002239b45b61b:   movabs rsi,0x7114bd448              ;   {oop(a 'java/lang/Class'{0x00000007114bd448} = 'jit_disassembly/JmmVolatileConditionRead')}
              0x000002239b45b625:   mov    esi,DWORD PTR [rsi+0x70]     ;*getstatic x {reexecute=0 rethrow=0 return_oop=0}
                                                                        ; - jit_disassembly.JmmVolatileConditionRead::reader1@8 (line 16)
        */
    }

    public static void reader2() {
        if (initialized) { /* R1 */
            int r2 = x; /* R2 */
        }

        /*
          x86:
              0x000002239b45b25e:   movsx  esi,BYTE PTR [rsi+0x74]      ;*getstatic initialized {reexecute=0 rethrow=0 return_oop=0}
                                                                        ; - jit_disassembly.JmmVolatileConditionRead::reader2@0 (line 21)
              0x000002239b45b262:   cmp    esi,0x0
              0x000002239b45b265:   movabs rsi,0x223b7c03930            ;   {metadata(method data for {method} {0x00000223b7c03388} 'reader2' '()V' in 'jit_disassembly/JmmVolatileConditionRead')}
              0x000002239b45b26f:   movabs rdi,0x110
              0x000002239b45b279:   je     0x000002239b45b289
              0x000002239b45b27f:   movabs rdi,0x120
              0x000002239b45b289:   mov    rbx,QWORD PTR [rsi+rdi*1]
              0x000002239b45b28d:   lea    rbx,[rbx+0x1]
              0x000002239b45b291:   mov    QWORD PTR [rsi+rdi*1],rbx
              0x000002239b45b295:   je     0x000002239b45b2a8           ;*ifeq {reexecute=0 rethrow=0 return_oop=0}
                                                                        ; - jit_disassembly.JmmVolatileConditionRead::reader2@3 (line 21)
              0x000002239b45b29b:   movabs rsi,0x7114bd448              ;   {oop(a 'java/lang/Class'{0x00000007114bd448} = 'jit_disassembly/JmmVolatileConditionRead')}
              0x000002239b45b2a5:   mov    esi,DWORD PTR [rsi+0x70]     ;*getstatic x {reexecute=0 rethrow=0 return_oop=0}
                                                                        ; - jit_disassembly.JmmVolatileConditionRead::reader2@6 (line 22)
        */
    }

Ссылка на программу, которую я тестировал (также привел там disasm и для arm64) - https://gist.github.com/blinky-z/bd0143794421878ee10dd4846da59df3

Стандарт нигде не говорит явно, что запись в volatile переменную должна стать видимой. Однако этот факт неявно исходит из следующего определения sequential consistency в JMM:

A set of actions is sequentially consistent if all actions occur in a total order (the
execution order) that is consistent with program order, and furthermore, each read
r of a variable v sees the value written by the write w to v such that:

w comes before r in the execution order, and there is no other write w' such that w comes before w' and w' comes before r in the execution order.

Sequential consistency is a very strong guarantee that is made about visibility and
ordering in an execution of a program. Within a sequentially consistent execution,
there is a total order over all individual actions (such as reads and writes) which is
consistent with the order of the program, and each individual action is atomic and
is immediately visible to every thread.

Таким образом, если мы пометим переменную как volatile, то нам гарантируется видимость изменений.

Разберем на примере. Обозначим запись в переменную как write(x, V) и чтение как read(x):V, где V - записываемое или читаемое значение. Пусть где-то в программе мы пишем значение 1 в shared переменную x и где-то читаем эту переменную. Тогда возможны такие execution order:

...read(x):0 -> ... -> write(x, 1)
...write(x, 1) -> ... -> read(x):?

Первый случай нас не очень интересует. А вот во втором случае, если мы не пометим переменную как volatile, нам не гарантируется видимости изменений, так как между записью и чтением нет отношения happens-before, а значит и нет sequential consistency. Но если бы мы пометили переменную как volatile, то между этими действиями был бы установлен happens-before, а значит есть sequential consistent, а значит есть и видимость изменений.

Все верно. Другими словами, выполнение программы начинается всегда с какой-либо записи. Если мы прочитали (0, 0), то это означало бы, что выполнение программы началось с чтений, но это противоречит порядку программы.

К сожалению, JMM не дает sequential consistency по умолчанию. Если говорить просто, sequential consistency - это когда мы берем действия всех тредов, как они идут в порядке программы, и просто перемешиваем как угодно.

JMM дает более слабую, "data race free", гарантию - sequential consistency гарантируется только в том случае, если программа не имеет data race. В вышеприведенном примере мы явно имеем data race, так как читаем и пишем в shared переменную из разных тредов без установки отношения happens-before

Информация

В рейтинге
Не участвует
Откуда
Санкт-Петербург, Санкт-Петербург и область, Россия
Дата рождения
Зарегистрирован
Активность