Оглавление

Java Concurrency 3

Cache Coherence

Cache coherence — это uniformity (целостность, не мог вспомнить слово на русском) данных, хранящихся в локальных кэшах для разделяемого ресурса. Проблемы с когерентностью возникают, когда один ресурс кэшируется в нескольких местах, в частности это происходит в multiprocessing системах: например, оба потока читают число 0 из одной и той же ячейки памяти и сохраняют её себе в кэш. После чего они по очереди добавляют к своему значению единицу. При отсутствии когерентности памяти каждый в итоге запишет единицу в main memory.

Когерентность кеша требует чтобы:

Короче, это всё гарантирует то, что всё будет работать так, как мы интуитивно хотим, то есть, чтобы операции чтения/записи потоков были максимально гранулированными и после записи в кэш сразу же производилась запись в main memory. Но на практике это всё реализуется как-то по другому, более по-умному.

img



Memory Barrier

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

# первый поток
while (f == 0);
# здесь нужен memory barrier, потому что иначе print может быть переставлен с циклом
# ожидания и тогда выведется ещё потенциально не заданный икс
print x;

# второй поток
x = 42;
# здесь снова нужен барьер, потому что иначе флаг f может быть выставлен до того, как будет
# присвоено значение иксу
f = 1;


Java Memory Model (JMM)

(ссылка на статью на рбрыварабаре)

Модель памяти Java (Java Memory Model, JMM) определяет то, каким образом потоки взаимодейтсвуют друг с другом и как используют разделяемые данные. Тут же определяются всякие оптимизации, которые может выполнить компилятор.

Основные штуки



Операции, связанные отношением happens-before



Memory Barriers in Java

ссылка на штуки

На самом деле, существует несколько вариантов барьеров. %Первое слово в названии% — действие, эффект которого будет виден прежде, чем будет виден эффект типа %второе слово в названии%:

Да и тут немного важно то, что 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

Из этого всего в частности следует, что:



Thread Signaling

Один из простых вариантов взаимодействия между потоками — это использование общего объекта, в котором инкапсулирован флаг, доступ к которому осуществляется через 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, и только потом окончательно проваливаться в ожидание.



Spurious Wakeups

Иногда, когда используешь 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().