Cache coherence — это uniformity (целостность, не мог вспомнить слово на русском) данных, хранящихся в локальных кэшах для разделяемого ресурса. Проблемы с когерентностью возникают, когда один ресурс кэшируется в нескольких местах, в частности это происходит в multiprocessing системах: например, оба потока читают число 0 из одной и той же ячейки памяти и сохраняют её себе в кэш. После чего они по очереди добавляют к своему значению единицу. При отсутствии когерентности памяти каждый в итоге запишет единицу в main memory.
Когерентность кеша требует чтобы:
Короче, это всё гарантирует то, что всё будет работать так, как мы интуитивно хотим, то есть, чтобы операции чтения/записи потоков были максимально гранулированными и после записи в кэш сразу же производилась запись в main memory. Но на практике это всё реализуется как-то по другому, более по-умному.
Memory barrier — это специальная инструкция, которая заставляет CPU или компилятор делать так, чтобы все обращения к памяти до барьера были завершены к началу выполнения инструкций (но там вроде есть разные виды барьеров, которые разделяют записи и чтения). Это нужно при реализации многопоточных штук, потому что процессор может переставлять инструкции так, чтобы в рамках одного потока проблем не возникало, но при этом в случае с двумя может случиться вот такое:
# первый поток
while (f == 0);
# здесь нужен memory barrier, потому что иначе print может быть переставлен с циклом
# ожидания и тогда выведется ещё потенциально не заданный икс
print x;
# второй поток
x = 42;
# здесь снова нужен барьер, потому что иначе флаг f может быть выставлен до того, как будет
# присвоено значение иксу
f = 1;
(ссылка на статью на рбрыварабаре)
Модель памяти Java (Java Memory Model, JMM) определяет то, каким образом потоки взаимодейтсвуют друг с другом и как используют разделяемые данные. Тут же определяются всякие оптимизации, которые может выполнить компилятор.
Atomicity. Ну, это уже упоминалось: операции записи могут быть неатомарными на некоторых платформах, так что могут быть прерваны в процессе.
Visibility. Как я понял, раньше у каждого потока был свой явный кэш, который в частности использовался как буфер при операциях чтения/записи, и при каких-то условиях этот кэш мог синхронизироваться с main memory (cache coherence), но в новой JMM, видимо, от этого всего остались только сами условия, при которых один поток видит изменения, выполненные другим потоком, а то, как оно реализовано внутри — не имеет значения.
Reordering. Перестановка инструкций для оптимизации, с одним потоком незаметно, но из других потоков инструкции видны в другом порядке, не в том, в котором ты их написал. (Ну, в качестве примера — тот кусок кода в memory barrier’ах.)
Happens-before отношение. Это то, с помощью чего задаются правила, по которому компилятору запрещено выполнять reordering. Если есть поток X и поток Y (может быть, что X = Y), в потоке X выполняется операция A, в потоке Y — операция B. A happens-before B означает, что во время выполнения B и после её выполнения из потока Y будут видны изменения, которые выполнились потоком X до операции A и то, что повлекла операция A.
Здесь на схеме зелёное в потоке X — то, что видно из зелёной части в потоке Y. Такое отношение будет также транзитивным (ну, это очевидно просто интуитивно).
Releasing монитора happens-before acquiring этого же самого монитора. По сути это интуитивная логика, по которой работают lock’и: второй поток может получить lock только после того, как другой поток его отпустит. При не надо беспокоиться по поводу конструкторов монитора: There is no practical need for a constructor to be synchronized, because it would lock the object under construction, which is normally not made available to other threads until all constructors for the object have completed their work.
Тем не менее, вроде как всё равно можно получить ссылку на объект с ещё не завершённым конструктором из другого потока, но это что-то экзотическое (внутри самого конструктора создаётся поток, который использует this
), и вроде как решается с помощью простого synchronized(this)
(сылсылкла).
Запись в volatile
переменную happens-before чтение из той же переменной. То есть чтение из volatile
переменной будет всегда возвращать актуальное на текущий момент значение. Благодаря этому можно реализовать double-checked locking pattern (который, впрочем, считается анти-паттерном, потому что всё очень легко заруинить тут):
public class Keeper {
private volatile Data data = null;
public Data getData() {
// просто минус одно обращение к volatile переменной
Data localData = data;
// если volatile data != null, то это означает, что там актуальное значение,
// которое можно вернуть
if (localData == null)
synchronized (this) {
// синхронизация нужна только в том случае, если нам необходимо создать
// новый instance объекта Data, чтобы никто не мог в это время что-то
// сделать c data, в частности, никто не мог вызвать getData из двух
// потоков одновременно
if (data == null) data = new Data();
}
return localData;
}
}
Смысл в том, что volatile
переменные работают быстрее чем lock объекты, так что, если в них нет необходимости, можно использовать этот шорткат. Если бы не volatile
, то не было бы гарантий, что в data
содержится актуальное значение при проверке data == null
, так что можно случайно два раза инициализировать объект Data
. Раньше (судя по всему) в старых версиях JMM была ещё проблема в том, что через переменную data
в этом случае из другого потока можно было получить доступ к объекту, у которого ещё не завершён конструктор, но сейчас вроде как такого нет.
Запись в final
поле при конструирование объекта его содержащего происходит до того, как кто-либо получит доступ к этому полю вне конструктора. Кроме того, если какие-то другие объекты достижимы из этого final
поля, то они обладают тем же свойством.
На самом деле, существует несколько вариантов барьеров. %Первое слово в названии%
— действие, эффект которого будет виден прежде, чем будет виден эффект типа %второе слово в названии%
:
Да и тут немного важно то, что JMM не гарантирует того, в какой момент будет запись в main memory, например. Memory barriers are only indirectly related to higher-level notions described in memory models such as “acquire” and “release”. And memory barriers are not themselves “synchronization barriers”. And memory barriers are unrelated to the kinds of “write barriers” used in some garbage collectors. Memory barrier instructions directly control only the interaction of a CPU with its cache, with its write-buffer that holds stores waiting to be flushed to memory, and/or its buffer of waiting loads or speculatively executed instructions. These effects may lead to further interaction among caches, main memory and other processors. But there is nothing in the JMM that mandates any particular form of communication across processors so long as stores eventually become globally performed; i.e., visible across all processors, and that loads retrieve them when they are visible.
2nd op | 2nd op | 2nd op | 2nd op | |
---|---|---|---|---|
1st op | Normal Load | Normal Store | Volatile Load MonitorEnter | Volatile Store MonitorExit |
Normal Load | LoadStore | |||
Normal Store | StoreStore | |||
Volatile Load MonitorEnter | LoadLoad | LoadStore | LoadLoad | LoadStore |
Volatile Store MonitorExit | StoreLoad | StoreStore |
Из этого всего в частности следует, что:
volatile
чтения. Такой барьер ещё называется барьером с acquire semantics. Этой штукой можно поставить барьер, чтобы убедиться в том, что выше определённого момента никакие инструкции не перенесутся. То есть это ограничение сверху.volatile
записи. Такой барьер ещё называется барьером с release semantics. Этой штукой можно поставить барьер, чтобы убедиться в том, что после определённого момента все чтения/записи вступили в силу. То есть это ограничение снизу.Один из простых вариантов взаимодействия между потоками — это использование общего объекта, в котором инкапсулирован флаг, доступ к которому осуществляется через synchronized методы:
class Signal {
private boolean flag = false;
public synchronized boolean getFlag() {
return flag;
}
public synchronized void setFlag(boolean flag) {
this.flag = flag;
}
}
И дальше один из потоков просто будет находиться в ожидании (busy wait), пока другой поток не выставить флаг:
while (!sharedSignal.getFlag());
С одной стороны, да, это максмально примитивный подход, но иногда он может быть лучше, чем использовать wait-signal, потому что в случае с последним происходит conext switch, а это довольно дорогая операция (и засыпание, и просыпывание (возобновление) потока): нужно сохранить/восстановить состояние процессора: какие-то там регистры, program counter и прочие штуки. Так что, если заранее известно, что ожидание долго не продлится, то имеет смысл сначала немного покрутиться в busy loop, и только потом окончательно проваливаться в ожидание.
Иногда, когда используешь wait-signal, то поток может внезапно проснуться без какой-либо причины (spurious wakeups). Чтобы защититься от таких внезапных wake-up’ов, надо крутить проверку условия в цикле, и, если поток проснулся, но условие ещё не выполнено, то обратно уходить в сон. Такая штука называется spin lock. То есть нужно писать что-то вроде такого, чтобы всё работало корректно:
public class Clazz {
// кстати, если использовать константную строку в качестве ad hoc лока, то
// из-за интернирования строк могут быть проблемы (например, если вызываешь
// notify(), ожидая, что у тебя только один поток в ожидании, а у тебя там
// на этой константной строке завязаны несколько потоков)
private final Object monitor = new Object();
private boolean signalled = false;
public void doWait() throws InterruptedException {
synchronized (monitor) {
while (!signalled) {
monitor.wait();
}
signalled = false;
}
}
public void doNotify() {
synchronized (monitor) {
signalled = true;
// monitor.notifyAll();
monitor.notify();
}
}
}
Такая штука не только защищает от spurious wakeups, но и в какой-то степени предотвращает потерю уведомлений, если по какой-то причине doNotify()
был вызван до doWait()
. Я как-то не знаю, в какой ситуации такое может произойти на практике, видимо, вот как раз при перестановке инструкций компилятором.
notify()
vs notifyAll()
. Ну и также spin lock полезен, если ждут сразу несколько потоков, но у них разные условия для выхода из состояния ожидания. Потому что в таком случае лучше вызывать notifyAll()
, а не просто notify()
, потому что во втором случае, скорее всего, придёт уведомление для неправильного потока. Но notifyAll()
— это дорогая операция (по крайней мере дороже, чем notify()
), и, если в любом случае только один поток должен быть возвращён из состояния ожидания, то тем более лучше вызывать notify()
.