Мини-учебнег по распределенным системам/протоколам. Оптимизации компилятора и процессора
Поэтому по умолчанию и компиляторы, и система исполнения да и процессор, полагают, что код исполняется в однопоточном режиме. Чтобы они считали по другому, им обычно нужно давать специальные указания. В однопоточном режиме же можно делать кучу всеразличных оптимизаций, которые нельзя делать в случае, когда другие потоки управления могут параллельно читать или писать в те участки памяти, которые читает и пишет программа. Соответственно, специальные указания процессору и компилятору позволяют сообщить, что те или иные участки памяти разделяются между процессорами, ну или те или иные куски программы лазают в память, в которую могут лазить другие потоки управления. Например, если мы захватили блокировку, то это одновременно неявное указание компилятору и процессору, что мы сейчас будем читать и писать разделяемые данные. Точно так же, освобождение блокировки - это "намек", что мы закончили работать с разделяемыми данными. Но могут быть и явные указания на разделяемые доступ к даннам - например volatile модификатор в Java.
Т.е. есть несколько видов кода: обычный код, код внутри критических секций, код который пишет или читает volatile переменные. Для них есть разные набор допущений, что позволяет компилятору и процессору использовать или наоборот запрещает использовать те или иные оптимизации. Рассмотрим типовые оптимизации.
Чтение после чтения
a = memA. b = memA
Так как мы уже прочитали локацию памяти memA, то компилятор может заменить второе чтение чтением из регистра, что-нить типа
Если конечно программа ничего не записала в memA в промежутке между двумя инструкциями. Обычно это так и нужно, но если мы например регулярно читаем memA ожидая, ожидая пока другой поток туда запишет какое-то значение, что будет нам сигналом? Например, мы делаем спин-лок. В этом случае, оптимизация все похерит. Так что нужно пометить memA как volatile, соответственно, компилятор будет знаеть, что это инструкция может читаться или писаться другим потоком.
Чтение после записи
memA = a. b = memA
Опять-таки, если мы знаем содержимое memA, то зачем его читать второй раз? Но другой поток опять-таки, может записать какое-то другое значение.
Запись после записи
memA = a. memA = b
Если в промежутке между инструкциями memA не читается, то тогда и a туда записывать смысла нет, все равно потом перетреться b. Но эту локацию в промежутке может читать другой поток управления.
Перестановка инструкций
data = aready = True
Вообще компьютер может переставлять инструкции, если мы получим тот же результат, что и в однопоточном случае. В данном случае, если мы сперва запишем ready = True, а потом что-то в data, а в промежутке вставит еще какие-то инструкции, например, начнет вычислять выражение a, то для однопоточного кода это по барабану. Но если другой поток увидит что ready == True, он может начать лезть в data, считая, что там уже какой-то готовый результат, в то время он еще только считается, вследствие какой-то оптимизации. Вобщем, если мы записали в volatile переменную (неявно считаем что ready как раз такая переменная), то нельзя передвигать другие операции записи, чтобы они были послее нее, а то другой поток может увидеть что ready == True, но не увидеть другие записи.
Рассморим теперь пример кода, который читает это дело
if (ready) process(data)
Допустим компилятор или процессор из каких-то соображений спекулятивно прочитал data раньше ready. В однопоточном случае проблемы и нет, ну прочитал он раньше (при условии конечно что зависимости внутри кода позволяют). А многопоточном контексте, зависимости могут быть другиие, и компилятор их не видит. Быть консервативным - значит отказываться от части оптимизаций. Но можно быть оптимистичным, и чтобы программист явно указывал что и где используется в многопоточном контексте.
Отмечу, что оптимизации может выполнять не только компилятор, но и процессор. Например, процессор может оставить запись в data в кэше, а вот запись в ready записать в основную память, а data из кэша в основную память сбросить позже. Переставив таким образом операции с точки зрения основной памяти. Ну или наоборот, он может прочитать ready из основной памяти, а data - из локального кэша, то что прочитал из основной памяти ранее.
Таким образом, для разных контекстов исполнения кода - многопоточного или однопоточного - могут быть применимы разные оптимизации. Часть оптимизаций валидных в однопоточном контексте приводят к проблемам в многопоточном. Но так как нам важно быть быстрыми в однопоточном случае, мы не можем консервативно полагать что у нас многопоточный случай. Т.е. для скорости, компилятор и процессор должны быть оптимистичными, а забота программистов - давать им указания, что те или иные операции исполняются в многопоточном контексте. Например, это можно делать с помощью memory-barrier - явных или неявных инструкций, что компилятору и/или процессору нельзя переставлять определенные инструкции через барьеры.
Например, в Java Memory Model неявно выполняются memory-barriers при чтении/записи volatile переменных, при захвате/освобождении блокировки, при старте потока и когда код выполняет join() чтобы дождаться завершения выполнения другого потока, ну и в других подобных случаях. Это соответствует так называемой Release/Acquire модели консистентности памяти, когда прежде чем прочитать что-то из разделяемой памяти, нам нужно сделать операцию Acquire, а чтобы быть уверенным, что то что мы записали, достигнет других потоков управления, нам нужно выполнить операцию Release. Соответственно, Acquire неявно выполняется при чтении из volatile переменной, при старте потока, после выхода из join(), при захвате блокировки (входе в synchronized секцию или метод), при выходе из метода wait() ну и т.д. Ну а Release выполняется при записи в volatile переменную, при освобождении блокировки (выходе из synchronized секции или метода), при вызове метода wait(), при старте потока, и в конце потока. Короче, чтобы коммуникация между потоками через память проходила без проблем, поток, который пересылает или потенциально может переслать какую-то информацию должен сбросить ее в основную память с помощью Release, а поток, который хочет получить информацию от других потоков без проблем, должен сделать Acquire. Соответственно, компилятор и среда исполнения позаботяться о том, чтобы не переставлять инструкции, которые бы нарушали эту семантику. А также выдать соответствующие membar инструкции процессору, чтобы он корректно отработал эту семантику на уровне кэшей.
А в остальном, компилятор и процессор могут переставлять инструкции и группировать их, если это не противорчечит семантике однопоточного кода. Я бы сказал что модель консистентности Release/Acquire весьма актуальна для распределенных приложений. Ибо там задержки коммуникаций между потоками управления на разных компьютерах особенно велики, ну и группировать операции чтения/записи особенно важно.