Подтвердить что ты не робот

Java 8 Небезопасно: инструкции xxxFence()

В Java 8 в класс Unsafe добавлены три инструкции по защите памяти (источник):

/**
 * Ensures lack of reordering of loads before the fence
 * with loads or stores after the fence.
 */
void loadFence();

/**
 * Ensures lack of reordering of stores before the fence
 * with loads or stores after the fence.
 */
void storeFence();

/**
 * Ensures lack of reordering of loads or stores before the fence
 * with loads or stores after the fence.
 */
void fullFence();

Если мы определяем барьер памяти следующим образом (который я считаю более или менее понятным):

Рассмотрим X и Y как типы/классы операций, подлежащие переупорядочению,

X_YFence() является инструкцией барьера памяти, которая гарантирует, что все операции типа X перед барьером завершены до любой операции типа Y после запуска барьера.

Теперь мы можем "сопоставить" имена барьеров от Unsafe к этой терминологии:

  • loadFence() становится load_loadstoreFence();
  • storeFence() становится store_loadStoreFence();
  • fullFence() становится loadstore_loadstoreFence();

Наконец, мой вопрос - почему мы не имеем load_storeFence(), store_loadFence(), store_storeFence() и load_loadFence()?

Мое предположение было бы - они действительно не нужны, но я не понимаю, почему на данный момент. Поэтому я хотел бы знать причины, по которым они не добавляются. Угадайся об этом тоже приветствуем (надеюсь, что это не приведет к тому, что этот вопрос будет оффтопическим, как основанный на мнениях).

Спасибо заранее.

4b9b3361

Ответ 1

Резюме

Процессорные ядра имеют специальные буферы для упорядочения памяти, которые помогают им выполнять внеочередное выполнение. Они могут быть (и обычно) разделены для загрузки и хранения: LOB для буферов порядка загрузки и SOB для буферов порядка хранения.

Операции фехтования, выбранные для Unsafe API, были выбраны на основе следующего предположения: базовые процессоры будут иметь отдельные буферы порядка загрузки (для переупорядочивания нагрузок), буферы хранилища заказов (для магазинов переупорядочения).

Поэтому, исходя из этого предположения, с точки зрения программного обеспечения вы можете запросить одну из трех вещей из CPU:

  • Пусто LOB (loadFence): означает, что никакие другие инструкции не начнут выполняться на этом ядре, пока ВСЕ записи LOB не будут обработаны. В x86 это LFENCE.
  • Пустой SOB (storeFence): означает, что никакие другие команды не начнут выполняться на этом ядре, пока не будут обработаны ВСЕ записи в SOB. В x86 это СУДЬБА.
  • Очистить как LOB, так и SOB (fullFence): означает оба указанных выше. В x86 это MFENCE.

В действительности каждая конкретная архитектура процессора обеспечивает различные гарантии упорядочения памяти, которые могут быть более строгими или более гибкими, чем выше. Например, архитектура SPARC может изменять порядок загрузки и хранения, тогда как x86 этого не сделает. Кроме того, существуют архитектуры, где LOB и SOB нельзя контролировать индивидуально (т.е. Возможен только полный забор). В обоих случаях:

  • когда архитектура более гибкая, API просто не предоставляет доступ к комбинациям секвенсора "laxer" в зависимости от выбора.

  • когда архитектура является более строгой, API просто реализует более строгую гарантию последовательности во всех случаях (например, все 3 вызова фактически и будут реализованы как полный забор)

Причина выбора конкретного API объясняется в JEP в соответствии с предоставленными ответами assylias, которые на 100% находятся на месте. Если вы знаете о упорядоченности памяти и когерентности кеша, достаточно ответить на запрос assylias. Я считаю, что тот факт, что они соответствуют стандартизованной инструкции в С++ API, является основным фактором (значительно упрощает реализацию JVM): http://en.cppreference.com/w/cpp/atomic/memory_order По всей вероятности, фактическая реализация будет вызовите в соответствующий С++ API вместо использования специальной инструкции.

Ниже у меня есть подробное объяснение с примерами на основе x86, которые предоставят весь контекст, необходимый для понимания этих вещей. Фактически, демаркация (раздел ниже отвечает на другой вопрос: "Можете ли вы представить основные примеры того, как работают ограждения памяти для управления когерентностью кэша в архитектуре x86?"

Причиной этого является то, что я сам (разработчик программного обеспечения, а не аппаратный разработчик) не понимал, что такое переупорядочение памяти, пока я не узнал конкретные примеры того, как когерентность кэша фактически работает в x86. Это дает бесценный контекст для обсуждения заграждений памяти в целом (для других архитектур). В конце я немного обсуждаю SPARC, используя знания, полученные из примеров x86.

Ссылка [1] является еще более подробным объяснением и имеет отдельный раздел для обсуждения каждого из: x86, SPARC, ARM и PowerPC, поэтому он отлично читается, если вас интересует более подробная информация.


Пример архитектуры

x86

x86 предоставляет 3 типа инструкций по фехтованию: LFENCE (загрузочный забор), SFENCE (забор для магазина) и MFENCE (забор для загрузки), поэтому он сопоставляет 100% с Java API.

Это связано с тем, что x86 имеет отдельные буферы порядка загрузки (LOB) и буферы порядка хранения (SOB), поэтому инструкции LFENCE/SFENCE применяются к соответствующему буферу, тогда как MFENCE применяется к обоим.

SOB используются для хранения исходящего значения (от процессора к системе кэширования), в то время как протокол когерентности кеша работает, чтобы получить разрешение на запись в строку кэша. LOB используются для хранения запросов недействительности, так что недействительность может выполняться асинхронно (уменьшает остановку на принимающей стороне в надежде на то, что исполняемый код там действительно не понадобится).

Магазины вне порядка и SFENCE

Предположим, что у вас есть двухпроцессорная система с двумя CPU, 0 и 1, выполняющая подпрограммы ниже. Рассмотрим случай, когда строка кэша, содержащая failure, первоначально принадлежит CPU 1, тогда как строка кэша, содержащая shutdown, первоначально принадлежит CPU 0.

// CPU 0:
void shutDownWithFailure(void)
{
  failure = 1; // must use SOB as this is owned by CPU 1
  shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
  while (shutdown == 0) { ... }
  if (failure) { ...}
}

В отсутствие ограждения магазина CPU 0 может сигнализировать об отключении из-за сбоя, но CPU 1 выйдет из цикла и НЕ попадает в блок обработки ошибок, если блок.

Это связано с тем, что CPU0 будет записывать значение 1 для failure в буфер порядка хранения, также отправляя сообщение о связности кеша, чтобы получить эксклюзивный доступ к строке кэша. Затем он переходит к следующей инструкции (при ожидании эксклюзивного доступа) и обновляет флаг shutdown немедленно (эта линия кэша принадлежит исключительно CPU0 уже, поэтому нет необходимости вести переговоры с другими ядрами). Наконец, когда он позже получит сообщение с подтверждением недействительности от CPU1 (относительно failure), он перейдет к обработке SOB для failure и напишет значение в кеш (но порядок теперь отменен).

Вставка storeFence() будет исправлять:

// CPU 0:
void shutDownWithFailure(void)
{
  failure = 1; // must use SOB as this is owned by CPU 1
  SFENCE // next instruction will execute after all SOBs are processed
  shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
  while (shutdown == 0) { ... }
  if (failure) { ...}
}

Последний аспект, заслуживающий упоминания, заключается в том, что x86 имеет функцию пересылки хранилища: когда ЦП записывает значение, которое застревает в SOB (из-за когерентности кэша), оно может впоследствии попытаться выполнить команду загрузки для того же адреса ПЕРЕД SOB обрабатывается и доставляется в кеш. Поэтому процессоры будут обращаться к SOA PRIOR для доступа к кешу, поэтому значение, полученное в этом случае, является последним записанным значением из SOB. это означает, что хранилища из этого ядра никогда не могут быть переупорядочены с последующими нагрузками из этого ядра независимо от того, что.

Нестандартные нагрузки и LFENCE

Теперь предположим, что у вас есть ограждение магазина на месте и счастливы, что shutdown не может обогнать failure на своем пути к CPU 1 и сосредоточиться на другой стороне. Даже при наличии забора магазина есть сценарии, в которых происходит неправильное. Рассмотрим случай, когда failure находится в обоих кэшах (shared), тогда как shutdown присутствует только в кеше CPU0 и принадлежит ему. Плохие вещи могут произойти следующим образом:

  • CPU0 записывает 1 в failure; Он также отправляет сообщение CPU1, чтобы аннулировать его копию разделяемой строки кэша как часть протокола когерентности кеша.
  • CPU0 выполняет SFENCE и киоски, ожидая, когда SOB, используемый для failure, будет зафиксирован.
  • CPU1 проверяет shutdown из-за цикла while и (понимая, что отсутствует значение) отправляет сообщение когерентности кеша, чтобы прочитать значение.
  • CPU1 получает сообщение от CPU0 на шаге 1, чтобы аннулировать failure, отправив ему немедленное подтверждение. ПРИМЕЧАНИЕ: это выполняется с использованием очереди недействительности, поэтому на самом деле она просто вводит примечание (выделяет запись в своем LOB), чтобы позже выполнить недействительность, но фактически не выполняет ее перед отправкой подтверждения.
  • CPU0 получает подтверждение для failure и переходит к SFENCE к следующей команде
  • CPU0 записывает 1 в shutdown без использования SOB, потому что он уже полностью владеет линией кэша. никакое дополнительное сообщение для недействительности не отправляется, поскольку строка кэша является исключительной для CPU0
  • CPU1 получает значение shutdown и передает его в свой локальный кеш, переходя к следующей строке.
  • CPU1 проверяет значение failure для оператора if, но поскольку очередь invalidate (примечание LOB) еще не обработана, она использует значение 0 из своего локального кэша (не входит в блок).
  • CPU1 обрабатывает очередь invalidate и обновляет failure до 1, но уже слишком поздно...

То, что мы называем буферами порядка загрузки, является actaully очередностью запросов о недействительности, а вышеописанное может быть исправлено с помощью:

// CPU 0:
void shutDownWithFailure(void)
{
  failure = 1; // must use SOB as this is owned by CPU 1
  SFENCE // next instruction will execute after all SOBs are processed
  shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
  while (shutdown == 0) { ... }
  LFENCE // next instruction will execute after all LOBs are processed
  if (failure) { ...}
}

Ваш вопрос на x86

Теперь, когда вы знаете, что делают SOB/LOB, подумайте о комбинациях, которые вы упомянули:

loadFence() becomes load_loadstoreFence();

Нет, загрузочный забор ожидает обработки LOB, по существу опуская очередь недействительности. Это означает, что все последующие загрузки будут иметь обновленные данные (без повторного заказа), так как они будут извлечены из подсистемы кеша (которая является когерентной). Магазины CANNNOT должны быть переупорядочены с последующими нагрузками, потому что они не проходят через LOB. (и, кроме того, экспедирование в хранилище заботится о локально модифицированных линиях cachce). С точки зрения ЭТОГО конкретного ядра (того, которое выполняется на грузовом ограждении), магазин, который следует за ограждением нагрузки, будет выполняться ПОСЛЕ того, что все регистры имеют загруженные данные. Об этом нет.

load_storeFence() becomes ???

Нет необходимости в load_storeFence, поскольку это не имеет смысла. Чтобы сохранить что-то, вы должны вычислить его с помощью ввода. Чтобы получить вход, вы должны выполнить нагрузки. Магазины будут происходить с использованием данных, полученных из нагрузок. Если вы хотите удостовериться, что вы видите обновленные значения всех других процессоров при загрузке, используйте loadFence. Для нагрузок после того, как система хранения забора заботится о последовательном порядке.

Все остальные случаи похожи.


SPARC

SPARC еще более гибкий и может переупорядочивать магазины с последующими нагрузками (и нагрузками с последующими магазинами). Я не был так знаком с SPARC, поэтому мой GUESS состоял в том, что нет перенаправления хранилища (SOB не обрабатываются при перезагрузке адреса), поэтому возможны "грязные чтения". На самом деле я ошибался: я нашел архитектуру SPARC в [3], и реальность такова, что пересылка хранилища имеет резьбу. Из раздела 5.3.4:

Все нагрузки проверяют буфер хранилища (только тот же поток) для чтения после записи (RAW). Полный RAW возникает, когда адрес dword загрузки совпадает с адресом хранилища в STB и все байты загрузки действительны в буфере хранилища. Частичный RAW возникает, когда адреса dword совпадают, но все байты недействительны в буфере хранилища. (Например, ST (хранилище слов), за которым следует LDX (загрузка dword) на один и тот же адрес, приводит к частичной RAW, поскольку полное dword не находится в записи буфера хранилища.)

Итак, разные потоки обращаются к различным буферам хранилища, следовательно, возможность грязного чтения после магазинов.


Ссылки

[1] Шлюзы памяти: аппаратное обеспечение для программных хакеров, технологический центр Linux, IBM Beaverton http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.07.23a.pdf

[2] Руководство по разработке программного обеспечения Intel® 64 и IA-32, том 3A http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf

[3] Спецификация микроархитектуры ядра OpenSPARC T2 http://www.oracle.com/technetwork/systems/opensparc/t2-06-opensparct2-core-microarch-1537749.html

Ответ 2

Хорошим источником информации является сам JEP 171.

Обоснование:

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

Реализация (извлечение):

для версий среды С++ (в prims/unsafe.cpp), реализующих с помощью существующих методов OrderAccess:

    loadFence:  { OrderAccess::acquire(); }
    storeFence: { OrderAccess::release(); }
    fullFence:  { OrderAccess::fence(); }

Другими словами, новые методы тесно связаны с тем, как ограждения памяти реализованы на уровне JVM и CPU. Они также соответствуют инструкциям памяти, доступным на С++, языке, на котором реализована точка доступа.

Более тонкий подход, вероятно, был бы осуществим, но преимущества не очевидны.

Например, если вы посмотрите на таблицу инструкций процессора в JSR 133 Cookbook, вы увидите, что LoadStore и LoadLoad сопоставляются с теми же инструкциями на большинстве архитектур, т.е. обе являются фактически инструкциями Load_LoadStore. Таким образом, наличие одной команды Load_LoadStore (loadFence) на уровне JVM кажется разумным конструктивным решением.

Ответ 3

Документ для storeFence() неверен. См. https://bugs.openjdk.java.net/browse/JDK-8038978

loadFence() - это LoadLoad плюс LoadStore, поэтому полезный часто называемый захват забора.

storeFence() - это хранилище StoreStore плюс LoadStore, поэтому его часто называют выпуском забора.

LoadLoad LoadStore StoreStore - дешевые ограждения (nop на x86 или Sparc, дешево на Power, может быть, дороже на ARM).

IA64 имеет разные инструкции для семантики получения и выпуска.

fullFence() - LoadLoad LoadStore StoreStore plus StoreLoad.

Забор StordLoad дорог (почти на всем процессоре), почти такой же дорогой, как и полный забор.

Это оправдывает дизайн API.